Compare commits

...

2 Commits

Author SHA1 Message Date
Vincent Koc
1587b4279a
Merge branch 'main' into vincentkoc-code/unpaired-response-modes 2026-03-07 12:50:02 -05:00
Vincent Koc
19ca0c5949 Security: add configurable unpaired DM responses 2026-03-07 09:48:34 -08:00
44 changed files with 339 additions and 149 deletions

View File

@ -603,15 +603,17 @@ export async function processMessage(
if (created) { if (created) {
logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`); logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
try { try {
await sendMessageBlueBubbles( const replyText = core.channel.pairing.buildPairingReply({
message.senderId, channel: "bluebubbles",
core.channel.pairing.buildPairingReply({ idLine: `Your BlueBubbles sender id: ${message.senderId}`,
channel: "bluebubbles", code,
idLine: `Your BlueBubbles sender id: ${message.senderId}`, });
code, if (replyText) {
}), await sendMessageBlueBubbles(message.senderId, replyText, {
{ cfg: config, accountId: account.accountId }, cfg: config,
); accountId: account.accountId,
});
}
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) { } catch (err) {
logVerbose( logVerbose(

View File

@ -1108,16 +1108,19 @@ export async function handleFeishuMessage(params: {
if (created) { if (created) {
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`); log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
try { try {
await sendMessageFeishu({ const replyText = core.channel.pairing.buildPairingReply({
cfg, channel: "feishu",
to: `chat:${ctx.chatId}`, idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
text: core.channel.pairing.buildPairingReply({ code,
channel: "feishu",
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
code,
}),
accountId: account.accountId,
}); });
if (replyText) {
await sendMessageFeishu({
cfg,
to: `chat:${ctx.chatId}`,
text: replyText,
accountId: account.accountId,
});
}
} catch (err) { } catch (err) {
log( log(
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`, `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,

View File

@ -318,16 +318,19 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
if (created) { if (created) {
logVerbose(`googlechat pairing request sender=${senderId}`); logVerbose(`googlechat pairing request sender=${senderId}`);
try { try {
await sendGoogleChatMessage({ const replyText = core.channel.pairing.buildPairingReply({
account, channel: "googlechat",
space: spaceId, idLine: `Your Google Chat user id: ${senderId}`,
text: core.channel.pairing.buildPairingReply({ code,
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) { } catch (err) {
logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`); logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`);
} }

View File

@ -219,13 +219,15 @@ export async function handleIrcInbound(params: {
idLine: `Your IRC id: ${senderDisplay}`, idLine: `Your IRC id: ${senderDisplay}`,
code, code,
}); });
await deliverIrcReply({ if (reply) {
payload: { text: reply }, await deliverIrcReply({
target: message.senderNick, payload: { text: reply },
accountId: account.accountId, target: message.senderNick,
sendReply: params.sendReply, accountId: account.accountId,
statusSink, sendReply: params.sendReply,
}); statusSink,
});
}
} catch (err) { } catch (err) {
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`); runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
} }

View File

@ -1047,12 +1047,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
id: params.payload.user_id, id: params.payload.user_id,
meta: { name: params.userName }, 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 { return {
ephemeral_text: core.channel.pairing.buildPairingReply({ ephemeral_text: replyText,
channel: "mattermost",
idLine: `Your Mattermost user id: ${params.payload.user_id}`,
code,
}),
}; };
} }
const denyText = const denyText =
@ -1316,15 +1320,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
logVerboseMessage(`mattermost: pairing request sender=${senderId} created=${created}`); logVerboseMessage(`mattermost: pairing request sender=${senderId} created=${created}`);
if (created) { if (created) {
try { try {
await sendMessageMattermost( const replyText = core.channel.pairing.buildPairingReply({
`user:${senderId}`, channel: "mattermost",
core.channel.pairing.buildPairingReply({ idLine: `Your Mattermost user id: ${senderId}`,
channel: "mattermost", code,
idLine: `Your Mattermost user id: ${senderId}`, });
code, if (replyText) {
}), await sendMessageMattermost(`user:${senderId}`, replyText, {
{ accountId: account.accountId }, accountId: account.accountId,
); });
}
opts.statusSink?.({ lastOutboundAt: Date.now() }); opts.statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) { } catch (err) {
logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`); logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`);

View File

@ -171,11 +171,12 @@ async function authorizeSlashInvocation(params: {
...decision, ...decision,
denyResponse: { denyResponse: {
response_type: "ephemeral", response_type: "ephemeral",
text: core.channel.pairing.buildPairingReply({ text:
channel: "mattermost", core.channel.pairing.buildPairingReply({
idLine: `Your Mattermost user id: ${senderId}`, channel: "mattermost",
code, idLine: `Your Mattermost user id: ${senderId}`,
}), code,
}) ?? "",
}, },
}; };
} }

View File

@ -179,15 +179,16 @@ export async function handleNextcloudTalkInbound(params: {
}); });
if (created) { if (created) {
try { try {
await sendMessageNextcloudTalk( const replyText = core.channel.pairing.buildPairingReply({
roomToken, channel: CHANNEL_ID,
core.channel.pairing.buildPairingReply({ idLine: `Your Nextcloud user id: ${senderId}`,
channel: CHANNEL_ID, code,
idLine: `Your Nextcloud user id: ${senderId}`, });
code, if (replyText) {
}), await sendMessageNextcloudTalk(roomToken, replyText, {
{ accountId: account.accountId }, accountId: account.accountId,
); });
}
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) { } catch (err) {
runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`); runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);

View File

@ -422,15 +422,19 @@ async function processMessageWithPipeline(params: {
if (created) { if (created) {
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`); logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
try { try {
const replyText = core.channel.pairing.buildPairingReply({
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
});
if (!replyText) {
return;
}
await sendMessage( await sendMessage(
token, token,
{ {
chat_id: chatId, chat_id: chatId,
text: core.channel.pairing.buildPairingReply({ text: replyText,
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
}),
}, },
fetcher, fetcher,
); );

View File

@ -270,15 +270,14 @@ async function processMessage(
if (created) { if (created) {
logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`); logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
try { try {
await sendMessageZalouser( const replyText = core.channel.pairing.buildPairingReply({
chatId, channel: "zalouser",
core.channel.pairing.buildPairingReply({ idLine: `Your Zalo user id: ${senderId}`,
channel: "zalouser", code,
idLine: `Your Zalo user id: ${senderId}`, });
code, if (replyText) {
}), await sendMessageZalouser(chatId, replyText, { profile: account.profile });
{ profile: account.profile }, }
);
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) { } catch (err) {
logVerbose( logVerbose(

View File

@ -1444,6 +1444,8 @@ export const FIELD_HELP: Record<string, string> = {
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
"channels.telegram.dmPolicy": "channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', '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": "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.', '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": "channels.discord.streaming":
@ -1478,17 +1480,27 @@ export const FIELD_HELP: Record<string, string> = {
"Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.", "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.",
"channels.whatsapp.dmPolicy": "channels.whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', '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.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).",
"channels.whatsapp.debounceMs": "channels.whatsapp.debounceMs":
"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).",
"channels.signal.dmPolicy": "channels.signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', '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": "channels.imessage.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', '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": "channels.bluebubbles.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', '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": "channels.discord.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"].', '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": "channels.discord.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"] (legacy: channels.discord.dm.allowFrom).', 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"] (legacy: channels.discord.dm.allowFrom).',
"channels.discord.retry.attempts": "channels.discord.retry.attempts":
@ -1554,4 +1566,6 @@ export const FIELD_HELP: Record<string, string> = {
'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"] (legacy: channels.slack.dm.allowFrom).', 'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"] (legacy: channels.slack.dm.allowFrom).',
"channels.slack.dmPolicy": "channels.slack.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"].', '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).',
}; };

View File

@ -704,6 +704,7 @@ export const FIELD_LABELS: Record<string, string> = {
...IRC_FIELD_LABELS, ...IRC_FIELD_LABELS,
"channels.telegram.botToken": "Telegram Bot Token", "channels.telegram.botToken": "Telegram Bot Token",
"channels.telegram.dmPolicy": "Telegram DM Policy", "channels.telegram.dmPolicy": "Telegram DM Policy",
"channels.telegram.unpairedResponse": "Telegram Unpaired DM Response",
"channels.telegram.configWrites": "Telegram Config Writes", "channels.telegram.configWrites": "Telegram Config Writes",
"channels.telegram.commands.native": "Telegram Native Commands", "channels.telegram.commands.native": "Telegram Native Commands",
"channels.telegram.commands.nativeSkills": "Telegram Native Skill Commands", "channels.telegram.commands.nativeSkills": "Telegram Native Skill Commands",
@ -721,17 +722,22 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.telegram.threadBindings.spawnSubagentSessions": "Telegram Thread-Bound Subagent Spawn", "channels.telegram.threadBindings.spawnSubagentSessions": "Telegram Thread-Bound Subagent Spawn",
"channels.telegram.threadBindings.spawnAcpSessions": "Telegram Thread-Bound ACP Spawn", "channels.telegram.threadBindings.spawnAcpSessions": "Telegram Thread-Bound ACP Spawn",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.unpairedResponse": "WhatsApp Unpaired DM Response",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
"channels.whatsapp.configWrites": "WhatsApp Config Writes", "channels.whatsapp.configWrites": "WhatsApp Config Writes",
"channels.signal.dmPolicy": "Signal DM Policy", "channels.signal.dmPolicy": "Signal DM Policy",
"channels.signal.unpairedResponse": "Signal Unpaired DM Response",
"channels.signal.configWrites": "Signal Config Writes", "channels.signal.configWrites": "Signal Config Writes",
"channels.imessage.dmPolicy": "iMessage DM Policy", "channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.imessage.unpairedResponse": "iMessage Unpaired DM Response",
"channels.imessage.configWrites": "iMessage Config Writes", "channels.imessage.configWrites": "iMessage Config Writes",
"channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy",
"channels.bluebubbles.unpairedResponse": "BlueBubbles Unpaired DM Response",
"channels.msteams.configWrites": "MS Teams Config Writes", "channels.msteams.configWrites": "MS Teams Config Writes",
"channels.irc.configWrites": "IRC Config Writes", "channels.irc.configWrites": "IRC Config Writes",
"channels.discord.dmPolicy": "Discord DM Policy", "channels.discord.dmPolicy": "Discord DM Policy",
"channels.discord.unpairedResponse": "Discord Unpaired DM Response",
"channels.discord.dm.policy": "Discord DM Policy", "channels.discord.dm.policy": "Discord DM Policy",
"channels.discord.configWrites": "Discord Config Writes", "channels.discord.configWrites": "Discord Config Writes",
"channels.discord.proxy": "Discord Proxy URL", "channels.discord.proxy": "Discord Proxy URL",
@ -779,6 +785,7 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.discord.activityUrl": "Discord Presence Activity URL", "channels.discord.activityUrl": "Discord Presence Activity URL",
"channels.slack.dm.policy": "Slack DM Policy", "channels.slack.dm.policy": "Slack DM Policy",
"channels.slack.dmPolicy": "Slack DM Policy", "channels.slack.dmPolicy": "Slack DM Policy",
"channels.slack.unpairedResponse": "Slack Unpaired DM Response",
"channels.slack.configWrites": "Slack Config Writes", "channels.slack.configWrites": "Slack Config Writes",
"channels.slack.commands.native": "Slack Native Commands", "channels.slack.commands.native": "Slack Native Commands",
"channels.slack.commands.nativeSkills": "Slack Native Skill Commands", "channels.slack.commands.nativeSkills": "Slack Native Skill Commands",

View File

@ -7,6 +7,7 @@ export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-ch
export type ReplyToMode = "off" | "first" | "all"; export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist"; export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type UnpairedResponseMode = "silent" | "code-only" | "branded";
export type OutboundRetryConfig = { export type OutboundRetryConfig = {
/** Max retry attempts for outbound requests (default: 3). */ /** Max retry attempts for outbound requests (default: 3). */

View File

@ -3,6 +3,7 @@ import type {
DmPolicy, DmPolicy,
GroupPolicy, GroupPolicy,
MarkdownConfig, MarkdownConfig,
UnpairedResponseMode,
} from "./types.base.js"; } from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js"; import type { DmConfig } from "./types.messages.js";
@ -20,6 +21,8 @@ export type CommonChannelMessagingConfig = {
enabled?: boolean; enabled?: boolean;
/** Direct message access policy (default: pairing). */ /** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy; dmPolicy?: DmPolicy;
/** How OpenClaw responds to unknown DM senders in pairing mode. */
unpairedResponse?: UnpairedResponseMode;
/** Optional allowlist for inbound DM senders. */ /** Optional allowlist for inbound DM senders. */
allowFrom?: Array<string | number>; allowFrom?: Array<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */ /** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */

View File

@ -7,6 +7,7 @@ import type {
MarkdownConfig, MarkdownConfig,
OutboundRetryConfig, OutboundRetryConfig,
ReplyToMode, ReplyToMode,
UnpairedResponseMode,
} from "./types.base.js"; } from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
@ -219,6 +220,8 @@ export type DiscordAccountConfig = {
configWrites?: boolean; configWrites?: boolean;
/** If false, do not start this Discord account. Default: true. */ /** If false, do not start this Discord account. Default: true. */
enabled?: boolean; enabled?: boolean;
/** How OpenClaw responds to unknown DM senders in pairing mode. */
unpairedResponse?: UnpairedResponseMode;
token?: SecretInput; token?: SecretInput;
/** HTTP(S) proxy URL for Discord gateway WebSocket connections. */ /** HTTP(S) proxy URL for Discord gateway WebSocket connections. */
proxy?: string; proxy?: string;

View File

@ -3,6 +3,7 @@ import type {
DmPolicy, DmPolicy,
GroupPolicy, GroupPolicy,
MarkdownConfig, MarkdownConfig,
UnpairedResponseMode,
} from "./types.base.js"; } from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js"; import type { DmConfig } from "./types.messages.js";
@ -31,6 +32,8 @@ export type IMessageAccountConfig = {
region?: string; region?: string;
/** Direct message access policy (default: pairing). */ /** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy; dmPolicy?: DmPolicy;
/** How OpenClaw responds to unknown DM senders in pairing mode. */
unpairedResponse?: UnpairedResponseMode;
/** Optional allowlist for inbound handles or chat_id targets. */ /** Optional allowlist for inbound handles or chat_id targets. */
allowFrom?: Array<string | number>; allowFrom?: Array<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */ /** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */

View File

@ -4,6 +4,7 @@ import type {
GroupPolicy, GroupPolicy,
MarkdownConfig, MarkdownConfig,
ReplyToMode, ReplyToMode,
UnpairedResponseMode,
} from "./types.base.js"; } from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
@ -98,6 +99,8 @@ export type SlackAccountConfig = {
configWrites?: boolean; configWrites?: boolean;
/** If false, do not start this Slack account. Default: true. */ /** If false, do not start this Slack account. Default: true. */
enabled?: boolean; enabled?: boolean;
/** How OpenClaw responds to unknown DM senders in pairing mode. */
unpairedResponse?: UnpairedResponseMode;
botToken?: string; botToken?: string;
appToken?: string; appToken?: string;
userToken?: string; userToken?: string;

View File

@ -7,6 +7,7 @@ import type {
OutboundRetryConfig, OutboundRetryConfig,
ReplyToMode, ReplyToMode,
SessionThreadBindingsConfig, SessionThreadBindingsConfig,
UnpairedResponseMode,
} from "./types.base.js"; } from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
@ -74,6 +75,8 @@ export type TelegramAccountConfig = {
* - "disabled": ignore all inbound DMs * - "disabled": ignore all inbound DMs
*/ */
dmPolicy?: DmPolicy; dmPolicy?: DmPolicy;
/** How OpenClaw responds to unknown DM senders in pairing mode. */
unpairedResponse?: UnpairedResponseMode;
/** If false, do not start this Telegram account. Default: true. */ /** If false, do not start this Telegram account. Default: true. */
enabled?: boolean; enabled?: boolean;
botToken?: string; botToken?: string;

View File

@ -3,6 +3,7 @@ import type {
DmPolicy, DmPolicy,
GroupPolicy, GroupPolicy,
MarkdownConfig, MarkdownConfig,
UnpairedResponseMode,
} from "./types.base.js"; } from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js"; import type { DmConfig } from "./types.messages.js";
@ -40,6 +41,8 @@ type WhatsAppSharedConfig = {
enabled?: boolean; enabled?: boolean;
/** Direct message access policy (default: pairing). */ /** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy; dmPolicy?: DmPolicy;
/** How OpenClaw responds to unknown DM senders in pairing mode. */
unpairedResponse?: UnpairedResponseMode;
/** Same-phone setup (bot uses your personal WhatsApp number). */ /** Same-phone setup (bot uses your personal WhatsApp number). */
selfChatMode?: boolean; selfChatMode?: boolean;
/** Optional allowlist for WhatsApp direct chats (E.164). */ /** Optional allowlist for WhatsApp direct chats (E.164). */

View File

@ -315,6 +315,8 @@ export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
export const UnpairedResponseSchema = z.enum(["silent", "code-only", "branded"]);
export const BlockStreamingCoalesceSchema = z export const BlockStreamingCoalesceSchema = z
.object({ .object({
minChars: z.number().int().positive().optional(), minChars: z.number().int().positive().optional(),

View File

@ -30,6 +30,7 @@ import {
ReplyToModeSchema, ReplyToModeSchema,
RetryConfigSchema, RetryConfigSchema,
TtsConfigSchema, TtsConfigSchema,
UnpairedResponseSchema,
requireAllowlistAllowFrom, requireAllowlistAllowFrom,
requireOpenAllowFrom, requireOpenAllowFrom,
} from "./zod-schema.core.js"; } from "./zod-schema.core.js";
@ -159,6 +160,7 @@ export const TelegramAccountSchemaBase = z
customCommands: z.array(TelegramCustomCommandSchema).optional(), customCommands: z.array(TelegramCustomCommandSchema).optional(),
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
unpairedResponse: UnpairedResponseSchema.optional().default("branded"),
botToken: SecretInputSchema.optional().register(sensitive), botToken: SecretInputSchema.optional().register(sensitive),
tokenFile: z.string().optional(), tokenFile: z.string().optional(),
replyToMode: ReplyToModeSchema.optional(), replyToMode: ReplyToModeSchema.optional(),
@ -955,6 +957,7 @@ export const SignalAccountSchemaBase = z
ignoreStories: z.boolean().optional(), ignoreStories: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(), sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
unpairedResponse: UnpairedResponseSchema.optional().default("branded"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(), defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
@ -1075,6 +1078,7 @@ export const IrcAccountSchemaBase = z
nickserv: IrcNickServSchema.optional(), nickserv: IrcNickServSchema.optional(),
channels: z.array(z.string()).optional(), channels: z.array(z.string()).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
unpairedResponse: UnpairedResponseSchema.optional().default("branded"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(), defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).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(), service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
region: z.string().optional(), region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
unpairedResponse: UnpairedResponseSchema.optional().default("branded"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(), defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
@ -1313,6 +1318,7 @@ export const BlueBubblesAccountSchemaBase = z
password: SecretInputSchema.optional().register(sensitive), password: SecretInputSchema.optional().register(sensitive),
webhookPath: z.string().optional(), webhookPath: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
unpairedResponse: UnpairedResponseSchema.optional().default("branded"),
allowFrom: z.array(BlueBubblesAllowFromEntry).optional(), allowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
groupAllowFrom: z.array(BlueBubblesAllowFromEntry).optional(), groupAllowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"), groupPolicy: GroupPolicySchema.optional().default("allowlist"),
@ -1424,6 +1430,7 @@ export const MSTeamsConfigSchema = z
.strict() .strict()
.optional(), .optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
unpairedResponse: UnpairedResponseSchema.optional().default("branded"),
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
defaultTo: z.string().optional(), defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(),

View File

@ -7,6 +7,7 @@ import {
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema, MarkdownConfigSchema,
UnpairedResponseSchema,
} from "./zod-schema.core.js"; } from "./zod-schema.core.js";
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
@ -40,6 +41,7 @@ const WhatsAppSharedSchema = z.object({
messagePrefix: z.string().optional(), messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(), responsePrefix: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
unpairedResponse: UnpairedResponseSchema.optional().default("branded"),
selfChatMode: z.boolean().optional(), selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
defaultTo: z.string().optional(), defaultTo: z.string().optional(),

View File

@ -519,6 +519,7 @@ async function ensureDmComponentAuthorized(params: {
} }
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const unpairedResponse = ctx.discordConfig?.unpairedResponse ?? "branded";
const { code, created } = await upsertChannelPairingRequest({ const { code, created } = await upsertChannelPairingRequest({
channel: "discord", channel: "discord",
id: user.id, id: user.id,
@ -529,14 +530,19 @@ async function ensureDmComponentAuthorized(params: {
}, },
}); });
try { 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({ await interaction.reply({
content: created content: replyText,
? buildPairingReply({
channel: "discord",
idLine: `Your Discord user id: ${user.id}`,
code,
})
: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts, ...replyOpts,
}); });
} catch { } catch {

View File

@ -209,6 +209,7 @@ export async function preflightDiscordMessage(
} }
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing"; const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
const unpairedResponse = params.discordConfig?.unpairedResponse ?? "branded";
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID; const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig); const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
@ -251,19 +252,19 @@ export async function preflightDiscordMessage(
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`, `discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`,
); );
try { try {
await sendMessageDiscord( const replyText = buildPairingReply({
`user:${author.id}`, channel: "discord",
buildPairingReply({ idLine: `Your Discord user id: ${author.id}`,
channel: "discord", code,
idLine: `Your Discord user id: ${author.id}`, mode: unpairedResponse,
code, });
}), if (replyText) {
{ await sendMessageDiscord(`user:${author.id}`, replyText, {
token: params.token, token: params.token,
rest: params.client.rest, rest: params.client.rest,
accountId: params.accountId, accountId: params.accountId,
}, });
); }
} catch (err) { } catch (err) {
logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`); logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`);
} }

View File

@ -1369,6 +1369,7 @@ async function dispatchDiscordCommandInteraction(params: {
await respond("Discord DMs are disabled."); await respond("Discord DMs are disabled.");
return; return;
} }
const unpairedResponse = discordConfig?.unpairedResponse ?? "branded";
const dmAccess = await resolveDiscordDmCommandAccess({ const dmAccess = await resolveDiscordDmCommandAccess({
accountId, accountId,
dmPolicy, dmPolicy,
@ -1392,14 +1393,15 @@ async function dispatchDiscordCommandInteraction(params: {
name: sender.name, name: sender.name,
}, },
onPairingCreated: async (code) => { onPairingCreated: async (code) => {
await respond( const replyText = buildPairingReply({
buildPairingReply({ channel: "discord",
channel: "discord", idLine: `Your Discord user id: ${user.id}`,
idLine: `Your Discord user id: ${user.id}`, code,
code, mode: unpairedResponse,
}), });
{ ephemeral: true }, if (replyText) {
); await respond(replyText, { ephemeral: true });
}
}, },
onUnauthorized: async () => { onUnauthorized: async () => {
await respond("You are not authorized to use this command.", { ephemeral: true }); await respond("You are not authorized to use this command.", { ephemeral: true });

View File

@ -300,20 +300,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
if (created) { if (created) {
logVerbose(`imessage pairing request sender=${decision.senderId}`); logVerbose(`imessage pairing request sender=${decision.senderId}`);
try { try {
await sendMessageIMessage( const replyText = buildPairingReply({
sender, channel: "imessage",
buildPairingReply({ idLine: `Your iMessage sender id: ${decision.senderId}`,
channel: "imessage", code,
idLine: `Your iMessage sender id: ${decision.senderId}`, mode: accountInfo.config.unpairedResponse,
code, });
}), if (!replyText) {
{ return;
client, }
maxBytes: mediaMaxBytes, await sendMessageIMessage(sender, replyText, {
accountId: accountInfo.accountId, client,
...(chatId ? { chatId } : {}), maxBytes: mediaMaxBytes,
}, accountId: accountInfo.accountId,
); ...(chatId ? { chatId } : {}),
});
} catch (err) { } catch (err) {
logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`); logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`);
} }

View File

@ -249,7 +249,11 @@ async function sendLinePairingReply(params: {
channel: "line", channel: "line",
idLine: `Your ${idLabel}: ${senderId}`, idLine: `Your ${idLabel}: ${senderId}`,
code, code,
mode: context.account.config.unpairedResponse,
}); });
if (!text) {
return;
}
try { try {
if (replyToken) { if (replyToken) {
await replyMessageLine(replyToken, [{ type: "text", text }], { await replyMessageLine(replyToken, [{ type: "text", text }], {

View File

@ -8,6 +8,7 @@ import type {
LocationMessage, LocationMessage,
} from "@line/bot-sdk"; } from "@line/bot-sdk";
import type { BaseProbeResult } from "../channels/plugins/types.js"; import type { BaseProbeResult } from "../channels/plugins/types.js";
import type { UnpairedResponseMode } from "../config/types.js";
export type LineTokenSource = "config" | "env" | "file" | "none"; export type LineTokenSource = "config" | "env" | "file" | "none";
@ -21,6 +22,7 @@ interface LineAccountBaseConfig {
allowFrom?: Array<string | number>; allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>; groupAllowFrom?: Array<string | number>;
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
unpairedResponse?: UnpairedResponseMode;
groupPolicy?: "open" | "allowlist" | "disabled"; groupPolicy?: "open" | "allowlist" | "disabled";
/** Outbound response prefix override for this account. */ /** Outbound response prefix override for this account. */
responsePrefix?: string; responsePrefix?: string;

View File

@ -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();
});
});

View File

@ -1,3 +1,4 @@
import type { UnpairedResponseMode } from "../config/types.base.js";
import { buildPairingReply } from "./pairing-messages.js"; import { buildPairingReply } from "./pairing-messages.js";
type PairingMeta = Record<string, string | undefined>; type PairingMeta = Record<string, string | undefined>;
@ -12,7 +13,8 @@ export type PairingChallengeParams = {
meta?: PairingMeta; meta?: PairingMeta;
}) => Promise<{ code: string; created: boolean }>; }) => Promise<{ code: string; created: boolean }>;
sendPairingReply: (text: string) => Promise<void>; sendPairingReply: (text: string) => Promise<void>;
buildReplyText?: (params: { code: string; senderIdLine: string }) => string; responseMode?: UnpairedResponseMode;
buildReplyText?: (params: { code: string; senderIdLine: string }) => string | null;
onCreated?: (params: { code: string }) => void; onCreated?: (params: { code: string }) => void;
onReplyError?: (err: unknown) => void; onReplyError?: (err: unknown) => void;
}; };
@ -38,7 +40,11 @@ export async function issuePairingChallenge(
channel: params.channel, channel: params.channel,
idLine: params.senderIdLine, idLine: params.senderIdLine,
code, code,
mode: params.responseMode,
}); });
if (replyText == null) {
return { created: true, code };
}
try { try {
await params.sendPairingReply(replyText); await params.sendPairingReply(replyText);
} catch (err) { } catch (err) {

View File

@ -50,6 +50,7 @@ describe("buildPairingReply", () => {
for (const testCase of cases) { for (const testCase of cases) {
it(`formats pairing reply for ${testCase.channel}`, () => { it(`formats pairing reply for ${testCase.channel}`, () => {
const text = buildPairingReply(testCase); const text = buildPairingReply(testCase);
expect(text).not.toBeNull();
expect(text).toContain(testCase.idLine); expect(text).toContain(testCase.idLine);
expect(text).toContain(`Pairing code: ${testCase.code}`); expect(text).toContain(`Pairing code: ${testCase.code}`);
// CLI commands should respect OPENCLAW_PROFILE when set (most tests run with isolated profile) // CLI commands should respect OPENCLAW_PROFILE when set (most tests run with isolated profile)
@ -59,4 +60,29 @@ describe("buildPairingReply", () => {
expect(text).toMatch(commandRe); 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();
});
}); });

View File

@ -1,20 +1,36 @@
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import type { UnpairedResponseMode } from "../config/types.base.js";
import type { PairingChannel } from "./pairing-store.js"; import type { PairingChannel } from "./pairing-store.js";
export function buildPairingReply(params: { export function buildPairingReply(params: {
channel: PairingChannel; channel: PairingChannel;
idLine: string; idLine: string;
code: string; code: string;
}): string { mode?: UnpairedResponseMode;
const { channel, idLine, code } = params; }): string | null {
return [ const { channel, idLine, code, mode = "branded" } = params;
"OpenClaw: access not configured.", if (mode === "silent") {
"", return null;
idLine, }
"", const lines =
`Pairing code: ${code}`, mode === "code-only"
"", ? [
"Ask the bot owner to approve with:", idLine,
formatCliCommand(`openclaw pairing approve ${channel} ${code}`), "",
].join("\n"); `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");
} }

View File

@ -430,6 +430,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
groupHistories, groupHistories,
textLimit, textLimit,
dmPolicy, dmPolicy,
unpairedResponse: accountInfo.config.unpairedResponse,
allowFrom, allowFrom,
groupAllowFrom, groupAllowFrom,
groupPolicy, groupPolicy,

View File

@ -8,6 +8,7 @@ import { isSignalSenderAllowed, type SignalSender } from "../identity.js";
type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
type SignalGroupPolicy = "open" | "allowlist" | "disabled"; type SignalGroupPolicy = "open" | "allowlist" | "disabled";
type SignalUnpairedResponseMode = "silent" | "code-only" | "branded";
export async function resolveSignalAccessState(params: { export async function resolveSignalAccessState(params: {
accountId: string; accountId: string;
@ -49,6 +50,7 @@ export async function handleSignalDirectMessageAccess(params: {
senderDisplay: string; senderDisplay: string;
senderName?: string; senderName?: string;
accountId: string; accountId: string;
unpairedResponse?: SignalUnpairedResponseMode;
sendPairingReply: (text: string) => Promise<void>; sendPairingReply: (text: string) => Promise<void>;
log: (message: string) => void; log: (message: string) => void;
}): Promise<boolean> { }): Promise<boolean> {
@ -66,6 +68,7 @@ export async function handleSignalDirectMessageAccess(params: {
channel: "signal", channel: "signal",
senderId: params.senderId, senderId: params.senderId,
senderIdLine: params.senderIdLine, senderIdLine: params.senderIdLine,
responseMode: params.unpairedResponse,
meta: { name: params.senderName }, meta: { name: params.senderName },
upsertPairingRequest: async ({ id, meta }) => upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({ await upsertChannelPairingRequest({

View File

@ -530,6 +530,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
senderDisplay, senderDisplay,
senderName: envelope.sourceName ?? undefined, senderName: envelope.sourceName ?? undefined,
accountId: deps.accountId, accountId: deps.accountId,
unpairedResponse: deps.unpairedResponse,
sendPairingReply: async (text) => { sendPairingReply: async (text) => {
await sendMessageSignal(`signal:${senderRecipient}`, text, { await sendMessageSignal(`signal:${senderRecipient}`, text, {
baseUrl: deps.baseUrl, baseUrl: deps.baseUrl,

View File

@ -1,7 +1,12 @@
import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.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 { RuntimeEnv } from "../../runtime.js";
import type { SignalSender } from "../identity.js"; import type { SignalSender } from "../identity.js";
@ -79,6 +84,7 @@ export type SignalEventHandlerDeps = {
groupHistories: Map<string, HistoryEntry[]>; groupHistories: Map<string, HistoryEntry[]>;
textLimit: number; textLimit: number;
dmPolicy: DmPolicy; dmPolicy: DmPolicy;
unpairedResponse?: UnpairedResponseMode;
allowFrom: string[]; allowFrom: string[];
groupAllowFrom: string[]; groupAllowFrom: string[];
groupPolicy: GroupPolicy; groupPolicy: GroupPolicy;

View File

@ -3,7 +3,7 @@ import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import type { OpenClawConfig, SlackReactionNotificationMode } from "../../config/config.js"; import type { OpenClawConfig, SlackReactionNotificationMode } from "../../config/config.js";
import { resolveSessionKey, type SessionScope } from "../../config/sessions.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 { logVerbose } from "../../globals.js";
import { createDedupeCache } from "../../infra/dedupe.js"; import { createDedupeCache } from "../../infra/dedupe.js";
import { getChildLogger } from "../../logging.js"; import { getChildLogger } from "../../logging.js";
@ -36,6 +36,7 @@ export type SlackMonitorContext = {
dmEnabled: boolean; dmEnabled: boolean;
dmPolicy: DmPolicy; dmPolicy: DmPolicy;
unpairedResponse: UnpairedResponseMode;
allowFrom: string[]; allowFrom: string[];
allowNameMatching: boolean; allowNameMatching: boolean;
groupDmEnabled: boolean; groupDmEnabled: boolean;
@ -101,6 +102,7 @@ export function createSlackMonitorContext(params: {
dmEnabled: boolean; dmEnabled: boolean;
dmPolicy: DmPolicy; dmPolicy: DmPolicy;
unpairedResponse?: UnpairedResponseMode;
allowFrom: Array<string | number> | undefined; allowFrom: Array<string | number> | undefined;
allowNameMatching: boolean; allowNameMatching: boolean;
groupDmEnabled: boolean; groupDmEnabled: boolean;
@ -399,6 +401,7 @@ export function createSlackMonitorContext(params: {
mainKey: params.mainKey, mainKey: params.mainKey,
dmEnabled: params.dmEnabled, dmEnabled: params.dmEnabled,
dmPolicy: params.dmPolicy, dmPolicy: params.dmPolicy,
unpairedResponse: params.unpairedResponse ?? "branded",
allowFrom, allowFrom,
allowNameMatching: params.allowNameMatching, allowNameMatching: params.allowNameMatching,
groupDmEnabled: params.groupDmEnabled, groupDmEnabled: params.groupDmEnabled,

View File

@ -41,6 +41,7 @@ export async function authorizeSlackDirectMessage(params: {
channel: "slack", channel: "slack",
senderId: params.senderId, senderId: params.senderId,
senderIdLine: `Your Slack user id: ${params.senderId}`, senderIdLine: `Your Slack user id: ${params.senderId}`,
responseMode: params.ctx.unpairedResponse,
meta: { name: senderName }, meta: { name: senderName },
upsertPairingRequest: async ({ id, meta }) => upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({ await upsertChannelPairingRequest({

View File

@ -247,6 +247,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
mainKey, mainKey,
dmEnabled, dmEnabled,
dmPolicy, dmPolicy,
unpairedResponse: slackCfg.unpairedResponse,
allowFrom, allowFrom,
allowNameMatching: isDangerousNameMatchingEnabled(slackCfg), allowNameMatching: isDangerousNameMatchingEnabled(slackCfg),
groupDmEnabled, groupDmEnabled,

View File

@ -1447,6 +1447,7 @@ export const registerTelegramHandlers = ({
const dmAuthorized = await enforceTelegramDmAccess({ const dmAuthorized = await enforceTelegramDmAccess({
isGroup: event.isGroup, isGroup: event.isGroup,
dmPolicy, dmPolicy,
unpairedResponse: telegramCfg.unpairedResponse,
msg: event.msg, msg: event.msg,
chatId: event.chatId, chatId: event.chatId,
effectiveDmAllow, effectiveDmAllow,

View File

@ -36,6 +36,7 @@ import type {
TelegramDirectConfig, TelegramDirectConfig,
TelegramGroupConfig, TelegramGroupConfig,
TelegramTopicConfig, TelegramTopicConfig,
UnpairedResponseMode,
} from "../config/types.js"; } from "../config/types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js"; import { recordChannelActivity } from "../infra/channel-activity.js";
@ -120,6 +121,7 @@ export type BuildTelegramMessageContextParams = {
historyLimit: number; historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>; groupHistories: Map<string, HistoryEntry[]>;
dmPolicy: DmPolicy; dmPolicy: DmPolicy;
unpairedResponse?: UnpairedResponseMode;
allowFrom?: Array<string | number>; allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>; groupAllowFrom?: Array<string | number>;
ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all"; ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all";
@ -163,6 +165,7 @@ export const buildTelegramMessageContext = async ({
historyLimit, historyLimit,
groupHistories, groupHistories,
dmPolicy, dmPolicy,
unpairedResponse,
allowFrom, allowFrom,
groupAllowFrom, groupAllowFrom,
ackReactionScope, ackReactionScope,
@ -301,6 +304,7 @@ export const buildTelegramMessageContext = async ({
!(await enforceTelegramDmAccess({ !(await enforceTelegramDmAccess({
isGroup, isGroup,
dmPolicy: effectiveDmPolicy, dmPolicy: effectiveDmPolicy,
unpairedResponse,
msg, msg,
chatId, chatId,
effectiveDmAllow, effectiveDmAllow,

View File

@ -66,6 +66,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
historyLimit, historyLimit,
groupHistories, groupHistories,
dmPolicy, dmPolicy,
unpairedResponse: telegramCfg.unpairedResponse,
allowFrom, allowFrom,
groupAllowFrom, groupAllowFrom,
ackReactionScope, ackReactionScope,

View File

@ -1,6 +1,6 @@
import type { Message } from "@grammyjs/types"; import type { Message } from "@grammyjs/types";
import type { Bot } from "grammy"; 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 { logVerbose } from "../globals.js";
import { buildPairingReply } from "../pairing/pairing-messages.js"; import { buildPairingReply } from "../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; import { upsertChannelPairingRequest } from "../pairing/pairing-store.js";
@ -34,6 +34,7 @@ function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSe
export async function enforceTelegramDmAccess(params: { export async function enforceTelegramDmAccess(params: {
isGroup: boolean; isGroup: boolean;
dmPolicy: DmPolicy; dmPolicy: DmPolicy;
unpairedResponse?: UnpairedResponseMode;
msg: Message; msg: Message;
chatId: number; chatId: number;
effectiveDmAllow: NormalizedAllowFrom; effectiveDmAllow: NormalizedAllowFrom;
@ -93,18 +94,18 @@ export async function enforceTelegramDmAccess(params: {
}, },
"telegram pairing request", "telegram pairing request",
); );
await withTelegramApiErrorLogging({ const replyText = buildPairingReply({
operation: "sendMessage", channel: "telegram",
fn: () => idLine: `Your Telegram user id: ${telegramUserId}`,
bot.api.sendMessage( code,
chatId, mode: params.unpairedResponse,
buildPairingReply({
channel: "telegram",
idLine: `Your Telegram user id: ${telegramUserId}`,
code,
}),
),
}); });
if (replyText) {
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, replyText),
});
}
} }
} catch (err) { } catch (err) {
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);

View File

@ -3,7 +3,12 @@ import path from "node:path";
import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveOAuthDir } from "../config/paths.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 { resolveAccountEntry } from "../routing/account-lookup.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
@ -22,6 +27,7 @@ export type ResolvedWhatsAppAccount = {
groupAllowFrom?: string[]; groupAllowFrom?: string[];
groupPolicy?: GroupPolicy; groupPolicy?: GroupPolicy;
dmPolicy?: DmPolicy; dmPolicy?: DmPolicy;
unpairedResponse?: UnpairedResponseMode;
textChunkLimit?: number; textChunkLimit?: number;
chunkMode?: "length" | "newline"; chunkMode?: "length" | "newline";
mediaMaxMb?: number; mediaMaxMb?: number;
@ -136,6 +142,7 @@ export function resolveWhatsAppAccount(params: {
isLegacyAuthDir: isLegacy, isLegacyAuthDir: isLegacy,
selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode, selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode,
dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy, dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy,
unpairedResponse: accountCfg?.unpairedResponse ?? rootCfg?.unpairedResponse,
allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom, allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom,
groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom, groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom,
groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy, groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy,

View File

@ -60,6 +60,7 @@ export async function checkInboundAccessControl(params: {
accountId: params.accountId, accountId: params.accountId,
}); });
const dmPolicy = account.dmPolicy ?? "pairing"; const dmPolicy = account.dmPolicy ?? "pairing";
const unpairedResponse = account.unpairedResponse ?? "branded";
const configuredAllowFrom = account.allowFrom ?? []; const configuredAllowFrom = account.allowFrom ?? [];
const storeAllowFrom = await readStoreAllowFromForDmPolicy({ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "whatsapp", provider: "whatsapp",
@ -182,13 +183,17 @@ export async function checkInboundAccessControl(params: {
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
); );
try { try {
await params.sock.sendMessage(params.remoteJid, { const replyText = buildPairingReply({
text: buildPairingReply({ channel: "whatsapp",
channel: "whatsapp", idLine: `Your WhatsApp phone number: ${candidate}`,
idLine: `Your WhatsApp phone number: ${candidate}`, code,
code, mode: unpairedResponse,
}),
}); });
if (replyText) {
await params.sock.sendMessage(params.remoteJid, {
text: replyText,
});
}
} catch (err) { } catch (err) {
logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
} }