From 2bcd56cfacea3166a6a8d0985c662427b2b6b7e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 19:36:02 +0000 Subject: [PATCH] refactor: unify DM pairing challenge flows --- .../bluebubbles/src/monitor-processing.ts | 38 ++++---- extensions/feishu/src/bot.ts | 29 +++--- extensions/googlechat/src/monitor-access.ts | 29 +++--- extensions/irc/src/inbound.ts | 26 +++--- extensions/nextcloud-talk/src/inbound.ts | 29 +++--- extensions/zalo/src/monitor.ts | 30 +++---- extensions/zalouser/src/monitor.ts | 34 ++++--- src/discord/monitor/agent-components.ts | 43 +++++---- src/discord/monitor/dm-command-decision.ts | 19 ++-- src/imessage/monitor/monitor-provider.ts | 50 +++++------ src/line/bot-handlers.ts | 67 +++++++------- src/pairing/pairing-challenge.test.ts | 90 +++++++++++++++++++ src/plugin-sdk/bluebubbles.ts | 1 + src/plugin-sdk/feishu.ts | 1 + src/plugin-sdk/googlechat.ts | 1 + src/plugin-sdk/irc.ts | 1 + src/plugin-sdk/nextcloud-talk.ts | 1 + src/plugin-sdk/zalo.ts | 1 + src/plugin-sdk/zalouser.ts | 1 + src/telegram/dm-access.ts | 64 ++++++------- src/web/inbound/access-control.ts | 42 ++++----- 21 files changed, 356 insertions(+), 241 deletions(-) create mode 100644 src/pairing/pairing-challenge.test.ts diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index a1c316429e4..a80f22df853 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -4,6 +4,7 @@ import { createScopedPairingAccess, createReplyPrefixOptions, evictOldHistoryKeys, + issuePairingChallenge, logAckFailure, logInboundDrop, logTypingFailure, @@ -595,25 +596,24 @@ export async function processMessage( } if (accessDecision.decision === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: message.senderId, + await issuePairingChallenge({ + channel: "bluebubbles", + senderId: message.senderId, + senderIdLine: `Your BlueBubbles sender id: ${message.senderId}`, meta: { name: message.senderName }, - }); - runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=${created}`); - 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 }, - ); + upsertPairingRequest: pairing.upsertPairingRequest, + onCreated: () => { + runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=true`); + logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`); + }, + sendPairingReply: async (text) => { + await sendMessageBlueBubbles(message.senderId, text, { + cfg: config, + accountId: account.accountId, + }); statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { + }, + onReplyError: (err) => { logVerbose( core, runtime, @@ -622,8 +622,8 @@ export async function processMessage( runtime.error?.( `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`, ); - } - } + }, + }); return; } diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 3540036c8a6..13a130b3d79 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -6,6 +6,7 @@ import { createScopedPairingAccess, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, + issuePairingChallenge, normalizeAgentId, recordPendingHistoryEntryIfEnabled, resolveOpenProviderRuntimeGroupPolicy, @@ -1101,29 +1102,29 @@ export async function handleFeishuMessage(params: { if (isDirect && dmPolicy !== "open" && !dmAllowed) { if (dmPolicy === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: ctx.senderOpenId, + await issuePairingChallenge({ + channel: "feishu", + senderId: ctx.senderOpenId, + senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`, meta: { name: ctx.senderName }, - }); - if (created) { - log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`); - try { + upsertPairingRequest: pairing.upsertPairingRequest, + onCreated: () => { + log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`); + }, + sendPairingReply: async (text) => { await sendMessageFeishu({ cfg, to: `chat:${ctx.chatId}`, - text: core.channel.pairing.buildPairingReply({ - channel: "feishu", - idLine: `Your Feishu user id: ${ctx.senderOpenId}`, - code, - }), + text, accountId: account.accountId, }); - } catch (err) { + }, + onReplyError: (err) => { log( `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`, ); - } - } + }, + }); } else { log( `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`, diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index daecea59f8a..bb5b6de9211 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -1,6 +1,7 @@ import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + issuePairingChallenge, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -311,27 +312,27 @@ export async function applyGoogleChatInboundAccessPolicy(params: { if (access.decision !== "allow") { if (access.decision === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: senderId, + await issuePairingChallenge({ + channel: "googlechat", + senderId, + senderIdLine: `Your Google Chat user id: ${senderId}`, meta: { name: senderName || undefined, email: senderEmail }, - }); - if (created) { - logVerbose(`googlechat pairing request sender=${senderId}`); - try { + upsertPairingRequest: pairing.upsertPairingRequest, + onCreated: () => { + logVerbose(`googlechat pairing request sender=${senderId}`); + }, + sendPairingReply: async (text) => { await sendGoogleChatMessage({ account, space: spaceId, - text: core.channel.pairing.buildPairingReply({ - channel: "googlechat", - idLine: `Your Google Chat user id: ${senderId}`, - code, - }), + text, }); statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { + }, + onReplyError: (err) => { logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`); - } - } + }, + }); } else { logVerbose(`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`); } diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 6c03ebadf02..a3a9e32c06e 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -3,6 +3,7 @@ import { createScopedPairingAccess, dispatchInboundReplyWithBase, formatTextWithAttachmentLinks, + issuePairingChallenge, logInboundDrop, isDangerousNameMatchingEnabled, readStoreAllowFromForDmPolicy, @@ -208,28 +209,25 @@ export async function handleIrcInbound(params: { }).allowed; if (!dmAllowed) { if (dmPolicy === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: senderDisplay.toLowerCase(), + await issuePairingChallenge({ + channel: CHANNEL_ID, + senderId: senderDisplay.toLowerCase(), + senderIdLine: `Your IRC id: ${senderDisplay}`, meta: { name: message.senderNick || undefined }, - }); - if (created) { - try { - const reply = core.channel.pairing.buildPairingReply({ - channel: CHANNEL_ID, - idLine: `Your IRC id: ${senderDisplay}`, - code, - }); + upsertPairingRequest: pairing.upsertPairingRequest, + sendPairingReply: async (text) => { await deliverIrcReply({ - payload: { text: reply }, + payload: { text }, target: message.senderNick, accountId: account.accountId, sendReply: params.sendReply, statusSink, }); - } catch (err) { + }, + onReplyError: (err) => { runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`); - } - } + }, + }); } runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`); return; diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 1657cbd9113..081029782f8 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -3,6 +3,7 @@ import { createScopedPairingAccess, dispatchInboundReplyWithBase, formatTextWithAttachmentLinks, + issuePairingChallenge, logInboundDrop, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithCommandGate, @@ -173,26 +174,20 @@ export async function handleNextcloudTalkInbound(params: { } else { if (access.decision !== "allow") { if (access.decision === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: senderId, + await issuePairingChallenge({ + channel: CHANNEL_ID, + senderId, + senderIdLine: `Your Nextcloud user id: ${senderId}`, meta: { name: senderName || undefined }, - }); - if (created) { - try { - await sendMessageNextcloudTalk( - roomToken, - core.channel.pairing.buildPairingReply({ - channel: CHANNEL_ID, - idLine: `Your Nextcloud user id: ${senderId}`, - code, - }), - { accountId: account.accountId }, - ); + upsertPairingRequest: pairing.upsertPairingRequest, + sendPairingReply: async (text) => { + await sendMessageNextcloudTalk(roomToken, text, { accountId: account.accountId }); statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { + }, + onReplyError: (err) => { runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`); - } - } + }, + }); } runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`); return; diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index b276019879e..33692f27bbb 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -7,6 +7,7 @@ import type { import { createScopedPairingAccess, createReplyPrefixOptions, + issuePairingChallenge, resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, resolveOutboundMediaUrls, @@ -414,31 +415,30 @@ async function processMessageWithPipeline(params: { } if (directDmOutcome === "unauthorized") { if (dmPolicy === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: senderId, + await issuePairingChallenge({ + channel: "zalo", + senderId, + senderIdLine: `Your Zalo user id: ${senderId}`, meta: { name: senderName ?? undefined }, - }); - - if (created) { - logVerbose(core, runtime, `zalo pairing request sender=${senderId}`); - try { + upsertPairingRequest: pairing.upsertPairingRequest, + onCreated: () => { + logVerbose(core, runtime, `zalo pairing request sender=${senderId}`); + }, + sendPairingReply: async (text) => { await sendMessage( token, { chat_id: chatId, - text: core.channel.pairing.buildPairingReply({ - channel: "zalo", - idLine: `Your Zalo user id: ${senderId}`, - code, - }), + text, }, fetcher, ); statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { + }, + onReplyError: (err) => { logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`); - } - } + }, + }); } else { logVerbose( core, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index fc3e07c564e..670e0992c0e 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -8,6 +8,7 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + issuePairingChallenge, resolveOutboundMediaUrls, mergeAllowlist, resolveMentionGatingWithBypass, @@ -262,32 +263,27 @@ async function processMessage( const allowed = senderAllowedForCommands; if (!allowed) { if (dmPolicy === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: senderId, + await issuePairingChallenge({ + channel: "zalouser", + senderId, + senderIdLine: `Your Zalo user id: ${senderId}`, meta: { name: senderName || undefined }, - }); - - 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 }, - ); + upsertPairingRequest: pairing.upsertPairingRequest, + onCreated: () => { + logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`); + }, + sendPairingReply: async (text) => { + await sendMessageZalouser(chatId, text, { profile: account.profile }); statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { + }, + onReplyError: (err) => { logVerbose( core, runtime, `zalouser pairing reply failed for ${senderId}: ${String(err)}`, ); - } - } + }, + }); } else { logVerbose( core, diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index ecf7325338a..f0aa6264720 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -35,7 +35,7 @@ import { logVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { logDebug, logError } from "../../logger.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; +import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; @@ -519,28 +519,37 @@ async function ensureDmComponentAuthorized(params: { } if (dmPolicy === "pairing") { - const { code, created } = await upsertChannelPairingRequest({ + const pairingResult = await issuePairingChallenge({ channel: "discord", - id: user.id, - accountId: ctx.accountId, + senderId: user.id, + senderIdLine: `Your Discord user id: ${user.id}`, meta: { tag: formatDiscordUserTag(user), name: user.username, }, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "discord", + id, + accountId: ctx.accountId, + meta, + }), + sendPairingReply: async (text) => { + await interaction.reply({ + content: text, + ...replyOpts, + }); + }, }); - try { - 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.", - ...replyOpts, - }); - } catch { - // Interaction may have expired + if (!pairingResult.created) { + try { + await interaction.reply({ + content: "Pairing already requested. Ask the bot owner to approve your code.", + ...replyOpts, + }); + } catch { + // Interaction may have expired + } } return false; } diff --git a/src/discord/monitor/dm-command-decision.ts b/src/discord/monitor/dm-command-decision.ts index a0f64fdfb4b..d5b533bfdaa 100644 --- a/src/discord/monitor/dm-command-decision.ts +++ b/src/discord/monitor/dm-command-decision.ts @@ -1,3 +1,4 @@ +import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; import type { DiscordDmCommandAccess } from "./dm-command-auth.js"; @@ -19,17 +20,25 @@ export async function handleDiscordDmCommandDecision(params: { if (params.dmAccess.decision === "pairing") { const upsertPairingRequest = params.upsertPairingRequest ?? upsertChannelPairingRequest; - const { code, created } = await upsertPairingRequest({ + const result = await issuePairingChallenge({ channel: "discord", - id: params.sender.id, - accountId: params.accountId, + senderId: params.sender.id, + senderIdLine: `Your Discord user id: ${params.sender.id}`, meta: { tag: params.sender.tag, name: params.sender.name, }, + upsertPairingRequest: async ({ id, meta }) => + await upsertPairingRequest({ + channel: "discord", + id, + accountId: params.accountId, + meta, + }), + sendPairingReply: async () => {}, }); - if (created) { - await params.onPairingCreated(code); + if (result.created && result.code) { + await params.onPairingCreated(result.code); } return false; } diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index ffc15a4df0a..1ea35b60d95 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -30,7 +30,7 @@ import { resolveIMessageRemoteAttachmentRoots, } from "../../media/inbound-path-policy.js"; import { kindFromMime } from "../../media/mime.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; +import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; import { readChannelAllowFromStore, upsertChannelPairingRequest, @@ -288,36 +288,36 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P if (!sender) { return; } - const { code, created } = await upsertChannelPairingRequest({ + await issuePairingChallenge({ channel: "imessage", - id: decision.senderId, - accountId: accountInfo.accountId, + senderId: decision.senderId, + senderIdLine: `Your iMessage sender id: ${decision.senderId}`, meta: { sender: decision.senderId, chatId: chatId ? String(chatId) : undefined, }, - }); - 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 } : {}), - }, - ); - } catch (err) { + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "imessage", + id, + accountId: accountInfo.accountId, + meta, + }), + onCreated: () => { + logVerbose(`imessage pairing request sender=${decision.senderId}`); + }, + sendPairingReply: async (text) => { + await sendMessageIMessage(sender, text, { + client, + maxBytes: mediaMaxBytes, + accountId: accountInfo.accountId, + ...(chatId ? { chatId } : {}), + }); + }, + onReplyError: (err) => { logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`); - } - } + }, + }); return; } diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 8cf9be9d79f..2772965a8e6 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -24,8 +24,8 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "../config/runtime-group-policy.js"; import { danger, logVerbose } from "../globals.js"; +import { issuePairingChallenge } from "../pairing/pairing-challenge.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; -import { buildPairingReply } from "../pairing/pairing-messages.js"; import { readChannelAllowFromStore, upsertChannelPairingRequest, @@ -237,15 +237,6 @@ async function sendLinePairingReply(params: { context: LineHandlerContext; }): Promise { const { senderId, replyToken, context } = params; - const { code, created } = await upsertChannelPairingRequest({ - channel: "line", - id: senderId, - accountId: context.account.accountId, - }); - if (!created) { - return; - } - logVerbose(`line pairing request sender=${senderId}`); const idLabel = (() => { try { return resolvePairingIdLabel("line"); @@ -253,30 +244,42 @@ async function sendLinePairingReply(params: { return "lineUserId"; } })(); - const text = buildPairingReply({ + await issuePairingChallenge({ channel: "line", - idLine: `Your ${idLabel}: ${senderId}`, - code, - }); - try { - if (replyToken) { - await replyMessageLine(replyToken, [{ type: "text", text }], { + senderId, + senderIdLine: `Your ${idLabel}: ${senderId}`, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "line", + id, accountId: context.account.accountId, - channelAccessToken: context.account.channelAccessToken, - }); - return; - } - } catch (err) { - logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`); - } - try { - await pushMessageLine(`line:${senderId}`, text, { - accountId: context.account.accountId, - channelAccessToken: context.account.channelAccessToken, - }); - } catch (err) { - logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`); - } + meta, + }), + onCreated: () => { + logVerbose(`line pairing request sender=${senderId}`); + }, + sendPairingReply: async (text) => { + if (replyToken) { + try { + await replyMessageLine(replyToken, [{ type: "text", text }], { + accountId: context.account.accountId, + channelAccessToken: context.account.channelAccessToken, + }); + return; + } catch (err) { + logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`); + } + } + try { + await pushMessageLine(`line:${senderId}`, text, { + accountId: context.account.accountId, + channelAccessToken: context.account.channelAccessToken, + }); + } catch (err) { + logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`); + } + }, + }); } async function shouldProcessLineEvent( diff --git a/src/pairing/pairing-challenge.test.ts b/src/pairing/pairing-challenge.test.ts new file mode 100644 index 00000000000..cb447499005 --- /dev/null +++ b/src/pairing/pairing-challenge.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { issuePairingChallenge } from "./pairing-challenge.js"; + +describe("issuePairingChallenge", () => { + it("creates and sends a pairing reply when request is newly created", async () => { + const sent: string[] = []; + + const result = await issuePairingChallenge({ + channel: "telegram", + senderId: "123", + senderIdLine: "Your Telegram user id: 123", + upsertPairingRequest: async () => ({ code: "ABCD", created: true }), + sendPairingReply: async (text) => { + sent.push(text); + }, + }); + + expect(result).toEqual({ created: true, code: "ABCD" }); + expect(sent).toHaveLength(1); + expect(sent[0]).toContain("ABCD"); + }); + + it("does not send a reply when request already exists", async () => { + const sendPairingReply = vi.fn(async () => {}); + + const result = await issuePairingChallenge({ + channel: "telegram", + senderId: "123", + senderIdLine: "Your Telegram user id: 123", + upsertPairingRequest: async () => ({ code: "ABCD", created: false }), + sendPairingReply, + }); + + expect(result).toEqual({ created: false }); + expect(sendPairingReply).not.toHaveBeenCalled(); + }); + + it("supports custom reply text builder", async () => { + const sent: string[] = []; + + await issuePairingChallenge({ + channel: "line", + senderId: "u1", + senderIdLine: "Your line id: u1", + upsertPairingRequest: async () => ({ code: "ZXCV", created: true }), + buildReplyText: ({ code }) => `custom ${code}`, + sendPairingReply: async (text) => { + sent.push(text); + }, + }); + + expect(sent).toEqual(["custom ZXCV"]); + }); + + it("calls onCreated and forwards meta to upsert", async () => { + const onCreated = vi.fn(); + const upsert = vi.fn(async () => ({ code: "1111", created: true })); + + await issuePairingChallenge({ + channel: "discord", + senderId: "42", + senderIdLine: "Your Discord user id: 42", + meta: { name: "alice" }, + upsertPairingRequest: upsert, + onCreated, + sendPairingReply: async () => {}, + }); + + expect(upsert).toHaveBeenCalledWith({ id: "42", meta: { name: "alice" } }); + expect(onCreated).toHaveBeenCalledWith({ code: "1111" }); + }); + + it("captures reply errors through onReplyError", async () => { + const onReplyError = vi.fn(); + + const result = await issuePairingChallenge({ + channel: "signal", + senderId: "+1555", + senderIdLine: "Your Signal sender id: +1555", + upsertPairingRequest: async () => ({ code: "9999", created: true }), + sendPairingReply: async () => { + throw new Error("send failed"); + }, + onReplyError, + }); + + expect(result).toEqual({ created: true, code: "9999" }); + expect(onReplyError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index bb67d56878e..8cf9b38b916 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -86,6 +86,7 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { isAllowedParsedChatSender } from "./allow-from.js"; export { readBooleanParam } from "./boolean-param.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { resolveRequestUrl } from "./request-url.js"; export { buildComputedAccountStatusSnapshot, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 360623d9e9c..4a21071e4c6 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -57,6 +57,7 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { createPersistentDedupe } from "./persistent-dedupe.js"; export { buildBaseChannelStatusSummary, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index e7b96355608..204e0affd3c 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -63,6 +63,7 @@ export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { extractToolSend } from "./tool-send.js"; export { resolveWebhookPath } from "./webhook-path.js"; export type { WebhookInFlightLimiter } from "./webhook-request-guards.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index afc9428bb05..1983130d193 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -60,6 +60,7 @@ export { export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 03116a7864b..e6f1f3a9bcd 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -84,6 +84,7 @@ export { resolveAccountWithDefaultFallback, } from "./account-resolution.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { createPersistentDedupe } from "./persistent-dedupe.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 852c6f17f0c..a498e050034 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -67,6 +67,7 @@ export { evaluateSenderGroupAccess } from "./group-access.js"; export type { SenderGroupAccessDecision } from "./group-access.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index d0c75742ef0..7558c7f750c 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -57,6 +57,7 @@ export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; export { loadOutboundMediaFromUrl } from "./outbound-media.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { diff --git a/src/telegram/dm-access.ts b/src/telegram/dm-access.ts index 1c68dd43d69..26734b69602 100644 --- a/src/telegram/dm-access.ts +++ b/src/telegram/dm-access.ts @@ -2,7 +2,7 @@ import type { Message } from "@grammyjs/types"; import type { Bot } from "grammy"; import type { DmPolicy } from "../config/types.js"; import { logVerbose } from "../globals.js"; -import { buildPairingReply } from "../pairing/pairing-messages.js"; +import { issuePairingChallenge } from "../pairing/pairing-challenge.js"; import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js"; @@ -70,42 +70,46 @@ export async function enforceTelegramDmAccess(params: { if (dmPolicy === "pairing") { try { const telegramUserId = sender.userId ?? sender.candidateId; - const { code, created } = await upsertChannelPairingRequest({ + await issuePairingChallenge({ channel: "telegram", - id: telegramUserId, - accountId, + senderId: telegramUserId, + senderIdLine: `Your Telegram user id: ${telegramUserId}`, meta: { username: sender.username || undefined, firstName: sender.firstName, lastName: sender.lastName, }, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "telegram", + id, + accountId, + meta, + }), + onCreated: () => { + logger.info( + { + chatId: String(chatId), + senderUserId: sender.userId ?? undefined, + username: sender.username || undefined, + firstName: sender.firstName, + lastName: sender.lastName, + matchKey: allowMatch.matchKey ?? "none", + matchSource: allowMatch.matchSource ?? "none", + }, + "telegram pairing request", + ); + }, + sendPairingReply: async (text) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => bot.api.sendMessage(chatId, text), + }); + }, + onReplyError: (err) => { + logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); + }, }); - if (created) { - logger.info( - { - chatId: String(chatId), - senderUserId: sender.userId ?? undefined, - username: sender.username || undefined, - firstName: sender.firstName, - lastName: sender.lastName, - matchKey: allowMatch.matchKey ?? "none", - matchSource: allowMatch.matchSource ?? "none", - }, - "telegram pairing request", - ); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - fn: () => - bot.api.sendMessage( - chatId, - buildPairingReply({ - channel: "telegram", - idLine: `Your Telegram user id: ${telegramUserId}`, - code, - }), - ), - }); - } } catch (err) { logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); } diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 2363434f34c..a01e27fb6e0 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -5,7 +5,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "../../config/runtime-group-policy.js"; import { logVerbose } from "../../globals.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; +import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; import { readStoreAllowFromForDmPolicy, @@ -171,28 +171,30 @@ export async function checkInboundAccessControl(params: { if (suppressPairingReply) { logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`); } else { - const { code, created } = await upsertChannelPairingRequest({ + await issuePairingChallenge({ channel: "whatsapp", - id: candidate, - accountId: account.accountId, + senderId: candidate, + senderIdLine: `Your WhatsApp phone number: ${candidate}`, meta: { name: (params.pushName ?? "").trim() || undefined }, - }); - if (created) { - logVerbose( - `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, - }), - }); - } catch (err) { + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "whatsapp", + id, + accountId: account.accountId, + meta, + }), + onCreated: () => { + logVerbose( + `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, + ); + }, + sendPairingReply: async (text) => { + await params.sock.sendMessage(params.remoteJid, { text }); + }, + onReplyError: (err) => { logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); - } - } + }, + }); } return { allowed: false,