openclaw/src/web/inbound/send-api.ts
Zuri ✦ c3cb338937 feat(whatsapp): extract mentions from text and captions
WhatsApp's Baileys library requires a separate mentions array in the
message payload for @mentions to work properly. Without this array,
text like '@5511999999999' appears as plain text instead of a clickable
mention.

This change automatically extracts phone numbers in the format
@<10-15 digits> from the message text AND media captions, adding them
to the mentions array as JIDs (e.g., '5511999999999@s.whatsapp.net').

Supported message types:
- Text messages
- Image with caption
- Video with caption
- Document with caption

Fixes mentions in group chats when using the message tool.
2026-01-26 23:49:38 -03:00

127 lines
4.2 KiB
TypeScript

import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys";
import { recordChannelActivity } from "../../infra/channel-activity.js";
import { toWhatsappJid } from "../../utils.js";
import type { ActiveWebSendOptions } from "../active-listener.js";
export function createWebSendApi(params: {
sock: {
sendMessage: (jid: string, content: AnyMessageContent) => Promise<unknown>;
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
};
defaultAccountId: string;
}) {
return {
sendMessage: async (
to: string,
text: string,
mediaBuffer?: Buffer,
mediaType?: string,
sendOptions?: ActiveWebSendOptions,
): Promise<{ messageId: string }> => {
const jid = toWhatsappJid(to);
const mentions: string[] = [];
if (text) {
const phoneRegex = /@(\d{10,15})/g;
let match: RegExpExecArray | null;
while ((match = phoneRegex.exec(text)) !== null) {
mentions.push(`${match[1]}@s.whatsapp.net`);
}
}
const mentionSpread = mentions.length > 0 ? { mentions } : {};
let payload: AnyMessageContent;
if (mediaBuffer && mediaType) {
if (mediaType.startsWith("image/")) {
payload = {
image: mediaBuffer,
caption: text || undefined,
mimetype: mediaType,
...mentionSpread,
} as AnyMessageContent;
} 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: text || undefined,
mimetype: mediaType,
...(gifPlayback ? { gifPlayback: true } : {}),
...mentionSpread,
} as AnyMessageContent;
} else {
payload = {
document: mediaBuffer,
fileName: "file",
caption: text || undefined,
mimetype: mediaType,
...mentionSpread,
} as AnyMessageContent;
}
} else {
payload = mentions.length > 0 ? ({ text, mentions } as AnyMessageContent) : { text };
}
const result = await params.sock.sendMessage(jid, payload);
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
recordChannelActivity({
channel: "whatsapp",
accountId,
direction: "outbound",
});
const messageId =
typeof result === "object" && result && "key" in result
? String((result as { key?: { id?: string } }).key?.id ?? "unknown")
: "unknown";
return { messageId };
},
sendPoll: async (
to: string,
poll: { question: string; options: string[]; maxSelections?: number },
): Promise<{ messageId: string }> => {
const jid = toWhatsappJid(to);
const result = await params.sock.sendMessage(jid, {
poll: {
name: poll.question,
values: poll.options,
selectableCount: poll.maxSelections ?? 1,
},
} as AnyMessageContent);
recordChannelActivity({
channel: "whatsapp",
accountId: params.defaultAccountId,
direction: "outbound",
});
const messageId =
typeof result === "object" && result && "key" in result
? String((result as { key?: { id?: string } }).key?.id ?? "unknown")
: "unknown";
return { messageId };
},
sendReaction: async (
chatJid: string,
messageId: string,
emoji: string,
fromMe: boolean,
participant?: string,
): Promise<void> => {
const jid = toWhatsappJid(chatJid);
await params.sock.sendMessage(jid, {
react: {
text: emoji,
key: {
remoteJid: jid,
id: messageId,
fromMe,
participant: participant ? toWhatsappJid(participant) : undefined,
},
},
} as AnyMessageContent);
},
sendComposingTo: async (to: string): Promise<void> => {
const jid = toWhatsappJid(to);
await params.sock.sendPresenceUpdate("composing", jid);
},
} as const;
}