Merge bed758da83bb0172513563161e56dfb759621819 into 6b4c24c2e55b5b4013277bd799525086f6a0c40f

This commit is contained in:
Ziy 2026-03-21 04:45:11 +00:00 committed by GitHub
commit f6015a26f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 220 additions and 47 deletions

View File

@ -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(),

View File

@ -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) {

View File

@ -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: (

View File

@ -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 }),
);
});
});

View File

@ -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<unknown>, 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);
}
}

View File

@ -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<typeof sock.sendMessage>[2]) => options !== undefined ? sock.sendMessage(jid, content, options) : sock.sendMessage(jid, content),
sendPresenceUpdate: (presence, jid?: string) => sock.sendPresenceUpdate(presence, jid),
},
defaultAccountId: options.accountId,

View File

@ -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",
}),
}),
}),
);
});
});

View File

@ -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<unknown>;
sendMessage: (jid: string, content: AnyMessageContent, options?: MiscMessageGenerationOptions) => Promise<unknown>;
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
};
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);

View File

@ -34,8 +34,8 @@ export type WebInboundMessage = {
fromMe?: boolean;
location?: NormalizedLocation;
sendComposing: () => Promise<void>;
reply: (text: string) => Promise<void>;
sendMedia: (payload: AnyMessageContent) => Promise<void>;
reply: (text: string, opts?: { replyToId?: string; mentionedJids?: string[] }) => Promise<void>;
sendMedia: (payload: AnyMessageContent, opts?: { replyToId?: string; mentionedJids?: string[] }) => Promise<void>;
mediaPath?: string;
mediaType?: string;
mediaFileName?: string;

View File

@ -87,7 +87,7 @@ describe("web monitor inbox", () => {
caption: "gif",
mimetype: "video/mp4",
gifPlayback: true,
});
}, {});
await listener.close();
});

View File

@ -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();
});

View File

@ -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

View File

@ -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<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
@ -98,6 +100,7 @@ export function createWhatsAppOutboundBase({
mediaLocalRoots,
accountId: accountId ?? undefined,
gifPlayback,
replyToId: replyToId ?? undefined,
});
},
sendPoll: async ({ cfg, to, poll, accountId }) =>