diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index c7b2f9af28b..11905a3bbca 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -604,6 +604,28 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances).toHaveLength(1); }); + it("respects blockStreamingDefault: on from agents config", async () => { + const result = createFeishuReplyDispatcher({ + cfg: { agents: { defaults: { blockStreamingDefault: "on" } } } as never, + agentId: "agent", + runtime: createRuntimeLogger(), + chatId: "oc_chat", + }); + + expect(result.replyOptions.disableBlockStreaming).toBe(false); + }); + + it("disables block streaming by default when blockStreamingDefault is not set", async () => { + const result = createFeishuReplyDispatcher({ + cfg: { agents: { defaults: {} } } as never, + agentId: "agent", + runtime: createRuntimeLogger(), + chatId: "oc_chat", + }); + + expect(result.replyOptions.disableBlockStreaming).toBe(true); + }); + it("passes replyToMessageId and replyInThread to streaming.start()", async () => { const { options } = createDispatcherHarness({ runtime: createRuntimeLogger(), diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 6ab7184c8e8..930ee50e094 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -479,7 +479,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected, - disableBlockStreaming: true, + disableBlockStreaming: cfg.agents?.defaults?.blockStreamingDefault !== "on", onPartialReply: streamingEnabled ? (payload: ReplyPayload) => { if (!payload.text) { diff --git a/extensions/whatsapp/src/active-listener.ts b/extensions/whatsapp/src/active-listener.ts index 8b62d15ff1f..0f061d523e9 100644 --- a/extensions/whatsapp/src/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -6,6 +6,8 @@ export type ActiveWebSendOptions = { gifPlayback?: boolean; accountId?: string; fileName?: string; + replyToId?: string; + mentionedJids?: string[]; }; export type ActiveWebListener = { @@ -15,6 +17,8 @@ export type ActiveWebListener = { mediaBuffer?: Buffer, mediaType?: string, options?: ActiveWebSendOptions, + replyToId?: string, + mentionedJids?: string[], ) => Promise<{ messageId: string }>; sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; sendReaction: ( diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index 2a28a636fff..47f99d85d1f 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -107,6 +107,7 @@ describe("deliverWebReply", () => { expect(msg.reply).toHaveBeenCalledTimes(1); expect(msg.reply).toHaveBeenCalledWith( "Intro line\nReasoning: appears in content but is not a prefix", + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), ); }); @@ -123,8 +124,14 @@ describe("deliverWebReply", () => { }); expect(msg.reply).toHaveBeenCalledTimes(2); - expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa"); - expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa"); + expect((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]).toEqual([ + "aaa", + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), + ]); + expect((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[1]).toEqual([ + "aaa", + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), + ]); expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)"); }); @@ -175,8 +182,12 @@ describe("deliverWebReply", () => { caption: "aaa", mimetype: "image/jpeg", }), + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), + ); + expect(msg.reply).toHaveBeenCalledWith( + "aaa", + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), ); - expect(msg.reply).toHaveBeenCalledWith("aaa"); expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (media)"); expect(logVerbose).toHaveBeenCalled(); }); @@ -220,6 +231,9 @@ describe("deliverWebReply", () => { expect( String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]), ).toContain("⚠️ Media failed"); + expect((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), + ); expect(replyLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ mediaUrl: "http://example.com/img.jpg" }), "failed to send web media reply", @@ -252,6 +266,7 @@ describe("deliverWebReply", () => { mimetype: "audio/ogg", caption: "cap", }), + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), ); }); @@ -280,6 +295,7 @@ describe("deliverWebReply", () => { caption: "cap", mimetype: "video/mp4", }), + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), ); }); @@ -310,6 +326,7 @@ describe("deliverWebReply", () => { caption: "cap", mimetype: "application/octet-stream", }), + expect.objectContaining({ replyToId: undefined, mentionedJids: undefined }), ); }); }); diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 92501c46fdd..d9072f856ad 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -57,6 +57,10 @@ export async function deliverWebReply(params: { ); const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode); const mediaList = resolveOutboundMediaUrls(replyResult); + const replyOpts = { + replyToId: replyResult.replyToId, + mentionedJids: replyResult.mentionedJids, + }; const sendWithRetry = async (fn: () => Promise, label: string, maxAttempts = 3) => { let lastErr: unknown; @@ -86,7 +90,7 @@ export async function deliverWebReply(params: { const totalChunks = textChunks.length; for (const [index, chunk] of textChunks.entries()) { const chunkStarted = Date.now(); - await sendWithRetry(() => msg.reply(chunk), "text"); + await sendWithRetry(() => msg.reply(chunk, replyOpts), "text"); if (!skipLog) { const durationMs = Date.now() - chunkStarted; whatsappOutboundLog.debug( @@ -132,32 +136,41 @@ export async function deliverWebReply(params: { if (media.kind === "image") { await sendWithRetry( () => - msg.sendMedia({ - image: media.buffer, - caption, - mimetype: media.contentType, - }), + msg.sendMedia( + { + image: media.buffer, + caption, + mimetype: media.contentType, + }, + replyOpts, + ), "media:image", ); } else if (media.kind === "audio") { await sendWithRetry( () => - msg.sendMedia({ - audio: media.buffer, - ptt: true, - mimetype: media.contentType, - caption, - }), + msg.sendMedia( + { + audio: media.buffer, + ptt: true, + mimetype: media.contentType, + caption, + }, + replyOpts, + ), "media:audio", ); } else if (media.kind === "video") { await sendWithRetry( () => - msg.sendMedia({ - video: media.buffer, - caption, - mimetype: media.contentType, - }), + msg.sendMedia( + { + video: media.buffer, + caption, + mimetype: media.contentType, + }, + replyOpts, + ), "media:video", ); } else { @@ -165,12 +178,15 @@ export async function deliverWebReply(params: { const mimetype = media.contentType ?? "application/octet-stream"; await sendWithRetry( () => - msg.sendMedia({ - document: media.buffer, - fileName, - caption, - mimetype, - }), + msg.sendMedia( + { + document: media.buffer, + fileName, + caption, + mimetype, + }, + replyOpts, + ), "media:document", ); } @@ -206,12 +222,12 @@ export async function deliverWebReply(params: { return; } whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); - await msg.reply(fallbackText); + await msg.reply(fallbackText, replyOpts); }, }); // Remaining text chunks after media for (const chunk of remainingText) { - await msg.reply(chunk); + await msg.reply(chunk, replyOpts); } } diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index b19e37feb69..422535c82bf 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -326,11 +326,36 @@ export async function monitorWebInbox(options: { logVerbose(`Presence update failed: ${String(err)}`); } }; - const reply = async (text: string) => { - await sock.sendMessage(chatJid, { text }); + const reply = async (text: string, opts?: { replyToId?: string; mentionedJids?: string[] }) => { + const contextInfo = opts?.mentionedJids?.length + ? { mentionedJid: opts.mentionedJids } + : undefined; + const msgContent: AnyMessageContent = { text, ...(contextInfo ? { contextInfo } : {}) }; + const options = opts?.replyToId + ? { + quoted: { + key: { remoteJid: chatJid, id: opts.replyToId }, + } as proto.IMessage, + } + : {}; + await sock.sendMessage(chatJid, msgContent, options); }; - const sendMedia = async (payload: AnyMessageContent) => { - await sock.sendMessage(chatJid, payload); + const sendMedia = async (payload: AnyMessageContent, opts?: { replyToId?: string; mentionedJids?: string[] }) => { + const contextInfo = opts?.mentionedJids?.length + ? { mentionedJid: opts.mentionedJids } + : undefined; + const msgContent: AnyMessageContent = { + ...payload, + ...(contextInfo ? { contextInfo } : {}), + }; + const options = opts?.replyToId + ? { + quoted: { + key: { remoteJid: chatJid, id: opts.replyToId }, + } as proto.IMessage, + } + : {}; + await sock.sendMessage(chatJid, msgContent, options); }; const timestamp = inbound.messageTimestampMs; const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined); @@ -452,7 +477,7 @@ export async function monitorWebInbox(options: { const sendApi = createWebSendApi({ sock: { - sendMessage: (jid: string, content: AnyMessageContent) => sock.sendMessage(jid, content), + sendMessage: (jid: string, content: AnyMessageContent, options?: Parameters[2]) => options !== undefined ? sock.sendMessage(jid, content, options) : sock.sendMessage(jid, content), sendPresenceUpdate: (presence, jid?: string) => sock.sendPresenceUpdate(presence, jid), }, defaultAccountId: options.accountId, diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index e7bfcdce360..af4c5a66697 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -30,6 +30,7 @@ describe("createWebSendApi", () => { caption: "doc", mimetype: "application/pdf", }), + {}, ); expect(recordChannelActivity).toHaveBeenCalledWith({ channel: "whatsapp", @@ -49,12 +50,13 @@ describe("createWebSendApi", () => { caption: "doc", mimetype: "application/pdf", }), + {}, ); }); it("sends plain text messages", async () => { await api.sendMessage("+1555", "hello"); - expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", { text: "hello" }); + expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", { text: "hello" }, {}); expect(recordChannelActivity).toHaveBeenCalledWith({ channel: "whatsapp", accountId: "main", @@ -72,6 +74,7 @@ describe("createWebSendApi", () => { caption: "cap", mimetype: "image/jpeg", }), + {}, ); }); @@ -85,6 +88,7 @@ describe("createWebSendApi", () => { ptt: true, mimetype: "audio/ogg", }), + {}, ); expect(recordChannelActivity).toHaveBeenCalledWith({ channel: "whatsapp", @@ -104,6 +108,7 @@ describe("createWebSendApi", () => { mimetype: "video/mp4", gifPlayback: true, }), + {}, ); }); @@ -155,4 +160,63 @@ describe("createWebSendApi", () => { await api.sendComposingTo("+1555"); expect(sendPresenceUpdate).toHaveBeenCalledWith("composing", "1555@s.whatsapp.net"); }); + + it("supports replyToId to quote a message", async () => { + await api.sendMessage("+1555", "hello", undefined, undefined, { + replyToId: "msg-quoted-123", + }); + expect(sendMessage).toHaveBeenCalledWith( + "1555@s.whatsapp.net", + expect.objectContaining({ text: "hello" }), + expect.objectContaining({ + quoted: expect.objectContaining({ + key: expect.objectContaining({ + remoteJid: "1555@s.whatsapp.net", + id: "msg-quoted-123", + }), + }), + }), + ); + }); + + it("supports mentionedJids in contextInfo for text messages", async () => { + await api.sendMessage("+1555", "hello @user", undefined, undefined, { + mentionedJids: ["+1999@s.whatsapp.net"], + }); + expect(sendMessage).toHaveBeenCalledWith( + "1555@s.whatsapp.net", + expect.objectContaining({ + text: "hello @user", + contextInfo: { mentionedJid: ["+1999@s.whatsapp.net"] }, + }), + {}, + ); + }); + + it("supports replyToId and mentionedJids together on media", async () => { + const payload = Buffer.from("img"); + await api.sendMessage( + "1234567890", + "hello", + payload, + "image/jpeg", + { replyToId: "quoted-msg", mentionedJids: ["+1999@s.whatsapp.net"] }, + ); + expect(sendMessage).toHaveBeenCalledWith( + "1234567890@s.whatsapp.net", + expect.objectContaining({ + image: payload, + caption: "hello", + contextInfo: { mentionedJid: ["+1999@s.whatsapp.net"] }, + }), + expect.objectContaining({ + quoted: expect.objectContaining({ + key: expect.objectContaining({ + remoteJid: "1234567890@s.whatsapp.net", + id: "quoted-msg", + }), + }), + }), + ); + }); }); diff --git a/extensions/whatsapp/src/inbound/send-api.ts b/extensions/whatsapp/src/inbound/send-api.ts index bb0761431f7..a42e9d6b3cb 100644 --- a/extensions/whatsapp/src/inbound/send-api.ts +++ b/extensions/whatsapp/src/inbound/send-api.ts @@ -1,4 +1,4 @@ -import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys"; +import type { AnyMessageContent, MiscMessageGenerationOptions, WAPresence, proto } from "@whiskeysockets/baileys"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { toWhatsappJid } from "openclaw/plugin-sdk/text-runtime"; import type { ActiveWebSendOptions } from "../active-listener.js"; @@ -19,7 +19,7 @@ function resolveOutboundMessageId(result: unknown): string { export function createWebSendApi(params: { sock: { - sendMessage: (jid: string, content: AnyMessageContent) => Promise; + sendMessage: (jid: string, content: AnyMessageContent, options?: MiscMessageGenerationOptions) => Promise; sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise; }; defaultAccountId: string; @@ -33,6 +33,11 @@ export function createWebSendApi(params: { sendOptions?: ActiveWebSendOptions, ): Promise<{ messageId: string }> => { const jid = toWhatsappJid(to); + const replyToId = sendOptions?.replyToId; + const mentionedJids = sendOptions?.mentionedJids; + const contextInfo = mentionedJids?.length + ? { mentionedJid: mentionedJids } + : undefined; let payload: AnyMessageContent; if (mediaBuffer && mediaType) { if (mediaType.startsWith("image/")) { @@ -40,9 +45,10 @@ export function createWebSendApi(params: { image: mediaBuffer, caption: text || undefined, mimetype: mediaType, + ...(contextInfo ? { contextInfo } : {}), }; } else if (mediaType.startsWith("audio/")) { - payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType }; + payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType, ...(contextInfo ? { contextInfo } : {}) }; } else if (mediaType.startsWith("video/")) { const gifPlayback = sendOptions?.gifPlayback; payload = { @@ -50,6 +56,7 @@ export function createWebSendApi(params: { caption: text || undefined, mimetype: mediaType, ...(gifPlayback ? { gifPlayback: true } : {}), + ...(contextInfo ? { contextInfo } : {}), }; } else { const fileName = sendOptions?.fileName?.trim() || "file"; @@ -58,12 +65,23 @@ export function createWebSendApi(params: { fileName, caption: text || undefined, mimetype: mediaType, + ...(contextInfo ? { contextInfo } : {}), }; } } else { - payload = { text }; + payload = { text, ...(contextInfo ? { contextInfo } : {}) }; } - const result = await params.sock.sendMessage(jid, payload); + const options = replyToId + ? { + quoted: { + key: { + remoteJid: jid, + id: replyToId, + }, + } as proto.IMessage, + } + : {}; + const result = await params.sock.sendMessage(jid, payload, options); const accountId = sendOptions?.accountId ?? params.defaultAccountId; recordWhatsAppOutbound(accountId); const messageId = resolveOutboundMessageId(result); diff --git a/extensions/whatsapp/src/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts index 731dcd2c8cc..83de46b2bcf 100644 --- a/extensions/whatsapp/src/inbound/types.ts +++ b/extensions/whatsapp/src/inbound/types.ts @@ -34,8 +34,8 @@ export type WebInboundMessage = { fromMe?: boolean; location?: NormalizedLocation; sendComposing: () => Promise; - reply: (text: string) => Promise; - sendMedia: (payload: AnyMessageContent) => Promise; + reply: (text: string, opts?: { replyToId?: string; mentionedJids?: string[] }) => Promise; + sendMedia: (payload: AnyMessageContent, opts?: { replyToId?: string; mentionedJids?: string[] }) => Promise; mediaPath?: string; mediaType?: string; mediaFileName?: string; diff --git a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts index 54a00c167d3..86d7b22a162 100644 --- a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts @@ -87,7 +87,7 @@ describe("web monitor inbox", () => { caption: "gif", mimetype: "video/mp4", gifPlayback: true, - }); + }, {}); await listener.close(); }); diff --git a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts index 9274abd0135..6f343e18f41 100644 --- a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts @@ -98,7 +98,7 @@ describe("web monitor inbox", () => { ); expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { text: "pong", - }); + }, {}); await listener.close(); } @@ -137,7 +137,7 @@ describe("web monitor inbox", () => { expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("composing", "999@s.whatsapp.net"); expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { text: "pong", - }); + }, {}); await listener.close(); }); diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index c59c5dd2008..9b291df3695 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -24,6 +24,8 @@ export async function sendMessageWhatsApp( mediaLocalRoots?: readonly string[]; gifPlayback?: boolean; accountId?: string; + replyToId?: string; + mentionedJids?: string[]; }, ): Promise<{ messageId: string; toJid: string }> { let text = body.trimStart(); @@ -88,11 +90,13 @@ export async function sendMessageWhatsApp( const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; const sendOptions: ActiveWebSendOptions | undefined = - options.gifPlayback || accountId || documentFileName + options.gifPlayback || accountId || documentFileName || options.replyToId || options.mentionedJids?.length ? { ...(options.gifPlayback ? { gifPlayback: true } : {}), ...(documentFileName ? { fileName: documentFileName } : {}), accountId, + ...(options.replyToId ? { replyToId: options.replyToId } : {}), + ...(options.mentionedJids?.length ? { mentionedJids: options.mentionedJids } : {}), } : undefined; const result = sendOptions diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index efbd832dd09..0f185c5dac3 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -65,7 +65,7 @@ export function createWhatsAppOutboundBase({ resolveTarget, ...createAttachedChannelResultAdapter({ channel: "whatsapp", - sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback, replyToId }) => { const normalizedText = normalizeText(text); if (skipEmptyText && !normalizedText) { return { messageId: "" }; @@ -77,6 +77,7 @@ export function createWhatsAppOutboundBase({ cfg, accountId: accountId ?? undefined, gifPlayback, + replyToId: replyToId ?? undefined, }); }, sendMedia: async ({ @@ -88,6 +89,7 @@ export function createWhatsAppOutboundBase({ accountId, deps, gifPlayback, + replyToId, }) => { const send = resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; @@ -98,6 +100,7 @@ export function createWhatsAppOutboundBase({ mediaLocalRoots, accountId: accountId ?? undefined, gifPlayback, + replyToId: replyToId ?? undefined, }); }, sendPoll: async ({ cfg, to, poll, accountId }) =>