mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
jidToE164,
|
||||
logVerbose,
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
resolveChunkMode,
|
||||
resolveIdentityNamePrefix,
|
||||
resolveInboundLastRouteSessionKey,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user