From e0439df4ced546f3fd79fe926dd8f1c095185d5c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 23:19:13 +0000 Subject: [PATCH] feat(pairing): show sender ids across providers --- CHANGELOG.md | 1 + src/cli/pairing-cli.test.ts | 25 +++++++- src/cli/pairing-cli.ts | 11 +++- src/discord/monitor.tool-result.test.ts | 82 +++++++++++++++++++++++++ src/discord/monitor.ts | 4 ++ src/imessage/monitor.test.ts | 3 + src/imessage/monitor.ts | 2 + src/signal/monitor.tool-result.test.ts | 6 ++ src/signal/monitor.ts | 6 ++ src/slack/monitor.tool-result.test.ts | 3 + src/slack/monitor.ts | 4 ++ src/web/inbound.ts | 2 + src/web/monitor-inbox.test.ts | 9 +++ 13 files changed, 156 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e867c310a..c8dd7436c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415) +- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly. - Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist - Agent system prompt: avoid automatic self-updates unless explicitly requested. - Onboarding: tighten QuickStart hint copy for configuring later. diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 51735b32f..887ada6b0 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -60,5 +60,28 @@ describe("pairing cli", () => { expect.stringContaining("telegramUserId=123"), ); }); -}); + it("labels Discord ids as discordUserId", async () => { + const { registerPairingCli } = await import("./pairing-cli.js"); + listProviderPairingRequests.mockResolvedValueOnce([ + { + id: "999", + code: "DEF456", + createdAt: "2026-01-08T00:00:00Z", + lastSeenAt: "2026-01-08T00:00:00Z", + meta: { tag: "Ada#0001" }, + }, + ]); + + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + program.name("test"); + registerPairingCli(program); + await program.parseAsync(["pairing", "list", "--provider", "discord"], { + from: "user", + }); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("discordUserId=999"), + ); + }); +}); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index f014e0ef3..645b1493e 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -22,6 +22,15 @@ const PROVIDERS: PairingProvider[] = [ "whatsapp", ]; +const PROVIDER_ID_LABELS: Record = { + telegram: "telegramUserId", + discord: "discordUserId", + slack: "slackUserId", + signal: "signalNumber", + imessage: "imessageSenderId", + whatsapp: "whatsappSenderId", +}; + function parseProvider(raw: unknown): PairingProvider { const value = ( typeof raw === "string" @@ -93,7 +102,7 @@ export function registerPairingCli(program: Command) { } for (const r of requests) { const meta = r.meta ? JSON.stringify(r.meta) : ""; - const idLabel = provider === "telegram" ? "telegramUserId" : "id"; + const idLabel = PROVIDER_ID_LABELS[provider]; console.log( `${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`, ); diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 26c824a6d..a9d09bdd1 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const sendMock = vi.fn(); const updateLastRouteMock = vi.fn(); const dispatchMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); vi.mock("./send.js", () => ({ sendMessageDiscord: (...args: unknown[]) => sendMock(...args), @@ -12,6 +14,12 @@ vi.mock("./send.js", () => ({ vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); vi.mock("../config/sessions.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -29,6 +37,10 @@ beforeEach(() => { dispatcher.sendFinalReply({ text: "hi" }); return { queuedFinal: true, counts: { final: 1 } }; }); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); vi.resetModules(); }); @@ -99,6 +111,76 @@ describe("discord tool result dispatch", () => { expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /); }, 10000); + it("replies with pairing code and sender id when dmPolicy is pairing", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, + discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } }, + routing: { allowFrom: [] }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + }); + + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m1", + content: "hello", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u2", bot: false, username: "Ada" }, + }, + author: { id: "u2", bot: false, username: "Ada" }, + guild_id: null, + }, + client, + ); + + expect(dispatchMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Your Discord user id: u2", + ); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }, 10000); + it("accepts guild messages when mentionPatterns match", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 110d7f36b..dccf36de1 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -594,6 +594,8 @@ export function createDiscordMessageHandler(params: { [ "Clawdbot: access not configured.", "", + `Your Discord user id: ${author.id}`, + "", `Pairing code: ${code}`, "", "Ask the bot owner to approve with:", @@ -1436,6 +1438,8 @@ function createDiscordNativeCommand(params: { content: [ "Clawdbot: access not configured.", "", + `Your Discord user id: ${user.id}`, + "", `Pairing code: ${code}`, "", "Ask the bot owner to approve with:", diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index 52cae4963..c7e67171f 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -284,6 +284,9 @@ describe("monitorIMessageProvider", () => { expect(replyMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).toHaveBeenCalled(); expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Your iMessage sender id: +15550001111", + ); expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( "Pairing code: PAIRCODE", ); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 3aed656f7..e4afe389a 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -259,6 +259,8 @@ export async function monitorIMessageProvider( [ "Clawdbot: access not configured.", "", + `Your iMessage sender id: ${senderId}`, + "", `Pairing code: ${code}`, "", "Ask the bot owner to approve with:", diff --git a/src/signal/monitor.tool-result.test.ts b/src/signal/monitor.tool-result.test.ts index 4748bd5ea..ff1777501 100644 --- a/src/signal/monitor.tool-result.test.ts +++ b/src/signal/monitor.tool-result.test.ts @@ -146,6 +146,9 @@ describe("monitorSignalProvider tool results", () => { expect(replyMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).toHaveBeenCalled(); expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Your Signal number: +15550001111", + ); expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( "Pairing code: PAIRCODE", ); @@ -237,6 +240,9 @@ describe("monitorSignalProvider tool results", () => { ); expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + `Your Signal sender id: uuid:${uuid}`, + ); }); it("reconnects after stream errors until aborted", async () => { diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 95a82120c..df72d28f1 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -317,6 +317,10 @@ export async function monitorSignalProvider( const senderRecipient = resolveSignalRecipient(sender); const senderPeerId = resolveSignalPeerId(sender); const senderAllowId = formatSignalSenderId(sender); + const senderIdLine = + sender.kind === "phone" + ? `Your Signal number: ${sender.e164}` + : `Your Signal sender id: ${senderAllowId}`; if (!senderRecipient) return; const groupId = dataMessage.groupInfo?.groupId ?? undefined; const groupName = dataMessage.groupInfo?.groupName ?? undefined; @@ -351,6 +355,8 @@ export async function monitorSignalProvider( [ "Clawdbot: access not configured.", "", + senderIdLine, + "", `Pairing code: ${code}`, "", "Ask the bot owner to approve with:", diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 540a065b8..5c63f7468 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -567,6 +567,9 @@ describe("monitorSlackProvider tool results", () => { expect(replyMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).toHaveBeenCalled(); expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Your Slack user id: U1", + ); expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( "Pairing code: PAIRCODE", ); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 729cf660a..f8e5c2215 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -827,6 +827,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { [ "Clawdbot: access not configured.", "", + `Your Slack user id: ${directUserId}`, + "", `Pairing code: ${code}`, "", "Ask the bot owner to approve with:", @@ -1720,6 +1722,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { text: [ "Clawdbot: access not configured.", "", + `Your Slack user id: ${command.user_id}`, + "", `Pairing code: ${code}`, "", "Ask the bot owner to approve with:", diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 1bdce198c..a25eeeeb9 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -313,6 +313,8 @@ export async function monitorWebInbox(options: { text: [ "Clawdbot: access not configured.", "", + `Your WhatsApp sender id: ${candidate}`, + "", `Pairing code: ${code}`, "", "Ask the bot owner to approve with:", diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 39eb206fa..e7c3662c4 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -670,6 +670,9 @@ describe("web monitor inbox", () => { // Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn). expect(sock.readMessages).not.toHaveBeenCalled(); expect(sock.sendMessage).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Your WhatsApp sender id: +999"), + }); expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { text: expect.stringContaining("Pairing code: PAIRCODE"), }); @@ -1121,6 +1124,9 @@ describe("web monitor inbox", () => { await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).not.toHaveBeenCalled(); expect(sock.sendMessage).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Your WhatsApp sender id: +999"), + }); expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { text: expect.stringContaining("Pairing code: PAIRCODE"), }); @@ -1274,6 +1280,9 @@ describe("web monitor inbox", () => { expect(onMessage).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Your WhatsApp sender id: +999"), + }); expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { text: expect.stringContaining("Pairing code: PAIRCODE"), });