From 65afcdc946b9e23924d1d8190205e866c2dbc11c Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 13 Apr 2026 12:27:09 +0000 Subject: [PATCH] feat(telegram-pairing): surface wrong-code attempts + auto-regen with retry cap - createPairing now replaces any existing pending pairing for the same intent (replace-by-default; no "two pending codes for one intent" state) - tryConsume records each attempt on pending records (capped at 10); a wrong code invalidates the pairing immediately (one attempt per code) - waitForPairing gains onAttempt callback for misses and rejects with a distinct "invalidated by wrong code" message so callers can distinguish TTL expiry from user-error - pair-telegram emits PAIR_TELEGRAM_ATTEMPT on misses and auto-regenerates the pairing up to 5 times, emitting PAIR_TELEGRAM_NEW_CODE for each - Skill docs updated so the host Claude knows to show new codes and offer another batch on max-regenerations-exceeded Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-telegram-v2/SKILL.md | 2 +- setup/pair-telegram.ts | 69 +++++++++++++++------- src/channels/telegram-pairing.test.ts | 72 +++++++++++++++++++++++ src/channels/telegram-pairing.ts | 77 ++++++++++++++++++++++++- 4 files changed, 196 insertions(+), 24 deletions(-) diff --git a/.claude/skills/add-telegram-v2/SKILL.md b/.claude/skills/add-telegram-v2/SKILL.md index da738dc..e5c7f77 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 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). +- **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`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. 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/setup/pair-telegram.ts b/setup/pair-telegram.ts index 21c8467..ea317ed 100644 --- a/setup/pair-telegram.ts +++ b/setup/pair-telegram.ts @@ -65,33 +65,58 @@ export async function run(args: string[]): Promise { const db = initDb(path.join(DATA_DIR, 'v2.db')); runMigrations(db); - const record = await createPairing(intent, { ttlMs }); - - // Tell the user what to do. The skill prints this as user-facing text. + const MAX_REGENERATIONS = 5; + let record = await createPairing(intent, { ttlMs }); emitStatus('PAIR_TELEGRAM_ISSUED', { CODE: record.code, INTENT: intentToString(intent), EXPIRES_AT: record.expiresAt, - INSTRUCTIONS: `Send "@ ${record.code}" from the Telegram chat you want to register.`, + INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@ ${record.code}" in a group with privacy on).`, }); - try { - const consumed = await waitForPairing(record.code, { timeoutMs: ttlMs }); - emitStatus('PAIR_TELEGRAM', { - STATUS: 'success', - CODE: record.code, - INTENT: intentToString(consumed.intent), - PLATFORM_ID: consumed.consumed!.platformId, - IS_GROUP: consumed.consumed!.isGroup, - ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '', - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - emitStatus('PAIR_TELEGRAM', { - STATUS: 'failed', - CODE: record.code, - ERROR: message, - }); - process.exit(2); + for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { + try { + const consumed = await waitForPairing(record.code, { + timeoutMs: ttlMs, + onAttempt: (a) => { + emitStatus('PAIR_TELEGRAM_ATTEMPT', { + EXPECTED_CODE: record.code, + RECEIVED_CODE: a.candidate, + PLATFORM_ID: a.platformId, + AT: a.at, + }); + }, + }); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'success', + CODE: record.code, + INTENT: intentToString(consumed.intent), + PLATFORM_ID: consumed.consumed!.platformId, + IS_GROUP: consumed.consumed!.isGroup, + ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '', + }); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const invalidated = /invalidated by wrong code/.test(message); + if (invalidated && regen < MAX_REGENERATIONS) { + record = await createPairing(intent, { ttlMs }); + emitStatus('PAIR_TELEGRAM_NEW_CODE', { + CODE: record.code, + INTENT: intentToString(intent), + EXPIRES_AT: record.expiresAt, + REASON: 'previous code invalidated by wrong attempt', + REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1, + INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`, + }); + continue; + } + emitStatus('PAIR_TELEGRAM', { + STATUS: 'failed', + CODE: record.code, + ERROR: invalidated ? 'max-regenerations-exceeded' : message, + }); + process.exit(2); + } } } diff --git a/src/channels/telegram-pairing.test.ts b/src/channels/telegram-pairing.test.ts index 6b73840..1404df8 100644 --- a/src/channels/telegram-pairing.test.ts +++ b/src/channels/telegram-pairing.test.ts @@ -9,6 +9,7 @@ import { createPairing, tryConsume, getStatus, + getPairing, waitForPairing, extractCode, extractAddressedText, @@ -160,6 +161,77 @@ describe('waitForPairing', () => { }); }); +describe('replace-by-default', () => { + it('supersedes an existing pending pairing with the same intent', async () => { + const first = await createPairing('main', { ttlMs: 60_000 }); + const second = await createPairing('main', { ttlMs: 60_000 }); + expect(getStatus(first.code)).toBe('expired'); + expect(getStatus(second.code)).toBe('pending'); + }); + + it('does not supersede pairings with a different intent', async () => { + const a = await createPairing({ kind: 'wire-to', folder: 'work' }); + const b = await createPairing({ kind: 'wire-to', folder: 'side' }); + expect(getStatus(a.code)).toBe('pending'); + expect(getStatus(b.code)).toBe('pending'); + }); + + it('causes waitForPairing on the old code to reject as expired', async () => { + const first = await createPairing('main', { ttlMs: 60_000 }); + const waiter = waitForPairing(first.code, { pollMs: 30 }); + await new Promise((r) => setTimeout(r, 50)); + await createPairing('main', { ttlMs: 60_000 }); + await expect(waiter).rejects.toThrow(/expired/); + }); +}); + +describe('attempt tracking', () => { + it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + const attempts: string[] = []; + const waiter = waitForPairing(r.code, { + pollMs: 30, + onAttempt: (a) => attempts.push(a.candidate), + }); + setTimeout(() => { + tryConsume({ text: '9999', botUsername: 'b', platformId: 'tg:1', isGroup: false }); + }, 60); + await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/); + expect(attempts).toEqual(['9999']); + expect(getStatus(r.code)).toBe('expired'); + }); + + it('a correct code consumes without firing onAttempt', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + const attempts: string[] = []; + const waiter = waitForPairing(r.code, { + pollMs: 30, + onAttempt: (a) => attempts.push(a.candidate), + }); + setTimeout(() => { + tryConsume({ text: r.code, botUsername: 'b', platformId: 'tg:1', isGroup: false }); + }, 60); + const consumed = await waiter; + expect(consumed.status).toBe('consumed'); + expect(attempts).toEqual([]); + }); + + it('ignores non-code messages and keeps the pairing pending', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false }); + const after = getPairing(r.code); + expect(after?.status).toBe('pending'); + expect(after?.attempts ?? []).toHaveLength(0); + }); + + it('a second code attempt after invalidation does not match', async () => { + const r = await createPairing('main', { ttlMs: 5000 }); + await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false }); + const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false }); + expect(retry).toBeNull(); + }); +}); + describe('intent passthrough', () => { it('preserves wire-to and new-agent intents', async () => { const a = await createPairing({ kind: 'wire-to', folder: 'work' }); diff --git a/src/channels/telegram-pairing.ts b/src/channels/telegram-pairing.ts index cf1a7e2..5a6cedb 100644 --- a/src/channels/telegram-pairing.ts +++ b/src/channels/telegram-pairing.ts @@ -30,6 +30,13 @@ export interface ConsumedDetails { consumedAt: string; } +export interface PairingAttempt { + candidate: string; + platformId: string; + at: string; + matched: boolean; +} + export interface PairingRecord { code: string; intent: PairingIntent; @@ -37,6 +44,15 @@ export interface PairingRecord { expiresAt: string; status: Exclude; consumed?: ConsumedDetails; + /** Recent pairing attempts observed while this record was pending. Capped. */ + attempts?: PairingAttempt[]; +} + +const MAX_ATTEMPTS_PER_RECORD = 10; + +function intentEquals(a: PairingIntent, b: PairingIntent): boolean { + if (a === 'main' || b === 'main') return a === b; + return a.kind === b.kind && a.folder === b.folder; } interface Store { @@ -112,6 +128,15 @@ export async function createPairing(intent: PairingIntent, opts: CreatePairingOp return withLock(() => { const store = readStore(); sweep(store, Date.now()); + // Replace-by-default: a new pairing for an intent supersedes any existing + // pending pairing for the same intent. Old waitForPairing calls observe + // `expired` and exit on their own. + for (const r of store.pairings) { + if (r.status === 'pending' && intentEquals(r.intent, intent)) { + r.status = 'expired'; + log.info('Pairing superseded by new request', { code: r.code, intent }); + } + } const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code)); const now = new Date(); const record: PairingRecord = { @@ -172,7 +197,28 @@ export async function tryConsume(input: ConsumeInput): Promise r.code === code && r.status === 'pending'); if (!record) { + // Miss: record the attempt on every currently-pending record so each + // waitForPairing caller can surface it as user feedback. + const attempt: PairingAttempt = { + candidate: code, + platformId: input.platformId, + at: new Date(now).toISOString(), + matched: false, + }; + let recorded = false; + for (const r of store.pairings) { + if (r.status !== 'pending') continue; + r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD); + // One attempt per code. A wrong guess invalidates the pairing + // immediately — pair-telegram observes the `expired` signal and + // auto-issues a fresh code (up to a retry cap). + r.status = 'expired'; + recorded = true; + } writeStore(store); + if (recorded) { + log.info('Pairing invalidated by wrong attempt', { candidate: code, platformId: input.platformId }); + } return null; } record.status = 'consumed'; @@ -183,6 +229,10 @@ export async function tryConsume(input: ConsumeInput): Promise void; } /** @@ -238,6 +290,7 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions = if (interval) clearInterval(interval); }; + let seenAttempts = 0; const check = () => { if (settled) return; const r = getPairing(code); @@ -246,6 +299,21 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions = reject(new Error(`Pairing ${code} disappeared`)); return; } + // Surface any new miss attempts since the last tick. Only fire for + // misses — matches are signaled by `status === 'consumed'` below. + if (opts.onAttempt && r.attempts) { + for (let i = seenAttempts; i < r.attempts.length; i++) { + const a = r.attempts[i]; + if (!a.matched) { + try { + opts.onAttempt(a); + } catch { + /* ignore */ + } + } + } + seenAttempts = r.attempts.length; + } if (r.status === 'consumed') { cleanup(); resolve(r); @@ -253,7 +321,14 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions = } if (r.status === 'expired' || Date.now() >= deadline) { cleanup(); - reject(new Error(`Pairing ${code} expired`)); + const lastMiss = r.attempts + ?.slice() + .reverse() + .find((a) => !a.matched); + const reason = lastMiss + ? `Pairing ${code} invalidated by wrong code (${lastMiss.candidate})` + : `Pairing ${code} expired`; + reject(new Error(reason)); return; } };