fix(auto-reply): surface private group replies

This commit is contained in:
Peter Steinberger
2026-04-30 14:46:07 +01:00
parent 8b665e0d70
commit 82ca6ecdde
11 changed files with 219 additions and 6 deletions

View File

@@ -533,7 +533,7 @@ Docs: https://docs.openclaw.ai
- Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev.
- CLI/status: show skipped fast-path memory checks as `not checked` and report active custom memory plugin runtime status from `status --json --all` without requiring built-in `agents.defaults.memorySearch`, so plugins such as memory-lancedb-pro and memory-cms no longer look unavailable when their own runtime is healthy. Fixes #56968. Thanks @Tony-ooo and @aderius.
- Gateway/channels: record and log unexpected clean channel monitor exits so channels that return without throwing no longer appear stopped with no error. Fixes #73099. Thanks @balaji1968-kingler.
- Discord/group chats: keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
- Group/channel chats (all channels): keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
- Plugins/package: force nested bundled-plugin runtime dependency installs out of inherited npm dry-run mode during prepack and package smoke checks, so packed installs materialize required plugin modules instead of reporting missing bundled files. Refs #73128. Thanks @Adam-Researchh.
- Discord: skip reaction events before REST channel fetch when notifications are off, guild reactions are disabled, or allowlist mode cannot match without channel overrides, reducing reconnect bursts that caused slow listener warnings. Fixes #73133. Thanks @isaacsummers.
- Channels/Telegram: centralize polling update tracking so accepted offsets remain durable across restarts, same-process handler failures can still retry, and slow offset writes cannot overwrite newer accepted watermarks. Refs #73115. Thanks @vdruts.

View File

@@ -1,4 +1,4 @@
c3bcb3a3da46bbbe15a7798869911cab109df950ee51c79fd86c96bb809dfdf1 config-baseline.json
d4b34f6fd2c39132bf4feff4be5ddfd226fa52c4596d6bdc438031456dde18d4 config-baseline.json
8f573caa7f4cf01ae9d4805d3d14e1ba6772f651f6da182baaf2b469592749a4 config-baseline.core.json
92712871defa92eeda8161b516db85574681f2b70678b940508a808b987aeae2 config-baseline.channel.json
aca3215b7382af82b5060d73c631a7f82661c6e99193fa5eb1c5b4b499fb657b config-baseline.plugin.json
6005cf9f6e8c9f25ef97207b5eee29ae0e506cf910cdeca77fc9894ad1755b1f config-baseline.plugin.json

View File

@@ -61,6 +61,9 @@ To restore legacy automatic final replies for group/channel rooms:
}
```
The gateway hot-reloads `messages` config after the file is saved. Restart only
when file watching or config reload is disabled in the deployment.
To require visible output to go through the message tool for every source chat:
```json5

View File

