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) <noreply@anthropic.com>
This commit is contained in:
Koshkoshinsk
2026-04-13 12:27:06 +00:00
parent 2017589683
commit 2454444f2e
3 changed files with 26 additions and 14 deletions
+1 -1
View File
@@ -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 <main|wire-to:folder|new-agent:folder>`, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send `@<botname> 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 <main|wire-to:folder|new-agent:folder>`, 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: `@<botname> 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.
+11 -5
View File
@@ -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 () => {
+14 -8
View File
@@ -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;
}