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:
@@ -68,7 +68,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
|||||||
|
|
||||||
- **type**: `telegram`
|
- **type**: `telegram`
|
||||||
- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot.
|
- **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
|
- **supports-threads**: no
|
||||||
- **typical-use**: Interactive chat — direct messages or small groups
|
- **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.
|
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
||||||
|
|||||||
@@ -45,15 +45,20 @@ describe('extractAddressedText', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('extractCode', () => {
|
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');
|
expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042');
|
||||||
});
|
});
|
||||||
it('rejects non-4-digit numbers', () => {
|
it('rejects non-4-digit numbers', () => {
|
||||||
expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull();
|
expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull();
|
||||||
expect(extractCode('@nanobot 12', 'nanobot')).toBeNull();
|
expect(extractCode('@nanobot 12', 'nanobot')).toBeNull();
|
||||||
|
expect(extractCode('12345', 'nanobot')).toBeNull();
|
||||||
});
|
});
|
||||||
it('returns null without addressing', () => {
|
it('rejects loose matches with surrounding text', () => {
|
||||||
expect(extractCode('1234', 'nanobot')).toBeNull();
|
expect(extractCode('my pin is 0349', 'nanobot')).toBeNull();
|
||||||
|
expect(extractCode('0349 thanks', 'nanobot')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,7 +108,7 @@ describe('tryConsume', () => {
|
|||||||
expect(out).toBeNull();
|
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 r = await createPairing('main');
|
||||||
const out = await tryConsume({
|
const out = await tryConsume({
|
||||||
text: r.code,
|
text: r.code,
|
||||||
@@ -111,7 +116,8 @@ describe('tryConsume', () => {
|
|||||||
platformId: 'x',
|
platformId: 'x',
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
});
|
});
|
||||||
expect(out).toBeNull();
|
expect(out).not.toBeNull();
|
||||||
|
expect(out!.status).toBe('consumed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cannot be consumed twice', async () => {
|
it('cannot be consumed twice', async () => {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
*
|
*
|
||||||
* BotFather hands out tokens with no user binding, so anyone who guesses the
|
* 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
|
* 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
|
* 4-digit code and the operator echoes it back from the chat they want to
|
||||||
* chat they want to register. The inbound interceptor in telegram.ts matches
|
* register. The message must be exactly the 4 digits (optionally prefixed by
|
||||||
* the code and records the chat (with admin_user_id) before it ever reaches
|
* `@botname ` for groups with privacy ON) — arbitrary messages that happen to
|
||||||
* the router.
|
* 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,
|
* Storage is a JSON file at data/telegram-pairings.json — single-process,
|
||||||
* read-modify-write under an in-process mutex.
|
* 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();
|
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 {
|
export function extractCode(text: string, botUsername: string): string | null {
|
||||||
const remainder = extractAddressedText(text, botUsername);
|
const addressed = extractAddressedText(text, botUsername);
|
||||||
if (remainder === null) return null;
|
const candidate = (addressed !== null ? addressed : text).trim();
|
||||||
const m = remainder.match(/\b(\d{4})\b/);
|
const m = candidate.match(/^(\d{4})$/);
|
||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user