mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
* refactor: replace sharp image backend with photon * refactor: remove whatsapp jimp dependency * chore: remove stale sharp install workarounds * test: keep image fixtures off photon * test: use valid prompt image fixtures * test: account for optimized PNG fixtures * test: use valid minimax image fixtures
204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
import type {
|
|
AnyMessageContent,
|
|
MiscMessageGenerationOptions,
|
|
WAMessage,
|
|
WAPresence,
|
|
} from "baileys";
|
|
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
|
import { resolveWhatsAppDocumentFileName } from "../document-filename.js";
|
|
import { addWhatsAppImagePreviewFields } from "../image-preview.js";
|
|
import { isWhatsAppNewsletterJid } from "../normalize.js";
|
|
import { buildQuotedMessageOptions } from "../quoted-message.js";
|
|
import { toWhatsappJid, toWhatsappJidWithLid } from "../text-runtime.js";
|
|
import {
|
|
addWhatsAppOutboundMentionsToContent,
|
|
type WhatsAppOutboundMentionResolution,
|
|
} from "./outbound-mentions.js";
|
|
import {
|
|
combineWhatsAppSendResults,
|
|
normalizeWhatsAppSendResult,
|
|
type WhatsAppSendResult,
|
|
} from "./send-result.js";
|
|
import type { ActiveWebSendOptions } from "./types.js";
|
|
|
|
function recordWhatsAppOutbound(accountId: string) {
|
|
recordChannelActivity({
|
|
channel: "whatsapp",
|
|
accountId,
|
|
direction: "outbound",
|
|
});
|
|
}
|
|
|
|
function supportsForcedDocumentMediaType(mediaType: string): boolean {
|
|
return mediaType.startsWith("image/") || mediaType.startsWith("video/");
|
|
}
|
|
|
|
export function createWebSendApi(params: {
|
|
sock: {
|
|
sendMessage: (
|
|
jid: string,
|
|
content: AnyMessageContent,
|
|
options?: MiscMessageGenerationOptions,
|
|
) => Promise<WAMessage | undefined>;
|
|
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
|
|
};
|
|
defaultAccountId: string;
|
|
resolveOutboundMentions?: (params: {
|
|
jid: string;
|
|
text: string;
|
|
}) => Promise<WhatsAppOutboundMentionResolution> | WhatsAppOutboundMentionResolution;
|
|
// When provided, lets outbound resolve `{phone}@s.whatsapp.net` to `{lid}@lid`
|
|
// via Baileys' lid-mapping-{phone-digits}.json files in the auth dir, so
|
|
// proactive sends to LID-addressed contacts reach the recipient instead of
|
|
// ending up in a sender-only ghost chat (#67378). Defaults to PN-only.
|
|
authDir?: string;
|
|
}) {
|
|
const resolveOutboundJid = (recipient: string): string =>
|
|
params.authDir
|
|
? toWhatsappJidWithLid(recipient, { authDir: params.authDir })
|
|
: toWhatsappJid(recipient);
|
|
const resolveMentions = async (
|
|
jid: string,
|
|
text: string,
|
|
): Promise<WhatsAppOutboundMentionResolution> =>
|
|
params.resolveOutboundMentions
|
|
? await params.resolveOutboundMentions({ jid, text })
|
|
: { text, mentionedJids: [] };
|
|
|
|
return {
|
|
sendMessage: async (
|
|
to: string,
|
|
text: string,
|
|
mediaBuffer?: Buffer,
|
|
mediaType?: string,
|
|
sendOptions?: ActiveWebSendOptions,
|
|
): Promise<WhatsAppSendResult> => {
|
|
const jid = resolveOutboundJid(to);
|
|
let payload: AnyMessageContent;
|
|
if (mediaBuffer) {
|
|
mediaType ??= "application/octet-stream";
|
|
}
|
|
const shouldSendAudioText = Boolean(
|
|
mediaBuffer && mediaType?.startsWith("audio/") && text.trim(),
|
|
);
|
|
const resolvedPayloadText = shouldSendAudioText
|
|
? { text, mentionedJids: [] }
|
|
: await resolveMentions(jid, text);
|
|
if (mediaBuffer && mediaType) {
|
|
if (sendOptions?.asDocument === true && supportsForcedDocumentMediaType(mediaType)) {
|
|
const fileName = resolveWhatsAppDocumentFileName({
|
|
fileName: sendOptions?.fileName,
|
|
mimetype: mediaType,
|
|
});
|
|
payload = {
|
|
document: mediaBuffer,
|
|
fileName,
|
|
caption: resolvedPayloadText.text || undefined,
|
|
mimetype: mediaType,
|
|
};
|
|
} else if (mediaType.startsWith("image/")) {
|
|
payload = await addWhatsAppImagePreviewFields({
|
|
image: mediaBuffer,
|
|
caption: resolvedPayloadText.text || undefined,
|
|
mimetype: mediaType,
|
|
});
|
|
} else if (mediaType.startsWith("audio/")) {
|
|
payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType };
|
|
} else if (mediaType.startsWith("video/")) {
|
|
const gifPlayback = sendOptions?.gifPlayback;
|
|
payload = {
|
|
video: mediaBuffer,
|
|
caption: resolvedPayloadText.text || undefined,
|
|
mimetype: mediaType,
|
|
...(gifPlayback ? { gifPlayback: true } : {}),
|
|
};
|
|
} else {
|
|
const fileName = resolveWhatsAppDocumentFileName({
|
|
fileName: sendOptions?.fileName,
|
|
mimetype: mediaType,
|
|
});
|
|
payload = {
|
|
document: mediaBuffer,
|
|
fileName,
|
|
caption: resolvedPayloadText.text || undefined,
|
|
mimetype: mediaType,
|
|
};
|
|
}
|
|
} else {
|
|
payload = { text: resolvedPayloadText.text };
|
|
}
|
|
payload = addWhatsAppOutboundMentionsToContent(payload, resolvedPayloadText.mentionedJids);
|
|
const quotedOpts = buildQuotedMessageOptions({
|
|
messageId: sendOptions?.quotedMessageKey?.id,
|
|
remoteJid: sendOptions?.quotedMessageKey?.remoteJid,
|
|
fromMe: sendOptions?.quotedMessageKey?.fromMe,
|
|
participant: sendOptions?.quotedMessageKey?.participant,
|
|
messageText: sendOptions?.quotedMessageKey?.messageText,
|
|
});
|
|
const result = quotedOpts
|
|
? await params.sock.sendMessage(jid, payload, quotedOpts)
|
|
: await params.sock.sendMessage(jid, payload);
|
|
const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")];
|
|
if (shouldSendAudioText) {
|
|
const resolvedAudioText = await resolveMentions(jid, text);
|
|
const textPayload = addWhatsAppOutboundMentionsToContent(
|
|
{ text: resolvedAudioText.text },
|
|
resolvedAudioText.mentionedJids,
|
|
);
|
|
const textResult = quotedOpts
|
|
? await params.sock.sendMessage(jid, textPayload, quotedOpts)
|
|
: await params.sock.sendMessage(jid, textPayload);
|
|
results.push(normalizeWhatsAppSendResult(textResult, "text"));
|
|
}
|
|
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
|
|
recordWhatsAppOutbound(accountId);
|
|
return combineWhatsAppSendResults(mediaBuffer ? "media" : "text", results);
|
|
},
|
|
sendPoll: async (
|
|
to: string,
|
|
poll: { question: string; options: string[]; maxSelections?: number },
|
|
): Promise<WhatsAppSendResult> => {
|
|
const jid = resolveOutboundJid(to);
|
|
const result = await params.sock.sendMessage(jid, {
|
|
poll: {
|
|
name: poll.question,
|
|
values: poll.options,
|
|
selectableCount: poll.maxSelections ?? 1,
|
|
},
|
|
} as AnyMessageContent);
|
|
recordWhatsAppOutbound(params.defaultAccountId);
|
|
return normalizeWhatsAppSendResult(result, "poll");
|
|
},
|
|
sendReaction: async (
|
|
chatJid: string,
|
|
messageId: string,
|
|
emoji: string,
|
|
fromMe: boolean,
|
|
participant?: string,
|
|
): Promise<WhatsAppSendResult> => {
|
|
// Resolve DM targets through the same LID-aware path as normal sends so
|
|
// reactions land on the delivered WhatsApp message key.
|
|
const jid = resolveOutboundJid(chatJid);
|
|
const result = await params.sock.sendMessage(jid, {
|
|
react: {
|
|
text: emoji,
|
|
key: {
|
|
remoteJid: jid,
|
|
id: messageId,
|
|
fromMe,
|
|
participant: participant ? toWhatsappJid(participant) : undefined,
|
|
},
|
|
},
|
|
} as AnyMessageContent);
|
|
return normalizeWhatsAppSendResult(result, "reaction");
|
|
},
|
|
sendComposingTo: async (to: string): Promise<void> => {
|
|
const jid = resolveOutboundJid(to);
|
|
if (isWhatsAppNewsletterJid(jid)) {
|
|
return;
|
|
}
|
|
await params.sock.sendPresenceUpdate("composing", jid);
|
|
},
|
|
} as const;
|
|
}
|