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) {
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(

View File

@ -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)}`,

View File

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

View File

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

View File

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

View File

@ -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,
}) ?? "",
},
};
}

View File

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

View File

@ -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,
);

View File

@ -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(

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).",
"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<string, string> = {
"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<string, string> = {
'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).',
};

View File

@ -704,6 +704,7 @@ export const FIELD_LABELS: Record<string, string> = {
...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<string, string> = {
"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<string, string> = {
"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",

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 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). */

View File

@ -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<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */

View File

@ -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;

View File

@ -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<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */

View File

@ -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;

View File

@ -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;

View File

@ -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). */

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 UnpairedResponseSchema = z.enum(["silent", "code-only", "branded"]);
export const BlockStreamingCoalesceSchema = z
.object({
minChars: z.number().int().positive().optional(),

View File

@ -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(),

View File

@ -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(),

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -249,7 +249,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 }], {

View File

@ -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<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
unpairedResponse?: UnpairedResponseMode;
groupPolicy?: "open" | "allowlist" | "disabled";
/** Outbound response prefix override for this account. */
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";
type PairingMeta = Record<string, string | undefined>;
@ -12,7 +13,8 @@ export type PairingChallengeParams = {
meta?: PairingMeta;
}) => Promise<{ code: string; created: boolean }>;
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;
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) {

View File

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

View File

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

View File

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

View File

@ -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<void>;
log: (message: string) => void;
}): Promise<boolean> {
@ -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({

View File

@ -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,

View File

@ -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<string, HistoryEntry[]>;
textLimit: number;
dmPolicy: DmPolicy;
unpairedResponse?: UnpairedResponseMode;
allowFrom: string[];
groupAllowFrom: string[];
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 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<string | number> | 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,

View File

@ -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({

View File

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

View File

@ -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,

View File

@ -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<string, HistoryEntry[]>;
dmPolicy: DmPolicy;
unpairedResponse?: UnpairedResponseMode;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
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,

View File

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

View File

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

View File

@ -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,

View File

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