fix(whatsapp): honor group visible reply mode (#76973)

* fix(whatsapp): honor group visible reply mode

* fix(whatsapp): preserve direct reply defaults
This commit is contained in:
scoootscooob
2026-05-03 18:07:38 -07:00
committed by GitHub
parent c1db7df2ea
commit b0f947f61c
6 changed files with 125 additions and 23 deletions

View File

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

View File

@@ -5,6 +5,7 @@ export {
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
resolveChannelSourceReplyDeliveryMode,
resolveChunkMode,
resolveIdentityNamePrefix,
resolveInboundLastRouteSessionKey,

View File

@@ -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<typeof dispatchWhatsAppBufferedReply>[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 () => {

View File

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

View File

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

View File

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