refactor: unify DM pairing challenge flows

This commit is contained in:
Peter Steinberger
2026-03-07 19:36:02 +00:00
parent dab0e97c22
commit 2bcd56cfac
21 changed files with 356 additions and 241 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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