mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:00:50 +00:00
fix(whatsapp): deliver tool replies that include media (#60968)
Merged via squash.
Prepared head SHA: 26704020a4
Co-authored-by: adaclaw <266167987+adaclaw@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
@@ -193,6 +193,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack: route native stream fallback replies through the normal chunked sender so long buffered Slack Connect responses are not dropped or duplicated. (#71124) Thanks @martingarramon.
|
||||
- WhatsApp: transcribe accepted voice notes before agent dispatch while keeping spoken transcripts out of command authorization. (#64120) Thanks @rogerdigital.
|
||||
- Plugins/CLI: expose channel plugin CLI descriptors during discovery-mode plugin loads so snapshot registries keep channel commands visible without activating full runtimes. (#71309) Thanks @gumadeiras.
|
||||
- WhatsApp: deliver media generated by tool-result replies while still suppressing text-only tool chatter. (#60968) Thanks @adaclaw.
|
||||
|
||||
## 2026.4.23
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
let capturedDispatchParams: unknown;
|
||||
|
||||
type CapturedReplyPayload = {
|
||||
text?: string;
|
||||
isReasoning?: boolean;
|
||||
isCompactionNotice?: boolean;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
const { dispatchReplyWithBufferedBlockDispatcherMock } = vi.hoisted(() => ({
|
||||
dispatchReplyWithBufferedBlockDispatcherMock: vi.fn(async (params: { ctx: unknown }) => {
|
||||
capturedDispatchParams = params;
|
||||
@@ -36,10 +44,20 @@ vi.mock("./runtime-api.js", () => ({
|
||||
},
|
||||
resolveInboundLastRouteSessionKey: (params: { sessionKey: string }) => params.sessionKey,
|
||||
resolveMarkdownTableMode: () => undefined,
|
||||
resolveSendableOutboundReplyParts: (payload: { text?: string }) => ({
|
||||
text: payload.text ?? "",
|
||||
hasMedia: false,
|
||||
}),
|
||||
resolveSendableOutboundReplyParts: (payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
}) => {
|
||||
const urls = [
|
||||
...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []),
|
||||
...(payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
];
|
||||
return {
|
||||
text: payload.text ?? "",
|
||||
hasMedia: urls.length > 0,
|
||||
};
|
||||
},
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
shouldLogVerbose: () => false,
|
||||
toLocationContext: () => ({}),
|
||||
@@ -91,7 +109,7 @@ function getCapturedDeliver() {
|
||||
capturedDispatchParams as {
|
||||
dispatcherOptions?: {
|
||||
deliver?: (
|
||||
payload: { text?: string; isReasoning?: boolean; isCompactionNotice?: boolean },
|
||||
payload: CapturedReplyPayload,
|
||||
info: { kind: "tool" | "block" | "final" },
|
||||
) => Promise<void>;
|
||||
};
|
||||
@@ -360,7 +378,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("delivers block and final WhatsApp payloads, but suppresses tool payloads", async () => {
|
||||
it("delivers block and final WhatsApp payloads; suppresses text-only tool payloads but delivers tool media", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
@@ -376,10 +394,27 @@ describe("whatsapp inbound dispatch", () => {
|
||||
expect(deliverReply).not.toHaveBeenCalled();
|
||||
expect(rememberSentText).not.toHaveBeenCalled();
|
||||
|
||||
await deliver?.(
|
||||
{ text: "tool image", mediaUrls: ["/tmp/generated.jpg"] },
|
||||
{
|
||||
kind: "tool",
|
||||
},
|
||||
);
|
||||
expect(deliverReply).toHaveBeenCalledTimes(1);
|
||||
expect(rememberSentText).toHaveBeenCalledTimes(1);
|
||||
expect(deliverReply).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
replyResult: expect.objectContaining({
|
||||
mediaUrls: ["/tmp/generated.jpg"],
|
||||
text: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await deliver?.({ text: "block payload" }, { kind: "block" });
|
||||
await deliver?.({ text: "final payload" }, { kind: "final" });
|
||||
expect(deliverReply).toHaveBeenCalledTimes(2);
|
||||
expect(rememberSentText).toHaveBeenCalledTimes(2);
|
||||
expect(deliverReply).toHaveBeenCalledTimes(3);
|
||||
expect(rememberSentText).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => {
|
||||
@@ -473,6 +508,65 @@ describe("whatsapp inbound dispatch", () => {
|
||||
expect(rememberSentText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns true for tool-only media turns after delivering media", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const rememberSentText = vi.fn();
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
ctx: unknown;
|
||||
dispatcherOptions?: {
|
||||
deliver?: (
|
||||
payload: CapturedReplyPayload,
|
||||
info: { kind: "tool" | "block" | "final" },
|
||||
) => Promise<void>;
|
||||
};
|
||||
}) => {
|
||||
capturedDispatchParams = params;
|
||||
await params.dispatcherOptions?.deliver?.(
|
||||
{ text: "tool image", mediaUrls: ["/tmp/generated.jpg"] },
|
||||
{ kind: "tool" },
|
||||
);
|
||||
return { queuedFinal: false, counts: { tool: 1, block: 0, final: 0 } };
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
dispatchWhatsAppBufferedReply({
|
||||
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
|
||||
connectionId: "conn",
|
||||
context: { Body: "hi" },
|
||||
conversationId: "+1000",
|
||||
deliverReply,
|
||||
groupHistories: new Map(),
|
||||
groupHistoryKey: "+1000",
|
||||
maxMediaBytes: 1,
|
||||
msg: makeMsg(),
|
||||
rememberSentText,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
} as never,
|
||||
replyPipeline: {},
|
||||
replyResolver: (async () => undefined) as never,
|
||||
route: makeRoute(),
|
||||
shouldClearGroupHistory: false,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(deliverReply).toHaveBeenCalledTimes(1);
|
||||
expect(deliverReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyResult: expect.objectContaining({
|
||||
mediaUrls: ["/tmp/generated.jpg"],
|
||||
text: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(rememberSentText).toHaveBeenCalledWith(undefined, expect.any(Object));
|
||||
});
|
||||
|
||||
it("passes sendComposing through as the reply typing callback", async () => {
|
||||
const sendComposing = vi.fn(async () => undefined);
|
||||
|
||||
|
||||
@@ -57,17 +57,20 @@ function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): bo
|
||||
return !cfg.channels.whatsapp.blockStreaming;
|
||||
}
|
||||
|
||||
function shouldSuppressWhatsAppPayload(
|
||||
function resolveWhatsAppDeliverablePayload(
|
||||
payload: ReplyPayload,
|
||||
info: { kind: ReplyLifecycleKind },
|
||||
): boolean {
|
||||
if (info.kind === "tool") {
|
||||
return true;
|
||||
}
|
||||
): ReplyPayload | null {
|
||||
if (payload.isReasoning === true || payload.isCompactionNotice === true) {
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
return false;
|
||||
if (info.kind === "tool") {
|
||||
if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
|
||||
return null;
|
||||
}
|
||||
return { ...payload, text: undefined };
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function resolveWhatsAppResponsePrefix(params: {
|
||||
@@ -291,11 +294,12 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
}
|
||||
},
|
||||
deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => {
|
||||
if (shouldSuppressWhatsAppPayload(payload, info)) {
|
||||
const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info);
|
||||
if (!deliveryPayload) {
|
||||
return;
|
||||
}
|
||||
await params.deliverReply({
|
||||
replyResult: payload,
|
||||
replyResult: deliveryPayload,
|
||||
msg: params.msg,
|
||||
mediaLocalRoots,
|
||||
maxMediaBytes: params.maxMediaBytes,
|
||||
@@ -307,17 +311,17 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
tableMode,
|
||||
});
|
||||
didSendReply = true;
|
||||
const shouldLog = payload.text ? true : undefined;
|
||||
params.rememberSentText(payload.text, {
|
||||
const shouldLog = deliveryPayload.text ? true : undefined;
|
||||
params.rememberSentText(deliveryPayload.text, {
|
||||
combinedBody: params.context.Body as string | undefined,
|
||||
combinedBodySessionKey: params.route.sessionKey,
|
||||
logVerboseMessage: shouldLog,
|
||||
});
|
||||
const fromDisplay =
|
||||
params.msg.chatType === "group" ? params.conversationId : (params.msg.from ?? "unknown");
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
const reply = resolveSendableOutboundReplyParts(deliveryPayload);
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = payload.text != null ? reply.text : "<media>";
|
||||
const preview = deliveryPayload.text != null ? reply.text : "<media>";
|
||||
logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
|
||||
}
|
||||
},
|
||||
@@ -329,7 +333,8 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const didQueueVisibleReply = queuedFinal || counts.block > 0 || counts.final > 0;
|
||||
const didQueueVisibleReply =
|
||||
queuedFinal || counts.tool > 0 || counts.block > 0 || counts.final > 0;
|
||||
if (!didQueueVisibleReply) {
|
||||
if (params.shouldClearGroupHistory) {
|
||||
params.groupHistories.set(params.groupHistoryKey, []);
|
||||
|
||||
Reference in New Issue
Block a user