feat(channels): add suppressOutbound config for listen-only mode#28761
feat(channels): add suppressOutbound config for listen-only mode#28761amitmiran137 wants to merge 38 commits intoopenclaw:mainfrom
Conversation
Greptile SummaryAdds two new configuration options for listen-only/suppress-outbound channel mode: What works well:
Key issue:
Impact: Confidence Score: 3/5
Last reviewed commit: ea013c6 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ea013c6804
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…d for Discord/Slack/Telegram
…tions under suppressOutbound
…asoning stream under suppressOutbound
…am auth/menu sends under suppressOutbound
Made-with: Cursor
…e entries on recovery
…h under suppressOutbound
…prevent false thread participation
Made-with: Cursor
2bef769 to
a466309
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a46630922a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (params.accountId) { | ||
| const accountSuppressed = resolveAccountEntry( | ||
| providerConfig.accounts, | ||
| params.accountId, | ||
| )?.suppressOutbound; |
There was a problem hiding this comment.
Resolve effective account before checking suppressOutbound
This check only applies account-level suppression when accountId is explicitly provided, so requests that omit accountId can bypass accounts.<id>.suppressOutbound even though downstream channel send paths resolve to a default account (for example, API/CLI sends that rely on default account fallback). In that configuration, a listen-only account can still send outbound messages unless channel-level suppression is also set, which breaks the advertised account-level override behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 01f68970e8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const discordCommandSuppressed = isOutboundSuppressed({ cfg, channel: "discord", accountId }); | ||
|
|
||
| const respond = async (content: string, options?: { ephemeral?: boolean }) => { | ||
| if (discordCommandSuppressed) { | ||
| logVerbose("[suppressOutbound] Blocked Discord native-command respond"); |
There was a problem hiding this comment.
Suppress Discord picker/button interaction responses too
This guard only applies inside dispatchDiscordCommandInteraction, but native-command component handlers still emit outbound writes directly (for example handleDiscordModelPickerInteraction uses interaction.update(...)/interaction.followUp(...) when users click model-picker controls). In channels.discord.suppressOutbound=true setups, clicking an existing picker message still sends Discord responses, so listen-only mode is bypassed for this interaction flow.
Useful? React with 👍 / 👎.
| const slashSuppressed = isOutboundSuppressed({ | ||
| cfg, | ||
| channel: "slack", | ||
| accountId: account.accountId, | ||
| }); | ||
| const respond: typeof p.respond = slashSuppressed | ||
| ? async () => { | ||
| logVerbose("[suppressOutbound] Blocked Slack slash respond"); | ||
| } |
There was a problem hiding this comment.
Apply slash suppression outside handleSlashCommand path
The new slashSuppressed gating only wraps handleSlashCommand responses, but slash arg-action handling still sends ephemeral replies before entering that helper (for stale/foreign button payloads via respondFn/chat.postEphemeral). With channels.slack.suppressOutbound=true, those arg-menu error paths still produce outbound Slack messages, so suppression is incomplete for interactive slash workflows.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c66cc368c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ( | ||
| !dryRun && | ||
| !SUPPRESS_EXEMPT_ACTIONS.has(action) && | ||
| isOutboundSuppressed({ cfg, channel, accountId }) | ||
| ) { |
There was a problem hiding this comment.
Fail fast on suppressOutbound before attachment hydration
The suppression guard is evaluated only after hydrateAttachmentParamsForAction(...) and target resolution run, so suppressed send/sendAttachment actions still execute expensive side effects (including loadWebMedia fetches in src/infra/outbound/message-action-params.ts) before being rejected. In listen-only deployments this means requests that are guaranteed to fail can still read local files or make outbound HTTP/media fetches, adding avoidable latency and network egress despite suppressOutbound being enabled.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d5121783a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (pickerCommandContext) { | ||
| if (discordCommandSuppressed) { | ||
| logVerbose("[suppressOutbound] Blocked Discord model picker"); | ||
| return; |
There was a problem hiding this comment.
Guard model-picker component handlers under suppressOutbound
This new guard only blocks the initial command path, but channels.discord.suppressOutbound=true is still bypassed when users interact with existing /model or command-arg components: handleDiscordModelPickerInteraction and handleDiscordCommandArgInteraction in the same file continue to call interaction.update(...)/interaction.followUp(...) without any suppression check, so outbound Discord writes still occur in listen-only mode. Fresh evidence is that these component handlers are invoked directly from the fallback button/select classes and do not pass through this guarded branch.
Useful? React with 👍 / 👎.
|
This pull request has been automatically marked as stale due to inactivity. |
Summary
Adds a
suppressOutboundboolean config flag that blocks all outbound communication for a given channel or account while keeping inbound message processing fully functional. This enables safe "listen-only" deployments where the gateway ingests and processes messages without sending anything back.Resolves #22791
Motivation
Users running OpenClaw alongside existing support agents or in monitoring/audit scenarios need a way to observe conversations without the gateway sending any replies, reactions, typing indicators, or status updates. Previously, the only option was to disconnect the channel entirely, losing inbound visibility.
Configuration
The flag can be set at the account level (highest priority) or the channel level:
Resolution order: account-level wins over channel-level when both are set.
Per-account control (multi-instance setups)
In deployments with multiple numbers or bot accounts on the same channel, each account can be independently configured. For example, to monitor your personal WhatsApp number while keeping the agent number active:
The same pattern works for any multi-instance channel:
You can also set channel-level suppression as the default and then override specific accounts:
What is blocked vs. allowed
send/polldelivery returns explicit errorChannel coverage
suppressOutbound)Architecture
Core helper
src/infra/outbound/suppress-outbound.ts— singleisOutboundSuppressed({ cfg, channel, accountId })function used by all guard sites. Resolution: account-level → channel-level →false. Account lookup is case-insensitive.Guard layers
deliverOutboundPayloadsthrows when suppressed (preserves pending delivery queue entries for retry once suppression is lifted).send/pollhandlers returnINVALID_REQUESTearly.runMessageActionblocks outbound tool calls but exempts read-only actions viaSUPPRESS_EXEMPT_ACTIONS.sendReadReceipts: falsewhen suppressed, preventing read receipts at the process level.respond()paths), and Telegram auth/error/menu messages are all guarded.recoverPendingDeliveriesrecognizes[suppressOutbound]errors as deferred, skipping retry count increments and preserving entries for delivery when suppression is lifted.Config & types
suppressOutbound: z.boolean().optional()added to all account schemas andReplyRuntimeConfigSchemaShape.suppressOutbound?: booleanadded toCommonChannelMessagingConfig,WhatsAppSharedConfig,TelegramAccountConfig,DiscordAccountConfig,SlackAccountConfig,SignalAccountConfig(via common),IMessageAccountConfig,MSTeamsConfig,GoogleChatConfig.Tests
suppress-outbound.test.tsdeliver.test.tssend.test.tsINVALID_REQUESTmessage-action-runner.test.tsSUPPRESS_EXEMPT_ACTIONSbypassaccess-control.test.tsack-reaction.test.tsprocess-message.inbound-contract.test.tsdelivery.suppress-outbound.test.tsdm-access.suppress-outbound.test.tsbot-message-context.suppress-outbound.test.tsbot-message-dispatch.test.tsreply-delivery.suppress-outbound.test.tsmessage-handler.process.test.tsreplies.suppress-outbound.test.tsdeliveredreturn value, slash command responses blocked/allowedprepare.test.tsevent-handler.suppress-outbound.test.tsdeliver.suppress-outbound.test.tsFiles changed
Click to expand file list
Config & types
src/config/types.channel-messaging-common.ts— addsuppressOutboundto common configsrc/config/types.discord.ts— addsuppressOutboundto Discord configsrc/config/types.googlechat.ts— addsuppressOutboundto Google Chat configsrc/config/types.imessage.ts— addsuppressOutboundto iMessage configsrc/config/types.msteams.ts— addsuppressOutboundto MS Teams configsrc/config/types.slack.ts— addsuppressOutboundto Slack configsrc/config/types.telegram.ts— addsuppressOutboundto Telegram configsrc/config/types.whatsapp.ts— addsuppressOutboundto WhatsApp configsrc/config/zod-schema.core.ts— addsuppressOutboundto reply runtime schemasrc/config/zod-schema.providers-core.ts— addsuppressOutboundto all account schemassrc/config/zod-schema.providers-whatsapp.ts— addsuppressOutboundto WhatsApp schemaCore infra
src/infra/outbound/suppress-outbound.ts— centralisOutboundSuppressed()helper (new)src/infra/outbound/deliver.ts— throw on suppression indeliverOutboundPayloadssrc/infra/outbound/delivery-queue.ts— defer suppressed entries during recovery (no retry increment)src/infra/outbound/message-action-runner.ts— agent tool action gating with exempt listsrc/gateway/server-methods/send.ts— gateway send/poll rejectionWhatsApp
src/web/inbound/access-control.ts— pairing reply guard, state preservationsrc/web/auto-reply/monitor/process-message.ts— auto-reply and composing guardsrc/web/auto-reply/monitor/ack-reaction.ts— ack reaction guardsrc/web/inbound/monitor.ts— connect presence + read receipt guardTelegram
src/telegram/bot/delivery.ts— reply delivery guardsrc/telegram/dm-access.ts— pairing reply guardsrc/telegram/bot-message-context.ts— ack reaction and status reaction guardsrc/telegram/bot-message-dispatch.ts— typing, status reactions, answer/reasoning streaming guardsrc/telegram/bot-handlers.ts— passcfgtoenforceTelegramDmAccesssrc/telegram/bot-native-commands.ts— auth/error/menu command reply guardDiscord
src/discord/monitor/reply-delivery.ts— reply delivery guardsrc/discord/monitor/message-handler.preflight.ts— pairing reply guardsrc/discord/monitor/message-handler.process.ts— typing, status reactions, draft streaming guardsrc/discord/monitor/agent-components.ts— component typing guard, DM auth/pairing reply guardsrc/discord/monitor/native-command.ts— native/slash command reply guard, command menu guard, model picker guardSlack
src/slack/monitor/replies.ts— reply delivery + slash command response guardsrc/slack/monitor/message-handler/dispatch.ts— typing, streaming guardsrc/slack/monitor/message-handler/prepare.ts— ack reaction guard + DM pairing reply guardsrc/slack/monitor/slash.ts— suppress allrespond()paths + pass cfg/accountId to slash reply deliverySignal
src/signal/monitor.ts— reply delivery guard, daemon read receipt suppressionsrc/signal/monitor/event-handler.ts— pairing reply, typing, read receipt guardiMessage
src/imessage/monitor/deliver.ts— reply delivery guardsrc/imessage/monitor/monitor-provider.ts— pairing reply guard