@@ -772,6 +772,8 @@ Group messages default to **require mention** (metadata mention or safe regex pa
Visible replies are controlled separately. Group/channel rooms default to `messages.groupChat.visibleReplies: "message_tool"`: OpenClaw still processes the turn, but normal final replies stay private and visible room output requires `message(action=send)`. Set `"automatic"` only when you want the legacy behavior where normal replies are posted back to the room. To apply the same tool-only visible-reply behavior to direct chats too, set `messages.visibleReplies: "message_tool"`.
The gateway hot-reloads `messages` config after the file is saved. Restart only when file watching or config reload is disabled in the deployment.
**Mention types:**
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.

View File

@@ -65,10 +65,38 @@ describe("gateway config mutation guard coverage", () => {
"agents.list[].id",
"agents.list[].model",
"channels.*.requireMention",
"messages.visibleReplies",
"messages.groupChat.visibleReplies",
]),
);
});
it("allows visible reply delivery mode edits via config.patch", () => {
expectAllowed(
{},
{
messages: {
visibleReplies: "automatic",
groupChat: { visibleReplies: "automatic" },
},
},
);
expectAllowed(
{
messages: {
visibleReplies: "automatic",
groupChat: { visibleReplies: "message_tool" },
},
},
{
messages: {
visibleReplies: "message_tool",
groupChat: { visibleReplies: "automatic" },
},
},
);
});
it("blocks disabling sandbox mode via config.patch", () => {
expectBlocked(
{ agents: { defaults: { sandbox: { mode: "all" } } } },

View File

@@ -51,6 +51,10 @@ const ALLOWED_GATEWAY_CONFIG_PATHS = [
"channels.*.*.*.requireMention",
"channels.*.*.*.*.requireMention",
"channels.*.*.*.*.*.requireMention",
// Visible reply delivery mode is a bounded message UX setting, not a secret
// or privilege boundary. Let agents repair silent group/channel rooms.
"messages.visibleReplies",
"messages.groupChat.visibleReplies",
] as const;
/** @internal Exposed for regression tests only; do not import from runtime code. */

View File

@@ -4402,6 +4402,31 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("falls back to automatic group/channel delivery when the message tool is unavailable", async () => {
setNoAbort();
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
return { text: "visible fallback" } satisfies ReplyPayload;
});
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
ChatType: "channel",
SessionKey: "test:discord:channel:C1",
}),
cfg: { tools: { allow: ["read"] } } as OpenClawConfig,
dispatcher,
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(
expect.objectContaining({ text: "visible fallback" }),
);
});
it("keeps native command replies visible in group/channel turns", async () => {
setNoAbort();
const dispatcher = createDispatcher();

View File

@@ -5,6 +5,11 @@ import {
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
} from "../../agents/pi-tools.policy.js";
import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js";
import {
resolveConversationBindingRecord,
touchConversationBindingRecord,
@@ -593,6 +598,33 @@ export async function dispatchReplyFromConfig(
undefined,
chatType: sessionStoreEntry.entry?.chatType,
});
const {
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
config: cfg,
sessionKey: acpDispatchSessionKey,
agentId: sessionAgentId,
});
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow);
const providerProfilePolicy = mergeAlsoAllowPolicy(
resolveToolProfilePolicy(providerProfile),
providerProfileAlsoAllow,
);
const messageToolAvailable = isToolAllowedByPolicies("message", [
profilePolicy,
providerProfilePolicy,
globalProviderPolicy,
agentProviderPolicy,
globalPolicy,
agentPolicy,
]);
const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({
cfg,
ctx,
@@ -601,6 +633,7 @@ export async function dispatchReplyFromConfig(
suppressAcpChildUserDelivery,
explicitSuppressTyping: params.replyOptions?.suppressTyping === true,
shouldSuppressTyping,
messageToolAvailable,
});
const {
sourceReplyDeliveryMode,

View File

@@ -1,6 +1,27 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
const loggerMocks = vi.hoisted(() => ({
warn: vi.fn(),
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
subsystem: "auto-reply",
isEnabled: () => false,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: loggerMocks.warn,
error: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: vi.fn(),
}),
}));
import {
resetVisibleRepliesPrivateDefaultWarningForTest,
resolveSourceReplyDeliveryMode,
resolveSourceReplyVisibilityPolicy,
} from "./source-reply-delivery-mode.js";
@@ -19,6 +40,11 @@ const globalToolOnlyReplyConfig = {
},
} as const satisfies OpenClawConfig;
beforeEach(() => {
loggerMocks.warn.mockClear();
resetVisibleRepliesPrivateDefaultWarningForTest();
});
describe("resolveSourceReplyDeliveryMode", () => {
it("defaults groups and channels to message-tool-only delivery", () => {
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "channel" } })).toBe(
@@ -30,6 +56,10 @@ describe("resolveSourceReplyDeliveryMode", () => {
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "direct" } })).toBe(
"automatic",
);
expect(loggerMocks.warn).toHaveBeenCalledTimes(1);
expect(loggerMocks.warn).toHaveBeenCalledWith(
expect.stringContaining("Group/channel replies are private by default"),
);
});
it("honors config and explicit requested mode", () => {
@@ -77,6 +107,42 @@ describe("resolveSourceReplyDeliveryMode", () => {
ctx: { ChatType: "group", CommandSource: "native" },
}),
).toBe("automatic");
expect(loggerMocks.warn).not.toHaveBeenCalled();
});
it("falls back to automatic when message tool is unavailable", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group" },
messageToolAvailable: false,
}),
).toBe("automatic");
expect(
resolveSourceReplyDeliveryMode({
cfg: globalToolOnlyReplyConfig,
ctx: { ChatType: "direct" },
messageToolAvailable: false,
}),
).toBe("automatic");
expect(loggerMocks.warn).not.toHaveBeenCalled();
});
it("keeps message-tool-only delivery when message tool availability is unknown", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "group" },
messageToolAvailable: true,
}),
).toBe("message_tool_only");
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "channel" },
}),
).toBe("message_tool_only");
expect(loggerMocks.warn).toHaveBeenCalledTimes(1);
});
});
@@ -220,4 +286,21 @@ describe("resolveSourceReplyVisibilityPolicy", () => {
suppressTyping: false,
});
});
it("keeps delivery automatic when message-tool-only mode cannot send visibly", () => {
expect(
resolveSourceReplyVisibilityPolicy({
cfg: emptyConfig,
ctx: { ChatType: "group" },
sendPolicy: "allow",
messageToolAvailable: false,
}),
).toMatchObject({
sourceReplyDeliveryMode: "automatic",
suppressAutomaticSourceDelivery: false,
suppressDelivery: false,
suppressHookUserDelivery: false,
deliverySuppressionReason: "",
});
});
});

