From 19ca0c5949148ee1c0b4227b833a7ef403bc6659 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 7 Mar 2026 09:48:34 -0800 Subject: [PATCH] Security: add configurable unpaired DM responses --- .../bluebubbles/src/monitor-processing.ts | 20 +++++----- extensions/feishu/src/bot.ts | 21 +++++----- extensions/googlechat/src/monitor-access.ts | 21 +++++----- extensions/irc/src/inbound.ts | 16 ++++---- .../mattermost/src/mattermost/monitor.ts | 33 ++++++++------- .../mattermost/src/mattermost/slash-http.ts | 11 ++--- extensions/nextcloud-talk/src/inbound.ts | 19 ++++----- extensions/zalo/src/monitor.ts | 14 ++++--- extensions/zalouser/src/monitor.ts | 17 ++++---- src/config/schema.help.ts | 14 +++++++ src/config/schema.labels.ts | 7 ++++ src/config/types.base.ts | 1 + src/config/types.channel-messaging-common.ts | 3 ++ src/config/types.discord.ts | 3 ++ src/config/types.imessage.ts | 3 ++ src/config/types.slack.ts | 3 ++ src/config/types.telegram.ts | 3 ++ src/config/types.whatsapp.ts | 3 ++ src/config/zod-schema.core.ts | 2 + src/config/zod-schema.providers-core.ts | 7 ++++ src/config/zod-schema.providers-whatsapp.ts | 2 + src/discord/monitor/agent-components.ts | 20 ++++++---- .../monitor/message-handler.preflight.ts | 21 +++++----- src/discord/monitor/native-command.ts | 18 +++++---- src/imessage/monitor/monitor-provider.ts | 29 +++++++------- src/line/bot-handlers.ts | 4 ++ src/line/types.ts | 2 + src/pairing/pairing-challenge.test.ts | 20 ++++++++++ src/pairing/pairing-challenge.ts | 8 +++- src/pairing/pairing-messages.test.ts | 26 ++++++++++++ src/pairing/pairing-messages.ts | 40 +++++++++++++------ src/signal/monitor.ts | 1 + src/signal/monitor/access-policy.ts | 3 ++ src/signal/monitor/event-handler.ts | 1 + src/signal/monitor/event-handler.types.ts | 8 +++- src/slack/monitor/context.ts | 5 ++- src/slack/monitor/dm-auth.ts | 1 + src/slack/monitor/provider.ts | 1 + src/telegram/bot-handlers.ts | 1 + src/telegram/bot-message-context.ts | 4 ++ src/telegram/bot-message.ts | 1 + src/telegram/dm-access.ts | 25 ++++++------ src/web/accounts.ts | 9 ++++- src/web/inbound/access-control.ts | 17 +++++--- 44 files changed, 339 insertions(+), 149 deletions(-) create mode 100644 src/pairing/pairing-challenge.test.ts diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index a1c316429e4..70e0765a07e 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -603,15 +603,17 @@ export async function processMessage( if (created) { logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`); try { - await sendMessageBlueBubbles( - message.senderId, - core.channel.pairing.buildPairingReply({ - channel: "bluebubbles", - idLine: `Your BlueBubbles sender id: ${message.senderId}`, - code, - }), - { cfg: config, accountId: account.accountId }, - ); + const replyText = core.channel.pairing.buildPairingReply({ + channel: "bluebubbles", + idLine: `Your BlueBubbles sender id: ${message.senderId}`, + code, + }); + if (replyText) { + await sendMessageBlueBubbles(message.senderId, replyText, { + cfg: config, + accountId: account.accountId, + }); + } statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { logVerbose( diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 3540036c8a6..eec6add136f 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1108,16 +1108,19 @@ export async function handleFeishuMessage(params: { if (created) { log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`); try { - await sendMessageFeishu({ - cfg, - to: `chat:${ctx.chatId}`, - text: core.channel.pairing.buildPairingReply({ - channel: "feishu", - idLine: `Your Feishu user id: ${ctx.senderOpenId}`, - code, - }), - accountId: account.accountId, + const replyText = core.channel.pairing.buildPairingReply({ + channel: "feishu", + idLine: `Your Feishu user id: ${ctx.senderOpenId}`, + code, }); + if (replyText) { + await sendMessageFeishu({ + cfg, + to: `chat:${ctx.chatId}`, + text: replyText, + accountId: account.accountId, + }); + } } catch (err) { log( `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`, diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index daecea59f8a..4089f474ae3 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -318,16 +318,19 @@ export async function applyGoogleChatInboundAccessPolicy(params: { if (created) { logVerbose(`googlechat pairing request sender=${senderId}`); try { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: core.channel.pairing.buildPairingReply({ - channel: "googlechat", - idLine: `Your Google Chat user id: ${senderId}`, - code, - }), + const replyText = core.channel.pairing.buildPairingReply({ + channel: "googlechat", + idLine: `Your Google Chat user id: ${senderId}`, + code, }); - statusSink?.({ lastOutboundAt: Date.now() }); + if (replyText) { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: replyText, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } } catch (err) { logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`); } diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 6c03ebadf02..903a661a16d 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -219,13 +219,15 @@ export async function handleIrcInbound(params: { idLine: `Your IRC id: ${senderDisplay}`, code, }); - await deliverIrcReply({ - payload: { text: reply }, - target: message.senderNick, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); + if (reply) { + await deliverIrcReply({ + payload: { text: reply }, + target: message.senderNick, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + } } catch (err) { runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`); } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 7de24cb03e6..8f9887f20ac 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1047,12 +1047,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} id: params.payload.user_id, meta: { name: params.userName }, }); + const replyText = core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${params.payload.user_id}`, + code, + }); + if (!replyText) { + return { ephemeral_text: "" }; + } return { - ephemeral_text: core.channel.pairing.buildPairingReply({ - channel: "mattermost", - idLine: `Your Mattermost user id: ${params.payload.user_id}`, - code, - }), + ephemeral_text: replyText, }; } const denyText = @@ -1316,15 +1320,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} logVerboseMessage(`mattermost: pairing request sender=${senderId} created=${created}`); if (created) { try { - await sendMessageMattermost( - `user:${senderId}`, - core.channel.pairing.buildPairingReply({ - channel: "mattermost", - idLine: `Your Mattermost user id: ${senderId}`, - code, - }), - { accountId: account.accountId }, - ); + const replyText = core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${senderId}`, + code, + }); + if (replyText) { + await sendMessageMattermost(`user:${senderId}`, replyText, { + accountId: account.accountId, + }); + } opts.statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 3c64b083d3a..6dffd2f0f5a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -171,11 +171,12 @@ async function authorizeSlashInvocation(params: { ...decision, denyResponse: { response_type: "ephemeral", - text: core.channel.pairing.buildPairingReply({ - channel: "mattermost", - idLine: `Your Mattermost user id: ${senderId}`, - code, - }), + text: + core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${senderId}`, + code, + }) ?? "", }, }; } diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 1657cbd9113..66005c907d6 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -179,15 +179,16 @@ export async function handleNextcloudTalkInbound(params: { }); if (created) { try { - await sendMessageNextcloudTalk( - roomToken, - core.channel.pairing.buildPairingReply({ - channel: CHANNEL_ID, - idLine: `Your Nextcloud user id: ${senderId}`, - code, - }), - { accountId: account.accountId }, - ); + const replyText = core.channel.pairing.buildPairingReply({ + channel: CHANNEL_ID, + idLine: `Your Nextcloud user id: ${senderId}`, + code, + }); + if (replyText) { + await sendMessageNextcloudTalk(roomToken, replyText, { + accountId: account.accountId, + }); + } statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index b276019879e..e2179a88ff9 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -422,15 +422,19 @@ async function processMessageWithPipeline(params: { if (created) { logVerbose(core, runtime, `zalo pairing request sender=${senderId}`); try { + const replyText = core.channel.pairing.buildPairingReply({ + channel: "zalo", + idLine: `Your Zalo user id: ${senderId}`, + code, + }); + if (!replyText) { + return; + } await sendMessage( token, { chat_id: chatId, - text: core.channel.pairing.buildPairingReply({ - channel: "zalo", - idLine: `Your Zalo user id: ${senderId}`, - code, - }), + text: replyText, }, fetcher, ); diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index fc3e07c564e..9a1ddb75d23 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -270,15 +270,14 @@ async function processMessage( if (created) { logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`); try { - await sendMessageZalouser( - chatId, - core.channel.pairing.buildPairingReply({ - channel: "zalouser", - idLine: `Your Zalo user id: ${senderId}`, - code, - }), - { profile: account.profile }, - ); + const replyText = core.channel.pairing.buildPairingReply({ + channel: "zalouser", + idLine: `Your Zalo user id: ${senderId}`, + code, + }); + if (replyText) { + await sendMessageZalouser(chatId, replyText, { profile: account.profile }); + } statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { logVerbose( diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f13944cb127..5e501d9c676 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1444,6 +1444,8 @@ export const FIELD_HELP: Record = { "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "channels.telegram.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', + "channels.telegram.unpairedResponse": + 'Response style for unknown direct-message senders in pairing mode: "silent", "code-only", or "branded" (default).', "channels.telegram.streaming": 'Unified Telegram stream preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram. Legacy boolean/streamMode keys are auto-mapped.', "channels.discord.streaming": @@ -1478,17 +1480,27 @@ export const FIELD_HELP: Record = { "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.", "channels.whatsapp.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', + "channels.whatsapp.unpairedResponse": + 'Response style for unknown direct-message senders in pairing mode: "silent", "code-only", or "branded" (default).', "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", "channels.whatsapp.debounceMs": "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", "channels.signal.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', + "channels.signal.unpairedResponse": + 'Response style for unknown direct-message senders in pairing mode: "silent", "code-only", or "branded" (default).', "channels.imessage.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', + "channels.imessage.unpairedResponse": + 'Response style for unknown direct-message senders in pairing mode: "silent", "code-only", or "branded" (default).', "channels.bluebubbles.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', + "channels.bluebubbles.unpairedResponse": + 'Response style for unknown direct-message senders in pairing mode: "silent", "code-only", or "branded" (default).', "channels.discord.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"].', + "channels.discord.unpairedResponse": + 'Response style for unknown direct-message senders in pairing mode: "silent", "code-only", or "branded" (default).', "channels.discord.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"] (legacy: channels.discord.dm.allowFrom).', "channels.discord.retry.attempts": @@ -1554,4 +1566,6 @@ export const FIELD_HELP: Record = { 'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"] (legacy: channels.slack.dm.allowFrom).', "channels.slack.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"].', + "channels.slack.unpairedResponse": + 'Response style for unknown direct-message senders in pairing mode: "silent", "code-only", or "branded" (default).', }; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9266516b957..9c008d6aa9b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -704,6 +704,7 @@ export const FIELD_LABELS: Record = { ...IRC_FIELD_LABELS, "channels.telegram.botToken": "Telegram Bot Token", "channels.telegram.dmPolicy": "Telegram DM Policy", + "channels.telegram.unpairedResponse": "Telegram Unpaired DM Response", "channels.telegram.configWrites": "Telegram Config Writes", "channels.telegram.commands.native": "Telegram Native Commands", "channels.telegram.commands.nativeSkills": "Telegram Native Skill Commands", @@ -721,17 +722,22 @@ export const FIELD_LABELS: Record = { "channels.telegram.threadBindings.spawnSubagentSessions": "Telegram Thread-Bound Subagent Spawn", "channels.telegram.threadBindings.spawnAcpSessions": "Telegram Thread-Bound ACP Spawn", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", + "channels.whatsapp.unpairedResponse": "WhatsApp Unpaired DM Response", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", "channels.whatsapp.configWrites": "WhatsApp Config Writes", "channels.signal.dmPolicy": "Signal DM Policy", + "channels.signal.unpairedResponse": "Signal Unpaired DM Response", "channels.signal.configWrites": "Signal Config Writes", "channels.imessage.dmPolicy": "iMessage DM Policy", + "channels.imessage.unpairedResponse": "iMessage Unpaired DM Response", "channels.imessage.configWrites": "iMessage Config Writes", "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", + "channels.bluebubbles.unpairedResponse": "BlueBubbles Unpaired DM Response", "channels.msteams.configWrites": "MS Teams Config Writes", "channels.irc.configWrites": "IRC Config Writes", "channels.discord.dmPolicy": "Discord DM Policy", + "channels.discord.unpairedResponse": "Discord Unpaired DM Response", "channels.discord.dm.policy": "Discord DM Policy", "channels.discord.configWrites": "Discord Config Writes", "channels.discord.proxy": "Discord Proxy URL", @@ -779,6 +785,7 @@ export const FIELD_LABELS: Record = { "channels.discord.activityUrl": "Discord Presence Activity URL", "channels.slack.dm.policy": "Slack DM Policy", "channels.slack.dmPolicy": "Slack DM Policy", + "channels.slack.unpairedResponse": "Slack Unpaired DM Response", "channels.slack.configWrites": "Slack Config Writes", "channels.slack.commands.native": "Slack Native Commands", "channels.slack.commands.nativeSkills": "Slack Native Skill Commands", diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 03336561d64..94dbdc942ef 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -7,6 +7,7 @@ export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-ch export type ReplyToMode = "off" | "first" | "all"; export type GroupPolicy = "open" | "disabled" | "allowlist"; export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; +export type UnpairedResponseMode = "silent" | "code-only" | "branded"; export type OutboundRetryConfig = { /** Max retry attempts for outbound requests (default: 3). */ diff --git a/src/config/types.channel-messaging-common.ts b/src/config/types.channel-messaging-common.ts index 5d927884bd6..5dafcd0bea8 100644 --- a/src/config/types.channel-messaging-common.ts +++ b/src/config/types.channel-messaging-common.ts @@ -3,6 +3,7 @@ import type { DmPolicy, GroupPolicy, MarkdownConfig, + UnpairedResponseMode, } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; @@ -20,6 +21,8 @@ export type CommonChannelMessagingConfig = { enabled?: boolean; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; + /** How OpenClaw responds to unknown DM senders in pairing mode. */ + unpairedResponse?: UnpairedResponseMode; /** Optional allowlist for inbound DM senders. */ allowFrom?: Array; /** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */ diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 2d2e674f6b6..f2cf9668226 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -7,6 +7,7 @@ import type { MarkdownConfig, OutboundRetryConfig, ReplyToMode, + UnpairedResponseMode, } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; @@ -219,6 +220,8 @@ export type DiscordAccountConfig = { configWrites?: boolean; /** If false, do not start this Discord account. Default: true. */ enabled?: boolean; + /** How OpenClaw responds to unknown DM senders in pairing mode. */ + unpairedResponse?: UnpairedResponseMode; token?: SecretInput; /** HTTP(S) proxy URL for Discord gateway WebSocket connections. */ proxy?: string; diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 9fe1b96fef2..0cb782bfe49 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -3,6 +3,7 @@ import type { DmPolicy, GroupPolicy, MarkdownConfig, + UnpairedResponseMode, } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; @@ -31,6 +32,8 @@ export type IMessageAccountConfig = { region?: string; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; + /** How OpenClaw responds to unknown DM senders in pairing mode. */ + unpairedResponse?: UnpairedResponseMode; /** Optional allowlist for inbound handles or chat_id targets. */ allowFrom?: Array; /** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 96abe2641d6..341308de312 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -4,6 +4,7 @@ import type { GroupPolicy, MarkdownConfig, ReplyToMode, + UnpairedResponseMode, } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; @@ -98,6 +99,8 @@ export type SlackAccountConfig = { configWrites?: boolean; /** If false, do not start this Slack account. Default: true. */ enabled?: boolean; + /** How OpenClaw responds to unknown DM senders in pairing mode. */ + unpairedResponse?: UnpairedResponseMode; botToken?: string; appToken?: string; userToken?: string; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 28adb785db1..847bdfb8c18 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -7,6 +7,7 @@ import type { OutboundRetryConfig, ReplyToMode, SessionThreadBindingsConfig, + UnpairedResponseMode, } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; @@ -74,6 +75,8 @@ export type TelegramAccountConfig = { * - "disabled": ignore all inbound DMs */ dmPolicy?: DmPolicy; + /** How OpenClaw responds to unknown DM senders in pairing mode. */ + unpairedResponse?: UnpairedResponseMode; /** If false, do not start this Telegram account. Default: true. */ enabled?: boolean; botToken?: string; diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index a39a5c28e1f..4c207f997bd 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -3,6 +3,7 @@ import type { DmPolicy, GroupPolicy, MarkdownConfig, + UnpairedResponseMode, } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; @@ -40,6 +41,8 @@ type WhatsAppSharedConfig = { enabled?: boolean; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; + /** How OpenClaw responds to unknown DM senders in pairing mode. */ + unpairedResponse?: UnpairedResponseMode; /** Same-phone setup (bot uses your personal WhatsApp number). */ selfChatMode?: boolean; /** Optional allowlist for WhatsApp direct chats (E.164). */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 733917e4dac..95cd54db584 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -315,6 +315,8 @@ export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]); export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); +export const UnpairedResponseSchema = z.enum(["silent", "code-only", "branded"]); + export const BlockStreamingCoalesceSchema = z .object({ minChars: z.number().int().positive().optional(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d01ad612153..bbccec3f866 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -30,6 +30,7 @@ import { ReplyToModeSchema, RetryConfigSchema, TtsConfigSchema, + UnpairedResponseSchema, requireAllowlistAllowFrom, requireOpenAllowFrom, } from "./zod-schema.core.js"; @@ -159,6 +160,7 @@ export const TelegramAccountSchemaBase = z customCommands: z.array(TelegramCustomCommandSchema).optional(), configWrites: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + unpairedResponse: UnpairedResponseSchema.optional().default("branded"), botToken: SecretInputSchema.optional().register(sensitive), tokenFile: z.string().optional(), replyToMode: ReplyToModeSchema.optional(), @@ -955,6 +957,7 @@ export const SignalAccountSchemaBase = z ignoreStories: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + unpairedResponse: UnpairedResponseSchema.optional().default("branded"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), defaultTo: z.string().optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), @@ -1075,6 +1078,7 @@ export const IrcAccountSchemaBase = z nickserv: IrcNickServSchema.optional(), channels: z.array(z.string()).optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + unpairedResponse: UnpairedResponseSchema.optional().default("branded"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), defaultTo: z.string().optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), @@ -1184,6 +1188,7 @@ export const IMessageAccountSchemaBase = z service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(), region: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + unpairedResponse: UnpairedResponseSchema.optional().default("branded"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), defaultTo: z.string().optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), @@ -1313,6 +1318,7 @@ export const BlueBubblesAccountSchemaBase = z password: SecretInputSchema.optional().register(sensitive), webhookPath: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + unpairedResponse: UnpairedResponseSchema.optional().default("branded"), allowFrom: z.array(BlueBubblesAllowFromEntry).optional(), groupAllowFrom: z.array(BlueBubblesAllowFromEntry).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), @@ -1424,6 +1430,7 @@ export const MSTeamsConfigSchema = z .strict() .optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + unpairedResponse: UnpairedResponseSchema.optional().default("branded"), allowFrom: z.array(z.string()).optional(), defaultTo: z.string().optional(), groupAllowFrom: z.array(z.string()).optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 2faba715bad..75cd591ef1d 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -7,6 +7,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + UnpairedResponseSchema, } from "./zod-schema.core.js"; const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); @@ -40,6 +41,7 @@ const WhatsAppSharedSchema = z.object({ messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + unpairedResponse: UnpairedResponseSchema.optional().default("branded"), selfChatMode: z.boolean().optional(), allowFrom: z.array(z.string()).optional(), defaultTo: z.string().optional(), diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index ecf7325338a..fed193589a5 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -519,6 +519,7 @@ async function ensureDmComponentAuthorized(params: { } if (dmPolicy === "pairing") { + const unpairedResponse = ctx.discordConfig?.unpairedResponse ?? "branded"; const { code, created } = await upsertChannelPairingRequest({ channel: "discord", id: user.id, @@ -529,14 +530,19 @@ async function ensureDmComponentAuthorized(params: { }, }); try { + const replyText = created + ? buildPairingReply({ + channel: "discord", + idLine: `Your Discord user id: ${user.id}`, + code, + mode: unpairedResponse, + }) + : "Pairing already requested. Ask the bot owner to approve your code."; + if (!replyText) { + return false; + } await interaction.reply({ - content: created - ? buildPairingReply({ - channel: "discord", - idLine: `Your Discord user id: ${user.id}`, - code, - }) - : "Pairing already requested. Ask the bot owner to approve your code.", + content: replyText, ...replyOpts, }); } catch { diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index d5a536bf661..4a07d2e2635 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -209,6 +209,7 @@ export async function preflightDiscordMessage( } const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing"; + const unpairedResponse = params.discordConfig?.unpairedResponse ?? "branded"; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID; const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig); @@ -251,19 +252,19 @@ export async function preflightDiscordMessage( `discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`, ); try { - await sendMessageDiscord( - `user:${author.id}`, - buildPairingReply({ - channel: "discord", - idLine: `Your Discord user id: ${author.id}`, - code, - }), - { + const replyText = buildPairingReply({ + channel: "discord", + idLine: `Your Discord user id: ${author.id}`, + code, + mode: unpairedResponse, + }); + if (replyText) { + await sendMessageDiscord(`user:${author.id}`, replyText, { token: params.token, rest: params.client.rest, accountId: params.accountId, - }, - ); + }); + } } catch (err) { logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`); } diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 18960697a40..e5acf094f7d 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -1369,6 +1369,7 @@ async function dispatchDiscordCommandInteraction(params: { await respond("Discord DMs are disabled."); return; } + const unpairedResponse = discordConfig?.unpairedResponse ?? "branded"; const dmAccess = await resolveDiscordDmCommandAccess({ accountId, dmPolicy, @@ -1392,14 +1393,15 @@ async function dispatchDiscordCommandInteraction(params: { name: sender.name, }, onPairingCreated: async (code) => { - await respond( - buildPairingReply({ - channel: "discord", - idLine: `Your Discord user id: ${user.id}`, - code, - }), - { ephemeral: true }, - ); + const replyText = buildPairingReply({ + channel: "discord", + idLine: `Your Discord user id: ${user.id}`, + code, + mode: unpairedResponse, + }); + if (replyText) { + await respond(replyText, { ephemeral: true }); + } }, onUnauthorized: async () => { await respond("You are not authorized to use this command.", { ephemeral: true }); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index ffc15a4df0a..cb57ecc1ac3 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -300,20 +300,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P if (created) { logVerbose(`imessage pairing request sender=${decision.senderId}`); try { - await sendMessageIMessage( - sender, - buildPairingReply({ - channel: "imessage", - idLine: `Your iMessage sender id: ${decision.senderId}`, - code, - }), - { - client, - maxBytes: mediaMaxBytes, - accountId: accountInfo.accountId, - ...(chatId ? { chatId } : {}), - }, - ); + const replyText = buildPairingReply({ + channel: "imessage", + idLine: `Your iMessage sender id: ${decision.senderId}`, + code, + mode: accountInfo.config.unpairedResponse, + }); + if (!replyText) { + return; + } + await sendMessageIMessage(sender, replyText, { + client, + maxBytes: mediaMaxBytes, + accountId: accountInfo.accountId, + ...(chatId ? { chatId } : {}), + }); } catch (err) { logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`); } diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index f28d41e66cf..611cf26dae8 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -248,7 +248,11 @@ async function sendLinePairingReply(params: { channel: "line", idLine: `Your ${idLabel}: ${senderId}`, code, + mode: context.account.config.unpairedResponse, }); + if (!text) { + return; + } try { if (replyToken) { await replyMessageLine(replyToken, [{ type: "text", text }], { diff --git a/src/line/types.ts b/src/line/types.ts index 3a866f6151e..5c222e0cc1b 100644 --- a/src/line/types.ts +++ b/src/line/types.ts @@ -8,6 +8,7 @@ import type { LocationMessage, } from "@line/bot-sdk"; import type { BaseProbeResult } from "../channels/plugins/types.js"; +import type { UnpairedResponseMode } from "../config/types.js"; export type LineTokenSource = "config" | "env" | "file" | "none"; @@ -21,6 +22,7 @@ interface LineAccountBaseConfig { allowFrom?: Array; groupAllowFrom?: Array; dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; + unpairedResponse?: UnpairedResponseMode; groupPolicy?: "open" | "allowlist" | "disabled"; /** Outbound response prefix override for this account. */ responsePrefix?: string; diff --git a/src/pairing/pairing-challenge.test.ts b/src/pairing/pairing-challenge.test.ts new file mode 100644 index 00000000000..ee8cf981552 --- /dev/null +++ b/src/pairing/pairing-challenge.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { issuePairingChallenge } from "./pairing-challenge.js"; + +describe("issuePairingChallenge", () => { + it("skips sending a reply in silent mode", async () => { + const sendPairingReply = vi.fn(async (_text: string) => {}); + + const result = await issuePairingChallenge({ + channel: "discord", + senderId: "123", + senderIdLine: "Your Discord user id: 123", + responseMode: "silent", + upsertPairingRequest: async () => ({ code: "PAIR123", created: true }), + sendPairingReply, + }); + + expect(result).toEqual({ created: true, code: "PAIR123" }); + expect(sendPairingReply).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pairing/pairing-challenge.ts b/src/pairing/pairing-challenge.ts index 8bf068f8d23..affaa9a4c93 100644 --- a/src/pairing/pairing-challenge.ts +++ b/src/pairing/pairing-challenge.ts @@ -1,3 +1,4 @@ +import type { UnpairedResponseMode } from "../config/types.base.js"; import { buildPairingReply } from "./pairing-messages.js"; type PairingMeta = Record; @@ -12,7 +13,8 @@ export type PairingChallengeParams = { meta?: PairingMeta; }) => Promise<{ code: string; created: boolean }>; sendPairingReply: (text: string) => Promise; - buildReplyText?: (params: { code: string; senderIdLine: string }) => string; + responseMode?: UnpairedResponseMode; + buildReplyText?: (params: { code: string; senderIdLine: string }) => string | null; onCreated?: (params: { code: string }) => void; onReplyError?: (err: unknown) => void; }; @@ -38,7 +40,11 @@ export async function issuePairingChallenge( channel: params.channel, idLine: params.senderIdLine, code, + mode: params.responseMode, }); + if (replyText == null) { + return { created: true, code }; + } try { await params.sendPairingReply(replyText); } catch (err) { diff --git a/src/pairing/pairing-messages.test.ts b/src/pairing/pairing-messages.test.ts index 5480d333c51..60a1f2a105f 100644 --- a/src/pairing/pairing-messages.test.ts +++ b/src/pairing/pairing-messages.test.ts @@ -50,6 +50,7 @@ describe("buildPairingReply", () => { for (const testCase of cases) { it(`formats pairing reply for ${testCase.channel}`, () => { const text = buildPairingReply(testCase); + expect(text).not.toBeNull(); expect(text).toContain(testCase.idLine); expect(text).toContain(`Pairing code: ${testCase.code}`); // CLI commands should respect OPENCLAW_PROFILE when set (most tests run with isolated profile) @@ -59,4 +60,29 @@ describe("buildPairingReply", () => { expect(text).toMatch(commandRe); }); } + + it("omits branding in code-only mode", () => { + const text = buildPairingReply({ + channel: "discord", + idLine: "Your Discord user id: 1", + code: "ABC123", + mode: "code-only", + }); + + expect(text).not.toBeNull(); + expect(text).not.toContain("OpenClaw: access not configured."); + expect(text).toContain("Your Discord user id: 1"); + expect(text).toContain("Pairing code: ABC123"); + }); + + it("returns null in silent mode", () => { + const text = buildPairingReply({ + channel: "discord", + idLine: "Your Discord user id: 1", + code: "ABC123", + mode: "silent", + }); + + expect(text).toBeNull(); + }); }); diff --git a/src/pairing/pairing-messages.ts b/src/pairing/pairing-messages.ts index edcce20348a..2fedb40b9ab 100644 --- a/src/pairing/pairing-messages.ts +++ b/src/pairing/pairing-messages.ts @@ -1,20 +1,36 @@ import { formatCliCommand } from "../cli/command-format.js"; +import type { UnpairedResponseMode } from "../config/types.base.js"; import type { PairingChannel } from "./pairing-store.js"; export function buildPairingReply(params: { channel: PairingChannel; idLine: string; code: string; -}): string { - const { channel, idLine, code } = params; - return [ - "OpenClaw: access not configured.", - "", - idLine, - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - formatCliCommand(`openclaw pairing approve ${channel} ${code}`), - ].join("\n"); + mode?: UnpairedResponseMode; +}): string | null { + const { channel, idLine, code, mode = "branded" } = params; + if (mode === "silent") { + return null; + } + const lines = + mode === "code-only" + ? [ + idLine, + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + formatCliCommand(`openclaw pairing approve ${channel} ${code}`), + ] + : [ + "OpenClaw: access not configured.", + "", + idLine, + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + formatCliCommand(`openclaw pairing approve ${channel} ${code}`), + ]; + return lines.join("\n"); } diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 13812593c63..89e250412a3 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -430,6 +430,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi groupHistories, textLimit, dmPolicy, + unpairedResponse: accountInfo.config.unpairedResponse, allowFrom, groupAllowFrom, groupPolicy, diff --git a/src/signal/monitor/access-policy.ts b/src/signal/monitor/access-policy.ts index e836868ec8d..8bb295c6501 100644 --- a/src/signal/monitor/access-policy.ts +++ b/src/signal/monitor/access-policy.ts @@ -8,6 +8,7 @@ import { isSignalSenderAllowed, type SignalSender } from "../identity.js"; type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; type SignalGroupPolicy = "open" | "allowlist" | "disabled"; +type SignalUnpairedResponseMode = "silent" | "code-only" | "branded"; export async function resolveSignalAccessState(params: { accountId: string; @@ -49,6 +50,7 @@ export async function handleSignalDirectMessageAccess(params: { senderDisplay: string; senderName?: string; accountId: string; + unpairedResponse?: SignalUnpairedResponseMode; sendPairingReply: (text: string) => Promise; log: (message: string) => void; }): Promise { @@ -66,6 +68,7 @@ export async function handleSignalDirectMessageAccess(params: { channel: "signal", senderId: params.senderId, senderIdLine: params.senderIdLine, + responseMode: params.unpairedResponse, meta: { name: params.senderName }, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 7369a166add..aaedb818dfd 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -530,6 +530,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { senderDisplay, senderName: envelope.sourceName ?? undefined, accountId: deps.accountId, + unpairedResponse: deps.unpairedResponse, sendPairingReply: async (text) => { await sendMessageSignal(`signal:${senderRecipient}`, text, { baseUrl: deps.baseUrl, diff --git a/src/signal/monitor/event-handler.types.ts b/src/signal/monitor/event-handler.types.ts index a7f3c6b1d1a..25d1b260a13 100644 --- a/src/signal/monitor/event-handler.types.ts +++ b/src/signal/monitor/event-handler.types.ts @@ -1,7 +1,12 @@ import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import type { DmPolicy, GroupPolicy, SignalReactionNotificationMode } from "../../config/types.js"; +import type { + DmPolicy, + GroupPolicy, + SignalReactionNotificationMode, + UnpairedResponseMode, +} from "../../config/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { SignalSender } from "../identity.js"; @@ -79,6 +84,7 @@ export type SignalEventHandlerDeps = { groupHistories: Map; textLimit: number; dmPolicy: DmPolicy; + unpairedResponse?: UnpairedResponseMode; allowFrom: string[]; groupAllowFrom: string[]; groupPolicy: GroupPolicy; diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index 1d75af03650..8f643b67097 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -3,7 +3,7 @@ import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import type { OpenClawConfig, SlackReactionNotificationMode } from "../../config/config.js"; import { resolveSessionKey, type SessionScope } from "../../config/sessions.js"; -import type { DmPolicy, GroupPolicy } from "../../config/types.js"; +import type { DmPolicy, GroupPolicy, UnpairedResponseMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { createDedupeCache } from "../../infra/dedupe.js"; import { getChildLogger } from "../../logging.js"; @@ -36,6 +36,7 @@ export type SlackMonitorContext = { dmEnabled: boolean; dmPolicy: DmPolicy; + unpairedResponse: UnpairedResponseMode; allowFrom: string[]; allowNameMatching: boolean; groupDmEnabled: boolean; @@ -101,6 +102,7 @@ export function createSlackMonitorContext(params: { dmEnabled: boolean; dmPolicy: DmPolicy; + unpairedResponse?: UnpairedResponseMode; allowFrom: Array | undefined; allowNameMatching: boolean; groupDmEnabled: boolean; @@ -399,6 +401,7 @@ export function createSlackMonitorContext(params: { mainKey: params.mainKey, dmEnabled: params.dmEnabled, dmPolicy: params.dmPolicy, + unpairedResponse: params.unpairedResponse ?? "branded", allowFrom, allowNameMatching: params.allowNameMatching, groupDmEnabled: params.groupDmEnabled, diff --git a/src/slack/monitor/dm-auth.ts b/src/slack/monitor/dm-auth.ts index f11a2aa51f7..f8e3de3e37a 100644 --- a/src/slack/monitor/dm-auth.ts +++ b/src/slack/monitor/dm-auth.ts @@ -41,6 +41,7 @@ export async function authorizeSlackDirectMessage(params: { channel: "slack", senderId: params.senderId, senderIdLine: `Your Slack user id: ${params.senderId}`, + responseMode: params.ctx.unpairedResponse, meta: { name: senderName }, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 12ba1020268..9b37e872803 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -247,6 +247,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { mainKey, dmEnabled, dmPolicy, + unpairedResponse: slackCfg.unpairedResponse, allowFrom, allowNameMatching: isDangerousNameMatchingEnabled(slackCfg), groupDmEnabled, diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 34b8b8de208..5d4b9038e7e 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -1447,6 +1447,7 @@ export const registerTelegramHandlers = ({ const dmAuthorized = await enforceTelegramDmAccess({ isGroup: event.isGroup, dmPolicy, + unpairedResponse: telegramCfg.unpairedResponse, msg: event.msg, chatId: event.chatId, effectiveDmAllow, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index ab628dc0e0a..4656f551520 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -36,6 +36,7 @@ import type { TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, + UnpairedResponseMode, } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; @@ -120,6 +121,7 @@ export type BuildTelegramMessageContextParams = { historyLimit: number; groupHistories: Map; dmPolicy: DmPolicy; + unpairedResponse?: UnpairedResponseMode; allowFrom?: Array; groupAllowFrom?: Array; ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all"; @@ -163,6 +165,7 @@ export const buildTelegramMessageContext = async ({ historyLimit, groupHistories, dmPolicy, + unpairedResponse, allowFrom, groupAllowFrom, ackReactionScope, @@ -301,6 +304,7 @@ export const buildTelegramMessageContext = async ({ !(await enforceTelegramDmAccess({ isGroup, dmPolicy: effectiveDmPolicy, + unpairedResponse, msg, chatId, effectiveDmAllow, diff --git a/src/telegram/bot-message.ts b/src/telegram/bot-message.ts index 15fb1bc943d..3c0fcccf6c5 100644 --- a/src/telegram/bot-message.ts +++ b/src/telegram/bot-message.ts @@ -66,6 +66,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep historyLimit, groupHistories, dmPolicy, + unpairedResponse: telegramCfg.unpairedResponse, allowFrom, groupAllowFrom, ackReactionScope, diff --git a/src/telegram/dm-access.ts b/src/telegram/dm-access.ts index 1c68dd43d69..3c6e8619c54 100644 --- a/src/telegram/dm-access.ts +++ b/src/telegram/dm-access.ts @@ -1,6 +1,6 @@ import type { Message } from "@grammyjs/types"; import type { Bot } from "grammy"; -import type { DmPolicy } from "../config/types.js"; +import type { DmPolicy, UnpairedResponseMode } from "../config/types.js"; import { logVerbose } from "../globals.js"; import { buildPairingReply } from "../pairing/pairing-messages.js"; import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; @@ -34,6 +34,7 @@ function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSe export async function enforceTelegramDmAccess(params: { isGroup: boolean; dmPolicy: DmPolicy; + unpairedResponse?: UnpairedResponseMode; msg: Message; chatId: number; effectiveDmAllow: NormalizedAllowFrom; @@ -93,18 +94,18 @@ export async function enforceTelegramDmAccess(params: { }, "telegram pairing request", ); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - fn: () => - bot.api.sendMessage( - chatId, - buildPairingReply({ - channel: "telegram", - idLine: `Your Telegram user id: ${telegramUserId}`, - code, - }), - ), + const replyText = buildPairingReply({ + channel: "telegram", + idLine: `Your Telegram user id: ${telegramUserId}`, + code, + mode: params.unpairedResponse, }); + if (replyText) { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => bot.api.sendMessage(chatId, replyText), + }); + } } } catch (err) { logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 3370d4c9d80..e43832359e5 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -3,7 +3,12 @@ import path from "node:path"; import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveOAuthDir } from "../config/paths.js"; -import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; +import type { + DmPolicy, + GroupPolicy, + UnpairedResponseMode, + WhatsAppAccountConfig, +} from "../config/types.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; @@ -22,6 +27,7 @@ export type ResolvedWhatsAppAccount = { groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; dmPolicy?: DmPolicy; + unpairedResponse?: UnpairedResponseMode; textChunkLimit?: number; chunkMode?: "length" | "newline"; mediaMaxMb?: number; @@ -136,6 +142,7 @@ export function resolveWhatsAppAccount(params: { isLegacyAuthDir: isLegacy, selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode, dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy, + unpairedResponse: accountCfg?.unpairedResponse ?? rootCfg?.unpairedResponse, allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom, groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom, groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy, diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 2363434f34c..351016f471b 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -60,6 +60,7 @@ export async function checkInboundAccessControl(params: { accountId: params.accountId, }); const dmPolicy = account.dmPolicy ?? "pairing"; + const unpairedResponse = account.unpairedResponse ?? "branded"; const configuredAllowFrom = account.allowFrom ?? []; const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: "whatsapp", @@ -182,13 +183,17 @@ export async function checkInboundAccessControl(params: { `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, ); try { - await params.sock.sendMessage(params.remoteJid, { - text: buildPairingReply({ - channel: "whatsapp", - idLine: `Your WhatsApp phone number: ${candidate}`, - code, - }), + const replyText = buildPairingReply({ + channel: "whatsapp", + idLine: `Your WhatsApp phone number: ${candidate}`, + code, + mode: unpairedResponse, }); + if (replyText) { + await params.sock.sendMessage(params.remoteJid, { + text: replyText, + }); + } } catch (err) { logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); }