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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,