View File

@@ -1,17 +1,28 @@
import { normalizeChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { SessionSendPolicyDecision } from "../../sessions/send-policy.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
const log = createSubsystemLogger("auto-reply");
let visibleRepliesPrivateDefaultWarned = false;
export type SourceReplyDeliveryModeContext = {
ChatType?: string;
CommandSource?: "text" | "native";
};
/** @internal Test-only reset for the process-level one-shot warning. */
export function resetVisibleRepliesPrivateDefaultWarningForTest(): void {
visibleRepliesPrivateDefaultWarned = false;
}
export function resolveSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
messageToolAvailable?: boolean;
}): SourceReplyDeliveryMode {
if (params.requested) {
return params.requested;
@@ -20,12 +31,33 @@ export function resolveSourceReplyDeliveryMode(params: {
return "automatic";
}
const chatType = normalizeChatType(params.ctx.ChatType);
let mode: SourceReplyDeliveryMode;
if (chatType === "group" || chatType === "channel") {
const configuredMode =
params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies;
return configuredMode === "automatic" ? "automatic" : "message_tool_only";
mode = configuredMode === "automatic" ? "automatic" : "message_tool_only";
if (
mode === "message_tool_only" &&
configuredMode === undefined &&
params.messageToolAvailable !== false &&
!visibleRepliesPrivateDefaultWarned
) {
visibleRepliesPrivateDefaultWarned = true;
log.warn(
`Group/channel replies are private by default since 2026.4.27. ` +
`To restore automatic room posting, set messages.groupChat.visibleReplies to "automatic" in openclaw.json and save the config. ` +
`The gateway hot-reloads messages config; restart only if file watching/reload is disabled. ` +
`Relates to https://github.com/openclaw/openclaw/issues/74876`,
);
}
} else {
mode =
params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
}
return params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
if (mode === "message_tool_only" && params.messageToolAvailable === false) {
return "automatic";
}
return mode;
}
export type SourceReplyVisibilityPolicy = {
@@ -47,11 +79,13 @@ export function resolveSourceReplyVisibilityPolicy(params: {
suppressAcpChildUserDelivery?: boolean;
explicitSuppressTyping?: boolean;
shouldSuppressTyping?: boolean;
messageToolAvailable?: boolean;
}): SourceReplyVisibilityPolicy {
const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: params.ctx,
requested: params.requested,
messageToolAvailable: params.messageToolAvailable,
});
const sendPolicyDenied = params.sendPolicy === "deny";
const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only";

View File

@@ -28,6 +28,7 @@ export function resolveChannelSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
messageToolAvailable?: boolean;
}): SourceReplyDeliveryMode {
return resolveSourceReplyDeliveryMode(params);
}