diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a805e3850..db8a32bb90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 476ff9e8cf5..a1d13a27dc6 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/channels/groups.md b/docs/channels/groups.md index bfee830dc24..93330b4b6bf 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -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 diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 2672508ce3d..a34deb1771a 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -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. diff --git a/src/agents/tools/gateway-tool-guard-coverage.test.ts b/src/agents/tools/gateway-tool-guard-coverage.test.ts index 1d8163232d6..bf3003b3b61 100644 --- a/src/agents/tools/gateway-tool-guard-coverage.test.ts +++ b/src/agents/tools/gateway-tool-guard-coverage.test.ts @@ -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" } } } }, diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 5b57bcbdedb..60f80383403 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -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. */ diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 9dcf72080ee..653bfeeb950 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 0b913287788..20a0ace2a57 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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, diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index 39f7a0319c9..1500cc2c81c 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -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: "", + }); + }); }); diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index 1de51d6712e..e6641befd60 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -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"; diff --git a/src/plugin-sdk/channel-reply-pipeline.ts b/src/plugin-sdk/channel-reply-pipeline.ts index f2935fd62a0..6a4ae08c4b5 100644 --- a/src/plugin-sdk/channel-reply-pipeline.ts +++ b/src/plugin-sdk/channel-reply-pipeline.ts @@ -28,6 +28,7 @@ export function resolveChannelSourceReplyDeliveryMode(params: { cfg: OpenClawConfig; ctx: SourceReplyDeliveryModeContext; requested?: SourceReplyDeliveryMode; + messageToolAvailable?: boolean; }): SourceReplyDeliveryMode { return resolveSourceReplyDeliveryMode(params); }