diff --git a/CHANGELOG.md b/CHANGELOG.md index abfada25e7e..a05f47ceb40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. (#76449) Thanks @joshavant. - Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc. - Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev. +- Channels/WhatsApp: attach native outbound mention metadata for group text and media captions by resolving `@+` and `@` tokens against WhatsApp participant data, including LID groups. Fixes #39879; carries forward #56863. Thanks @kengi1437, @joe2643, and @fridayck. - Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc. - Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc. - CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index d23dfc7741e..948157ae858 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -153,6 +153,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window. - Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts. - Outbound sends require an active WhatsApp listener for the target account. +- Group sends attach native mention metadata for `@+` and `@` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups. - Status and broadcast chats are ignored (`@status`, `@broadcast`). - The reconnect watchdog follows WhatsApp Web transport activity, not only inbound app-message volume: quiet linked-device sessions stay up while transport frames continue, but a transport stall forces reconnect well before the later remote disconnect path. - Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session). diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 826143e274e..e1212fcc2d8 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -40,6 +40,12 @@ import { } from "./extract.js"; import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js"; import { downloadInboundMedia, downloadQuotedInboundMedia } from "./media.js"; +import { + addWhatsAppOutboundMentionsToContent, + mayContainWhatsAppOutboundMention, + resolveWhatsAppOutboundMentions, + type WhatsAppOutboundMentionParticipant, +} from "./outbound-mentions.js"; import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js"; import { createWebSendApi } from "./send-api.js"; import { normalizeWhatsAppSendResult } from "./send-result.js"; @@ -57,6 +63,7 @@ type WhatsAppGroupMetadataCacheEntry = { export type WhatsAppGroupMetadataCache = Map; type LocalGroupMetadataCacheEntry = WhatsAppGroupMetadataCacheEntry & { participants?: string[]; + mentionParticipants?: WhatsAppOutboundMentionParticipant[]; }; function rememberGroupMetadataCacheEntry( @@ -355,18 +362,26 @@ export async function attachWebInboxToSocket( }; const summarizeGroupMeta = async (meta: GroupMetadata) => { - const participants = - ( - await Promise.all( - meta.participants?.map(async (p) => { - const mapped = await resolveInboundJid(p.id); - return mapped ?? p.id; - }) ?? [], - ) - ).filter(Boolean) ?? []; + const participantEntries = await Promise.all( + meta.participants?.map(async (p) => { + const mapped = await resolveInboundJid(p.id); + return { + display: mapped ?? p.id, + mention: { + id: p.id, + lid: p.lid, + phoneNumber: p.phoneNumber, + e164: mapped, + } satisfies WhatsAppOutboundMentionParticipant, + }; + }) ?? [], + ); + const participants = participantEntries.map((entry) => entry.display).filter(Boolean); + const mentionParticipants = participantEntries.map((entry) => entry.mention); return { subject: meta.subject, participants, + mentionParticipants, expires: Date.now() + GROUP_META_TTL_MS, }; }; @@ -384,7 +399,7 @@ export async function attachWebInboxToSocket( return cached; } try { - const meta = await sock.groupMetadata(jid); + const meta = await (getCurrentSock() ?? sock).groupMetadata(jid); const entry = await summarizeGroupMeta(meta); rememberGroupMetadataCacheEntry(groupMetadataCache, jid, { subject: entry.subject, @@ -410,6 +425,43 @@ export async function attachWebInboxToSocket( } }; + const resolveOutboundMentionsForGroup = async ( + jid: string, + text: string, + ): Promise<{ text: string; mentionedJids: string[] }> => { + if (!isGroupJid(jid) || !mayContainWhatsAppOutboundMention(text)) { + return { text, mentionedJids: [] }; + } + const meta = await getGroupMeta(jid); + return resolveWhatsAppOutboundMentions({ + chatJid: jid, + text, + participants: meta.mentionParticipants, + }); + }; + + const applyOutboundMentionsToContent = async ( + jid: string, + content: AnyMessageContent, + ): Promise => { + if ("text" in content && typeof content.text === "string") { + const resolved = await resolveOutboundMentionsForGroup(jid, content.text); + return addWhatsAppOutboundMentionsToContent( + { ...content, text: resolved.text } as AnyMessageContent, + resolved.mentionedJids, + ); + } + const caption = (content as { caption?: unknown }).caption; + if (typeof caption === "string") { + const resolved = await resolveOutboundMentionsForGroup(jid, caption); + return addWhatsAppOutboundMentionsToContent( + { ...content, caption: resolved.text } as AnyMessageContent, + resolved.mentionedJids, + ); + } + return content; + }; + type NormalizedInboundMessage = { id?: string; remoteJid: string; @@ -632,14 +684,23 @@ export async function attachWebInboxToSocket( } }; const reply = async (text: string, options?: MiscMessageGenerationOptions) => { - const result = await sendTrackedMessage(chatJid, { text }, options); + const resolved = await resolveOutboundMentionsForGroup(chatJid, text); + const result = await sendTrackedMessage( + chatJid, + addWhatsAppOutboundMentionsToContent({ text: resolved.text }, resolved.mentionedJids), + options, + ); return normalizeWhatsAppSendResult(result, "text"); }; const sendMedia = async ( payload: AnyMessageContent, options?: MiscMessageGenerationOptions, ) => { - const result = await sendTrackedMessage(chatJid, payload, options); + const result = await sendTrackedMessage( + chatJid, + await applyOutboundMentionsToContent(chatJid, payload), + options, + ); return normalizeWhatsAppSendResult(result, "media"); }; const timestamp = inbound.messageTimestampMs; @@ -856,6 +917,7 @@ export async function attachWebInboxToSocket( }, }, defaultAccountId: options.accountId, + resolveOutboundMentions: ({ jid, text }) => resolveOutboundMentionsForGroup(jid, text), }); return { diff --git a/extensions/whatsapp/src/inbound/outbound-mentions.test.ts b/extensions/whatsapp/src/inbound/outbound-mentions.test.ts new file mode 100644 index 00000000000..4457e0e9450 --- /dev/null +++ b/extensions/whatsapp/src/inbound/outbound-mentions.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js"; + +describe("resolveWhatsAppOutboundMentions", () => { + it("resolves phone-number tokens to WhatsApp participant JIDs", () => { + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "120363000000000000@g.us", + text: "hi @+15551234567 and @15557654321", + participants: [{ id: "15551234567@s.whatsapp.net" }, { id: "15557654321@s.whatsapp.net" }], + }), + ).toEqual({ + text: "hi @+15551234567 and @15557654321", + mentionedJids: ["15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"], + }); + }); + + it("rewrites phone-number tokens to LID mention text without device suffixes", () => { + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "120363000000000000@g.us", + text: "ping @+5511976136970", + participants: [ + { + id: "277038292303944:2@lid", + phoneNumber: "5511976136970@s.whatsapp.net", + }, + ], + }), + ).toEqual({ + text: "ping @277038292303944", + mentionedJids: ["277038292303944@lid"], + }); + }); + + it("uses resolved E.164 metadata when LID participant records omit phoneNumber", () => { + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "120363000000000000@g.us", + text: "ping @15551234567", + participants: [ + { + id: "277038292303944@lid", + e164: "+15551234567", + }, + ], + }), + ).toEqual({ + text: "ping @277038292303944", + mentionedJids: ["277038292303944@lid"], + }); + }); + + it("prefers explicit LID metadata over a phone JID id", () => { + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "120363000000000000@g.us", + text: "ping @15551234567 and @277038292303944", + participants: [ + { + id: "15551234567@s.whatsapp.net", + lid: "277038292303944@lid", + }, + ], + }), + ).toEqual({ + text: "ping @277038292303944 and @277038292303944", + mentionedJids: ["277038292303944@lid"], + }); + }); + + it("uses bare digit tokens for LIDs before phone numbers when participant keys collide", () => { + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "120363000000000000@g.us", + text: "ping @277038292303944 and @+277038292303944", + participants: [{ id: "277038292303944@s.whatsapp.net" }, { id: "277038292303944@lid" }], + }), + ).toEqual({ + text: "ping @277038292303944 and @+277038292303944", + mentionedJids: ["277038292303944@lid", "277038292303944@s.whatsapp.net"], + }); + }); + + it("applies LID rewrites by match position while skipping code spans", () => { + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "120363000000000000@g.us", + text: [ + "visible @+5511976136970", + "`inline @+5511976136970`", + "```", + "fenced @+5511976136970", + "```", + "again @+5511976136970", + ].join("\n"), + participants: [ + { + id: "277038292303944:9@lid", + phoneNumber: "5511976136970@s.whatsapp.net", + }, + ], + }), + ).toEqual({ + text: [ + "visible @277038292303944", + "`inline @+5511976136970`", + "```", + "fenced @+5511976136970", + "```", + "again @277038292303944", + ].join("\n"), + mentionedJids: ["277038292303944@lid"], + }); + }); + + it("does not add mention metadata for direct chats or unmatched group participants", () => { + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "15551234567@s.whatsapp.net", + text: "hi @+15551234567", + participants: [{ id: "15551234567@s.whatsapp.net" }], + }), + ).toEqual({ text: "hi @+15551234567", mentionedJids: [] }); + expect( + resolveWhatsAppOutboundMentions({ + chatJid: "120363000000000000@g.us", + text: "hi @+15551234567", + participants: [{ id: "15550000000@s.whatsapp.net" }], + }), + ).toEqual({ text: "hi @+15551234567", mentionedJids: [] }); + }); +}); diff --git a/extensions/whatsapp/src/inbound/outbound-mentions.ts b/extensions/whatsapp/src/inbound/outbound-mentions.ts new file mode 100644 index 00000000000..ed165edbd48 --- /dev/null +++ b/extensions/whatsapp/src/inbound/outbound-mentions.ts @@ -0,0 +1,258 @@ +import type { AnyMessageContent } from "@whiskeysockets/baileys"; + +export type WhatsAppOutboundMentionParticipant = + | string + | { + id?: string | null; + lid?: string | null; + phoneNumber?: string | null; + e164?: string | null; + }; + +export type WhatsAppOutboundMentionResolution = { + text: string; + mentionedJids: string[]; +}; + +const CODE_FENCE_RE = /```[\s\S]*?```/g; +const INLINE_CODE_RE = /`[^`\n]+`/g; +const OUTBOUND_MENTION_RE = /@(\+?\d+)/g; +const KNOWN_USER_JID_RE = /^(\d+)(?::\d+)?@(s\.whatsapp\.net|hosted|lid|hosted\.lid|c\.us)$/i; +const PHONE_JID_DOMAIN_RE = /^(s\.whatsapp\.net|hosted|c\.us)$/i; +const LID_JID_DOMAIN_RE = /^(lid|hosted\.lid)$/i; + +type TextRange = { + start: number; + end: number; +}; + +type MentionTarget = { + mentionJid: string; + replacementText?: string; +}; + +function isWhatsAppGroupJid(jid: string): boolean { + return jid.endsWith("@g.us"); +} + +export function mayContainWhatsAppOutboundMention(text: string): boolean { + return /@\+?\d/.test(text); +} + +function collectCodeRanges(text: string): TextRange[] { + const ranges: TextRange[] = []; + for (const match of text.matchAll(CODE_FENCE_RE)) { + ranges.push({ start: match.index, end: match.index + match[0].length }); + } + for (const match of text.matchAll(INLINE_CODE_RE)) { + const start = match.index; + if (ranges.some((range) => start >= range.start && start < range.end)) { + continue; + } + ranges.push({ start, end: start + match[0].length }); + } + return ranges.toSorted((a, b) => a.start - b.start); +} + +function isInRange(index: number, ranges: readonly TextRange[]): boolean { + return ranges.some((range) => index >= range.start && index < range.end); +} + +function normalizeKnownUserJid(value: string): string | null { + const trimmed = value.replace(/^whatsapp:/i, "").trim(); + const jidMatch = trimmed.match(KNOWN_USER_JID_RE); + if (jidMatch) { + const domain = + jidMatch[2].toLowerCase() === "c.us" ? "s.whatsapp.net" : jidMatch[2].toLowerCase(); + return `${jidMatch[1]}@${domain}`; + } + const digits = trimmed.startsWith("+") + ? trimmed.replace(/\D/g, "") + : /^\d+$/.test(trimmed) + ? trimmed + : ""; + return digits ? `${digits}@s.whatsapp.net` : null; +} + +function extractKnownJidParts(value: string): { user: string; domain: string } | null { + const normalized = normalizeKnownUserJid(value); + if (!normalized) { + return null; + } + const match = normalized.match(/^(\d+)@(.+)$/); + return match ? { user: match[1], domain: match[2] } : null; +} + +function extractPhoneDigits(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const trimmed = value.replace(/^whatsapp:/i, "").trim(); + if (trimmed.startsWith("+") || /^\d+$/.test(trimmed)) { + const digits = trimmed.replace(/\D/g, ""); + return digits || null; + } + const parts = extractKnownJidParts(trimmed); + return parts && PHONE_JID_DOMAIN_RE.test(parts.domain) ? parts.user : null; +} + +function extractLidDigits(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const parts = extractKnownJidParts(value); + return parts && LID_JID_DOMAIN_RE.test(parts.domain) ? parts.user : null; +} + +function isLidJid(jid: string): boolean { + const parts = extractKnownJidParts(jid); + return Boolean(parts && LID_JID_DOMAIN_RE.test(parts.domain)); +} + +function lidReplacementText(jid: string): string | undefined { + const parts = extractKnownJidParts(jid); + if (!parts || !LID_JID_DOMAIN_RE.test(parts.domain)) { + return undefined; + } + return `@${parts.user}`; +} + +function participantValues(participant: WhatsAppOutboundMentionParticipant): { + id?: string | null; + lid?: string | null; + phoneNumber?: string | null; + e164?: string | null; +} { + return typeof participant === "string" ? { id: participant } : participant; +} + +function chooseMentionJid(participant: WhatsAppOutboundMentionParticipant): string | null { + const values = participantValues(participant); + const idJid = normalizeKnownUserJid(values.id ?? ""); + const lidJid = normalizeKnownUserJid(values.lid ?? ""); + return ( + (idJid && isLidJid(idJid) ? idJid : null) ?? + (lidJid && isLidJid(lidJid) ? lidJid : null) ?? + idJid ?? + lidJid ?? + normalizeKnownUserJid(values.phoneNumber ?? "") ?? + normalizeKnownUserJid(values.e164 ?? "") + ); +} + +function buildMentionTargetMaps(participants: readonly WhatsAppOutboundMentionParticipant[]): { + byPhone: Map; + byLid: Map; +} { + const byPhone = new Map(); + const byLid = new Map(); + for (const participant of participants) { + const mentionJid = chooseMentionJid(participant); + if (!mentionJid) { + continue; + } + const target = { + mentionJid, + ...(isLidJid(mentionJid) ? { replacementText: lidReplacementText(mentionJid) } : {}), + }; + const values = participantValues(participant); + for (const value of [values.id, values.phoneNumber, values.e164]) { + const digits = extractPhoneDigits(value); + if (digits && !byPhone.has(digits)) { + byPhone.set(digits, target); + } + } + for (const value of [values.id, values.lid]) { + const digits = extractLidDigits(value); + if (digits && !byLid.has(digits)) { + byLid.set(digits, target); + } + } + } + return { byPhone, byLid }; +} + +function shouldSkipMentionAt( + text: string, + index: number, + codeRanges: readonly TextRange[], +): boolean { + if (isInRange(index, codeRanges)) { + return true; + } + const previous = index > 0 ? text[index - 1] : ""; + return Boolean(previous && /[\w@]/.test(previous)); +} + +export function resolveWhatsAppOutboundMentions(params: { + chatJid: string; + text: string; + participants?: readonly WhatsAppOutboundMentionParticipant[]; +}): WhatsAppOutboundMentionResolution { + if ( + !isWhatsAppGroupJid(params.chatJid) || + !mayContainWhatsAppOutboundMention(params.text) || + !params.participants?.length + ) { + return { text: params.text, mentionedJids: [] }; + } + + const { byPhone, byLid } = buildMentionTargetMaps(params.participants); + if (byPhone.size === 0 && byLid.size === 0) { + return { text: params.text, mentionedJids: [] }; + } + + const codeRanges = collectCodeRanges(params.text); + const replacements: Array<{ start: number; end: number; text: string }> = []; + const mentionedJids: string[] = []; + const seenMentionJids = new Set(); + + for (const match of params.text.matchAll(OUTBOUND_MENTION_RE)) { + const start = match.index; + if (shouldSkipMentionAt(params.text, start, codeRanges)) { + continue; + } + const token = match[0]; + const digits = match[1].replace(/\D/g, ""); + const target = token.startsWith("@+") + ? (byPhone.get(digits) ?? byLid.get(digits)) + : (byLid.get(digits) ?? byPhone.get(digits)); + if (!target) { + continue; + } + if (!seenMentionJids.has(target.mentionJid)) { + seenMentionJids.add(target.mentionJid); + mentionedJids.push(target.mentionJid); + } + if (target.replacementText && target.replacementText !== token) { + replacements.push({ + start, + end: start + token.length, + text: target.replacementText, + }); + } + } + + if (replacements.length === 0) { + return { text: params.text, mentionedJids }; + } + + let text = ""; + let cursor = 0; + for (const replacement of replacements) { + text += params.text.slice(cursor, replacement.start); + text += replacement.text; + cursor = replacement.end; + } + text += params.text.slice(cursor); + return { text, mentionedJids }; +} + +export function addWhatsAppOutboundMentionsToContent( + content: AnyMessageContent, + mentionedJids: readonly string[], +): AnyMessageContent { + return mentionedJids.length > 0 + ? ({ ...content, mentions: [...mentionedJids] } as AnyMessageContent) + : content; +} diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index 99fc7c4b316..db55fb98d88 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -4,6 +4,7 @@ import type { WAMessage, } from "@whiskeysockets/baileys"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js"; import { createWebSendApi } from "./send-api.js"; const recordChannelActivity = vi.hoisted(() => vi.fn()); @@ -86,6 +87,31 @@ describe("createWebSendApi", () => { }); }); + it("adds native mention metadata to group text sends", async () => { + api = createWebSendApi({ + sock: { sendMessage, sendPresenceUpdate }, + defaultAccountId: "main", + resolveOutboundMentions: ({ jid, text }) => + resolveWhatsAppOutboundMentions({ + chatJid: jid, + text, + participants: [ + { + id: "277038292303944:4@lid", + phoneNumber: "5511976136970@s.whatsapp.net", + }, + ], + }), + }); + + await api.sendMessage("120363000000000000@g.us", "ping @+5511976136970"); + + expect(sendMessage).toHaveBeenCalledWith("120363000000000000@g.us", { + text: "ping @277038292303944", + mentions: ["277038292303944@lid"], + }); + }); + it("supports image media with caption", async () => { const payload = Buffer.from("img"); await api.sendMessage("+1555", "cap", payload, "image/jpeg"); @@ -99,6 +125,32 @@ describe("createWebSendApi", () => { ); }); + it("adds native mention metadata to group media captions", async () => { + api = createWebSendApi({ + sock: { sendMessage, sendPresenceUpdate }, + defaultAccountId: "main", + resolveOutboundMentions: ({ jid, text }) => + resolveWhatsAppOutboundMentions({ + chatJid: jid, + text, + participants: [{ id: "15551234567@s.whatsapp.net" }], + }), + }); + const payload = Buffer.from("img"); + + await api.sendMessage("120363000000000000@g.us", "cap @15551234567", payload, "image/jpeg"); + + expect(sendMessage).toHaveBeenCalledWith( + "120363000000000000@g.us", + expect.objectContaining({ + image: payload, + caption: "cap @15551234567", + mimetype: "image/jpeg", + mentions: ["15551234567@s.whatsapp.net"], + }), + ); + }); + it("supports audio as push-to-talk voice note", async () => { const payload = Buffer.from("aud"); await api.sendMessage("+1555", "", payload, "audio/ogg", { accountId: "alt" }); diff --git a/extensions/whatsapp/src/inbound/send-api.ts b/extensions/whatsapp/src/inbound/send-api.ts index fb2a4f99f6a..97c63cc340d 100644 --- a/extensions/whatsapp/src/inbound/send-api.ts +++ b/extensions/whatsapp/src/inbound/send-api.ts @@ -8,6 +8,10 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runt import { isWhatsAppNewsletterJid } from "../normalize.js"; import { buildQuotedMessageOptions } from "../quoted-message.js"; import { toWhatsappJid } from "../text-runtime.js"; +import { + addWhatsAppOutboundMentionsToContent, + type WhatsAppOutboundMentionResolution, +} from "./outbound-mentions.js"; import { combineWhatsAppSendResults, normalizeWhatsAppSendResult, @@ -33,7 +37,19 @@ export function createWebSendApi(params: { sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise; }; defaultAccountId: string; + resolveOutboundMentions?: (params: { + jid: string; + text: string; + }) => Promise | WhatsAppOutboundMentionResolution; }) { + const resolveMentions = async ( + jid: string, + text: string, + ): Promise => + params.resolveOutboundMentions + ? await params.resolveOutboundMentions({ jid, text }) + : { text, mentionedJids: [] }; + return { sendMessage: async ( to: string, @@ -47,11 +63,17 @@ export function createWebSendApi(params: { 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 (mediaType.startsWith("image/")) { payload = { image: mediaBuffer, - caption: text || undefined, + caption: resolvedPayloadText.text || undefined, mimetype: mediaType, }; } else if (mediaType.startsWith("audio/")) { @@ -60,7 +82,7 @@ export function createWebSendApi(params: { const gifPlayback = sendOptions?.gifPlayback; payload = { video: mediaBuffer, - caption: text || undefined, + caption: resolvedPayloadText.text || undefined, mimetype: mediaType, ...(gifPlayback ? { gifPlayback: true } : {}), }; @@ -69,13 +91,14 @@ export function createWebSendApi(params: { payload = { document: mediaBuffer, fileName, - caption: text || undefined, + caption: resolvedPayloadText.text || undefined, mimetype: mediaType, }; } } else { - payload = { text }; + payload = { text: resolvedPayloadText.text }; } + payload = addWhatsAppOutboundMentionsToContent(payload, resolvedPayloadText.mentionedJids); const quotedOpts = buildQuotedMessageOptions({ messageId: sendOptions?.quotedMessageKey?.id, remoteJid: sendOptions?.quotedMessageKey?.remoteJid, @@ -87,8 +110,12 @@ export function createWebSendApi(params: { ? await params.sock.sendMessage(jid, payload, quotedOpts) : await params.sock.sendMessage(jid, payload); const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")]; - if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) { - const textPayload: AnyMessageContent = { 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);