diff --git a/CHANGELOG.md b/CHANGELOG.md index 76dec664922..9d147a8bd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai - QA/Matrix: wait for live approval reactions to echo before starting the threaded approval decision timeout. Thanks @vincentkoc. - QA/Matrix: reuse the primed driver sync stream when confirming approval reaction echoes, avoiding missed self-reactions in live release runs. Thanks @vincentkoc. - Tlon: expose `groupInviteAllowlist` in the channel config schema and clarify that group invite auto-accept fails closed without an invite allowlist. Thanks @vincentkoc. +- Channels/WhatsApp: apply the shared group/channel visible-reply mode during inbound dispatch so group replies stay message-tool-only by default without overriding direct-chat harness defaults. Refs #75178 and #67394. Thanks @scoootscooob. - Control UI/WebChat: collapse duplicate in-flight internal text sends onto the active Gateway run so rapid repeat submits do not start fresh `agent:main:main` dispatches. Fixes #75737. Thanks @dsdsddd1 and @BunsDev. - Mattermost: accept the documented `channels.mattermost.streaming` config and honor `streaming: "off"` by disabling draft preview posts. Thanks @vincentkoc. - Mattermost: expose streaming progress config labels and help text in generated channel config metadata so Control UI/docs can explain the new `channels.mattermost.streaming.progress.*` fields. Thanks @vincentkoc. diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts index 86f6b6e86a9..2c8bc79a48c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts @@ -5,6 +5,7 @@ export { getAgentScopedMediaLocalRoots, jidToE164, logVerbose, + resolveChannelSourceReplyDeliveryMode, resolveChunkMode, resolveIdentityNamePrefix, resolveInboundLastRouteSessionKey, diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 74a1954b90c..fd1786c3ab5 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -36,6 +36,28 @@ vi.mock("./runtime-api.js", () => ({ return phone ? `+${phone}` : null; }, logVerbose: () => {}, + resolveChannelSourceReplyDeliveryMode: ({ + cfg, + ctx, + }: { + cfg: { + messages?: { + visibleReplies?: "automatic" | "message_tool"; + groupChat?: { visibleReplies?: "automatic" | "message_tool" }; + }; + }; + ctx: { ChatType?: string; CommandSource?: "native" }; + }) => { + if (ctx.CommandSource === "native") { + return "automatic"; + } + if (ctx.ChatType === "group" || ctx.ChatType === "channel") { + const configuredMode = + cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies; + return configuredMode === "automatic" ? "automatic" : "message_tool_only"; + } + return cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic"; + }, resolveChunkMode: () => "length", resolveIdentityNamePrefix: (cfg: { agents?: { list?: Array<{ id?: string; default?: boolean; identity?: { name?: string } }> }; @@ -139,6 +161,17 @@ function getCapturedOnError() { )?.dispatcherOptions?.onError; } +function getCapturedReplyOptions() { + return ( + capturedDispatchParams as { + replyOptions?: { + disableBlockStreaming?: boolean; + sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; + }; + } + )?.replyOptions; +} + type BufferedReplyParams = Parameters[0]; function makeReplyLogger(): BufferedReplyParams["replyLogger"] { @@ -575,13 +608,7 @@ describe("whatsapp inbound dispatch", () => { it("maps WhatsApp blockStreaming=true to disableBlockStreaming=false", async () => { await dispatchBufferedReply(); - expect( - ( - capturedDispatchParams as { - replyOptions?: { disableBlockStreaming?: boolean }; - } - )?.replyOptions?.disableBlockStreaming, - ).toBe(false); + expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(false); }); it("maps WhatsApp blockStreaming=false to disableBlockStreaming=true", async () => { @@ -589,13 +616,7 @@ describe("whatsapp inbound dispatch", () => { cfg: { channels: { whatsapp: { blockStreaming: false } } } as never, }); - expect( - ( - capturedDispatchParams as { - replyOptions?: { disableBlockStreaming?: boolean }; - } - )?.replyOptions?.disableBlockStreaming, - ).toBe(true); + expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(true); }); it("leaves disableBlockStreaming undefined when WhatsApp blockStreaming is unset", async () => { @@ -603,13 +624,47 @@ describe("whatsapp inbound dispatch", () => { cfg: { channels: { whatsapp: {} } } as never, }); - expect( - ( - capturedDispatchParams as { - replyOptions?: { disableBlockStreaming?: boolean }; - } - )?.replyOptions?.disableBlockStreaming, - ).toBeUndefined(); + expect(getCapturedReplyOptions()?.disableBlockStreaming).toBeUndefined(); + }); + + it("leaves WhatsApp direct reply mode unset by default", async () => { + await dispatchBufferedReply({ + context: { Body: "hi", ChatType: "direct" }, + msg: makeMsg({ from: "+15550001000", chatType: "direct" }), + }); + + expect(getCapturedReplyOptions()).toMatchObject({ + disableBlockStreaming: false, + }); + expect(getCapturedReplyOptions()?.sourceReplyDeliveryMode).toBeUndefined(); + }); + + it("defaults WhatsApp group replies to message-tool-only and disables source streaming", async () => { + await dispatchBufferedReply({ + context: { Body: "hi", ChatType: "group" }, + msg: makeMsg({ from: "120363000000000000@g.us", chatType: "group" }), + }); + + expect(getCapturedReplyOptions()).toMatchObject({ + sourceReplyDeliveryMode: "message_tool_only", + disableBlockStreaming: true, + }); + }); + + it("honors automatic visible replies for WhatsApp groups", async () => { + await dispatchBufferedReply({ + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: { groupChat: { visibleReplies: "automatic" } }, + } as never, + context: { Body: "hi", ChatType: "group" }, + msg: makeMsg({ from: "120363000000000000@g.us", chatType: "group" }), + }); + + expect(getCapturedReplyOptions()).toMatchObject({ + sourceReplyDeliveryMode: "automatic", + disableBlockStreaming: false, + }); }); it("treats block-only turns as visible replies instead of silent turns", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 6bfde4c3fea..71ba9a90e09 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -15,6 +15,7 @@ import { getAgentScopedMediaLocalRoots, jidToE164, logVerbose, + resolveChannelSourceReplyDeliveryMode, resolveChunkMode, resolveIdentityNamePrefix, resolveInboundLastRouteSessionKey, @@ -313,7 +314,22 @@ export async function dispatchWhatsAppBufferedReply(params: { accountId: params.route.accountId, }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId); - const disableBlockStreaming = resolveWhatsAppDisableBlockStreaming(params.cfg); + const sourceReplyChatType = + typeof params.context.ChatType === "string" ? params.context.ChatType : params.msg.chatType; + const sourceReplyDeliveryMode = + sourceReplyChatType === "group" || sourceReplyChatType === "channel" + ? resolveChannelSourceReplyDeliveryMode({ + cfg: params.cfg, + ctx: { + ChatType: sourceReplyChatType, + CommandSource: params.context.CommandSource === "native" ? "native" : undefined, + }, + }) + : undefined; + const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only"; + const disableBlockStreaming = sourceRepliesAreToolOnly + ? true + : resolveWhatsAppDisableBlockStreaming(params.cfg); let didSendReply = false; let didLogHeartbeatStrip = false; @@ -401,6 +417,7 @@ export async function dispatchWhatsAppBufferedReply(params: { }, replyOptions: { disableBlockStreaming, + ...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}), onModelSelected: params.onModelSelected, }, }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts index 46dfc04fd6c..7996e4eea66 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts @@ -2,7 +2,10 @@ export { resolveIdentityNamePrefix } from "openclaw/plugin-sdk/agent-runtime"; export { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-envelope"; export { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-inbound"; export { toLocationContext } from "openclaw/plugin-sdk/channel-location"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { + createChannelReplyPipeline, + resolveChannelSourceReplyDeliveryMode, +} from "openclaw/plugin-sdk/channel-reply-pipeline"; export { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-detection"; export { resolveChannelContextVisibilityMode } from "../config.runtime.js"; export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index fb08dad76de..edb37f73ad6 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -4616,6 +4616,31 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => expect(mocks.routeReply).not.toHaveBeenCalled(); }); + it("keeps default direct source delivery automatic", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { + expect(opts?.sourceReplyDeliveryMode).toBe("automatic"); + return { text: "visible direct reply" } satisfies ReplyPayload; + }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + ChatType: "direct", + SessionKey: "agent:main:telegram:direct:U1", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(result.queuedFinal).toBe(true); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "visible direct reply" }), + ); + }); + it("uses harness defaults for direct source delivery when config is unset", async () => { setNoAbort(); registerAgentHarness({