From 2454444f2e5e8e53fd1461a7a1865023ef0855f6 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 12:27:06 +0000 Subject: [PATCH] feat(telegram-pairing): accept bare 4-digit codes Require the message to be exactly the 4 digits (optionally prefixed by @botname). Loose matches like "my pin is 0349" are rejected to avoid false positives from chat traffic that happens to contain a 4-digit number. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-telegram-v2/SKILL.md | 2 +- src/channels/telegram-pairing.test.ts | 16 +++++++++++----- src/channels/telegram-pairing.ts | 22 ++++++++++++++-------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md index fc18cc5..da738dc 100644 --- a/.claude/skills/add-telegram-v2/SKILL.md +++ b/.claude/skills/add-telegram-v2/SKILL.md @@ -68,7 +68,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **type**: `telegram` - **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot. -- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send `@ CODE` from the chat they want to register (DM the bot for `main`, post in the group otherwise). The step waits up to 5 minutes and emits a `PAIR_TELEGRAM` block with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID` once the user echoes the code. The service must be running for this to work (the polling adapter is what observes the code). +- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@ CODE`. The step waits up to 5 minutes and emits a `PAIR_TELEGRAM` block with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID` once the user echoes the code. The service must be running for this to work (the polling adapter is what observes the code). - **supports-threads**: no - **typical-use**: Interactive chat — direct messages or small groups - **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. diff --git a/src/channels/telegram-pairing.test.ts b/src/channels/telegram-pairing.test.ts index 0af26b0..6b73840 100644 --- a/src/channels/telegram-pairing.test.ts +++ b/src/channels/telegram-pairing.test.ts @@ -45,15 +45,20 @@ describe('extractAddressedText', () => { }); describe('extractCode', () => { - it('finds 4-digit code after @botname', () => { + it('accepts a bare 4-digit code', () => { + expect(extractCode('0349', 'nanobot')).toBe('0349'); + }); + it('accepts 4-digit code after @botname', () => { expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042'); }); it('rejects non-4-digit numbers', () => { expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull(); expect(extractCode('@nanobot 12', 'nanobot')).toBeNull(); + expect(extractCode('12345', 'nanobot')).toBeNull(); }); - it('returns null without addressing', () => { - expect(extractCode('1234', 'nanobot')).toBeNull(); + it('rejects loose matches with surrounding text', () => { + expect(extractCode('my pin is 0349', 'nanobot')).toBeNull(); + expect(extractCode('0349 thanks', 'nanobot')).toBeNull(); }); }); @@ -103,7 +108,7 @@ describe('tryConsume', () => { expect(out).toBeNull(); }); - it('returns null without @botname addressing', async () => { + it('matches a bare code without @botname addressing', async () => { const r = await createPairing('main'); const out = await tryConsume({ text: r.code, @@ -111,7 +116,8 @@ describe('tryConsume', () => { platformId: 'x', isGroup: false, }); - expect(out).toBeNull(); + expect(out).not.toBeNull(); + expect(out!.status).toBe('consumed'); }); it('cannot be consumed twice', async () => { diff --git a/src/channels/telegram-pairing.ts b/src/channels/telegram-pairing.ts index 7c3e194..cf1a7e2 100644 --- a/src/channels/telegram-pairing.ts +++ b/src/channels/telegram-pairing.ts @@ -3,10 +3,12 @@ * * BotFather hands out tokens with no user binding, so anyone who guesses the * bot's username can DM it. Pairing closes that gap: setup creates a one-time - * 4-digit code and the operator echoes it back as `@botname CODE` from the - * chat they want to register. The inbound interceptor in telegram.ts matches - * the code and records the chat (with admin_user_id) before it ever reaches - * the router. + * 4-digit code and the operator echoes it back from the chat they want to + * register. The message must be exactly the 4 digits (optionally prefixed by + * `@botname ` for groups with privacy ON) — arbitrary messages that happen to + * contain a 4-digit number do NOT match. The inbound interceptor in + * telegram.ts matches the code and records the chat (with admin_user_id) + * before it ever reaches the router. * * Storage is a JSON file at data/telegram-pairings.json — single-process, * read-modify-write under an in-process mutex. @@ -144,11 +146,15 @@ export function extractAddressedText(text: string, botUsername: string): string return trimmed.slice(m[0].length).trim(); } -/** Find a 4-digit code in `@botname CODE`-style text. Returns null if none. */ +/** + * Extract a pairing code from an inbound message. The message must be exactly + * 4 digits (optionally prefixed by `@botname `) — loose matches like + * "my pin is 1234" are rejected to avoid false positives from chatter. + */ export function extractCode(text: string, botUsername: string): string | null { - const remainder = extractAddressedText(text, botUsername); - if (remainder === null) return null; - const m = remainder.match(/\b(\d{4})\b/); + const addressed = extractAddressedText(text, botUsername); + const candidate = (addressed !== null ? addressed : text).trim(); + const m = candidate.match(/^(\d{4})$/); return m ? m[1] : null; }