From a1a324097e4dd5932a95a01a34aa7f10edf611c9 Mon Sep 17 00:00:00 2001 From: gabi-simons <263580637+gabi-simons@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:54:48 +0000 Subject: [PATCH] refactor(telegram-pairing): remove TTL expiry from pairing codes Pairing codes no longer expire on a timer. They are consumed on match or invalidated by wrong guesses. Removes ttlMs/expiresAt/deadline from the pairing primitive, setup CLI, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/pair-telegram.ts | 22 ++------- src/channels/telegram-pairing.test.ts | 50 +++++++++++--------- src/channels/telegram-pairing.ts | 67 ++++++++++----------------- 3 files changed, 57 insertions(+), 82 deletions(-) diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts index 308f662..e5e767c 100644 --- a/setup/pair-telegram.ts +++ b/setup/pair-telegram.ts @@ -21,14 +21,8 @@ import { } from '../src/channels/telegram-pairing.js'; import { emitStatus } from './status.js'; -interface Args { - intent: PairingIntent; - ttlMs: number; -} - -function parseArgs(args: string[]): Args { +function parseArgs(args: string[]): PairingIntent { let intent: PairingIntent = 'main'; - let ttlMs = 5 * 60 * 1000; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--intent': { @@ -44,12 +38,9 @@ function parseArgs(args: string[]): Args { } break; } - case '--ttl-ms': - ttlMs = parseInt(args[++i] || '300000', 10); - break; } } - return { intent, ttlMs }; + return intent; } function intentToString(intent: PairingIntent): string { @@ -58,7 +49,7 @@ function intentToString(intent: PairingIntent): string { } export async function run(args: string[]): Promise { - const { intent, ttlMs } = parseArgs(args); + const intent = parseArgs(args); // Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly // required for the pairing primitive itself, but the inbound interceptor @@ -68,11 +59,10 @@ export async function run(args: string[]): Promise { runMigrations(db); const MAX_REGENERATIONS = 5; - let record = await createPairing(intent, { ttlMs }); + let record = await createPairing(intent); 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 (or "@ ${record.code}" in a group with privacy on).`, REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`, }); @@ -80,7 +70,6 @@ export async function run(args: string[]): Promise { 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, @@ -105,11 +94,10 @@ export async function run(args: string[]): Promise { 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 }); + record = await createPairing(intent); 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.`, diff --git a/src/channels/telegram-pairing.test.ts b/src/channels/telegram-pairing.test.ts index 1404df8..1d66550 100644 --- a/src/channels/telegram-pairing.test.ts +++ b/src/channels/telegram-pairing.test.ts @@ -64,11 +64,10 @@ describe('extractCode', () => { }); describe('createPairing', () => { - it('generates a 4-digit code with TTL', async () => { - const r = await createPairing('main', { ttlMs: 60_000 }); + it('generates a 4-digit code', async () => { + const r = await createPairing('main'); expect(r.code).toMatch(/^\d{4}$/); expect(r.status).toBe('pending'); - expect(Date.parse(r.expiresAt)).toBeGreaterThan(Date.now()); }); it('does not collide with active codes', async () => { @@ -128,12 +127,13 @@ describe('tryConsume', () => { expect(second).toBeNull(); }); - it('cannot consume an expired pairing', async () => { - const r = await createPairing('main', { ttlMs: 1 }); - await new Promise((res) => setTimeout(res, 10)); + it('cannot consume an invalidated pairing', async () => { + const r = await createPairing('main'); + // Invalidate by sending a wrong code + await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false }); const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false }); expect(out).toBeNull(); - expect(getStatus(r.code)).toBe('expired'); + expect(getStatus(r.code)).toBe('invalidated'); }); }); @@ -145,7 +145,7 @@ describe('getStatus', () => { describe('waitForPairing', () => { it('resolves when consumed', async () => { - const r = await createPairing('main', { ttlMs: 5000 }); + const r = await createPairing('main'); const p = waitForPairing(r.code, { pollMs: 50 }); setTimeout(() => { tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' }); @@ -155,17 +155,21 @@ describe('waitForPairing', () => { expect(consumed.consumed?.name).toBe('Group'); }); - it('rejects on expiry', async () => { - const r = await createPairing('main', { ttlMs: 100 }); - await expect(waitForPairing(r.code, { pollMs: 30 })).rejects.toThrow(/expired/); + it('rejects on invalidation', async () => { + const r = await createPairing('main'); + const waiter = waitForPairing(r.code, { pollMs: 30 }); + setTimeout(() => { + tryConsume({ text: '0000', botUsername: 'b', platformId: 'tg:1', isGroup: false }); + }, 60); + await expect(waiter).rejects.toThrow(/invalidated/); }); }); 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'); + const first = await createPairing('main'); + const second = await createPairing('main'); + expect(getStatus(first.code)).toBe('invalidated'); expect(getStatus(second.code)).toBe('pending'); }); @@ -176,18 +180,18 @@ describe('replace-by-default', () => { 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 }); + it('causes waitForPairing on the old code to reject as invalidated', async () => { + const first = await createPairing('main'); 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/); + await createPairing('main'); + await expect(waiter).rejects.toThrow(/invalidated/); }); }); 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 r = await createPairing('main'); const attempts: string[] = []; const waiter = waitForPairing(r.code, { pollMs: 30, @@ -198,11 +202,11 @@ describe('attempt tracking', () => { }, 60); await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/); expect(attempts).toEqual(['9999']); - expect(getStatus(r.code)).toBe('expired'); + expect(getStatus(r.code)).toBe('invalidated'); }); it('a correct code consumes without firing onAttempt', async () => { - const r = await createPairing('main', { ttlMs: 5000 }); + const r = await createPairing('main'); const attempts: string[] = []; const waiter = waitForPairing(r.code, { pollMs: 30, @@ -217,7 +221,7 @@ describe('attempt tracking', () => { }); it('ignores non-code messages and keeps the pairing pending', async () => { - const r = await createPairing('main', { ttlMs: 5000 }); + const r = await createPairing('main'); await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false }); const after = getPairing(r.code); expect(after?.status).toBe('pending'); @@ -225,7 +229,7 @@ describe('attempt tracking', () => { }); it('a second code attempt after invalidation does not match', async () => { - const r = await createPairing('main', { ttlMs: 5000 }); + const r = await createPairing('main'); 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(); diff --git a/src/channels/telegram-pairing.ts b/src/channels/telegram-pairing.ts index c88cb84..2c29cd1 100644 --- a/src/channels/telegram-pairing.ts +++ b/src/channels/telegram-pairing.ts @@ -21,7 +21,7 @@ import { DATA_DIR } from '../config.js'; import { log } from '../log.js'; export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string }; -export type PairingStatus = 'pending' | 'consumed' | 'expired' | 'unknown'; +export type PairingStatus = 'pending' | 'consumed' | 'invalidated' | 'unknown'; export interface ConsumedDetails { platformId: string; @@ -42,7 +42,6 @@ export interface PairingRecord { code: string; intent: PairingIntent; createdAt: string; - expiresAt: string; status: Exclude; consumed?: ConsumedDetails; /** Recent pairing attempts observed while this record was pending. Capped. */ @@ -60,7 +59,7 @@ interface Store { pairings: PairingRecord[]; } -const DEFAULT_TTL_MS = 5 * 60 * 1000; +/** Pairing codes do not expire — they are consumed on match or invalidated by wrong guesses. */ const FILE_NAME = 'telegram-pairings.json'; let storePathOverride: string | null = null; @@ -98,15 +97,11 @@ function writeStore(store: Store): void { fs.renameSync(tmp, p); } -function sweep(store: Store, now: number): boolean { - let changed = false; - for (const r of store.pairings) { - if (r.status === 'pending' && Date.parse(r.expiresAt) <= now) { - r.status = 'expired'; - changed = true; - } - } - return changed; +/** Clean up old consumed/invalidated records (keep last 50). */ +function sweep(store: Store): boolean { + if (store.pairings.length <= 50) return false; + store.pairings = store.pairings.slice(-50); + return true; } function generateCode(active: Set): string { @@ -120,36 +115,29 @@ function generateCode(active: Set): string { throw new Error('Could not allocate a free pairing code (too many active).'); } -export interface CreatePairingOptions { - ttlMs?: number; -} - -export async function createPairing(intent: PairingIntent, opts: CreatePairingOptions = {}): Promise { - const ttl = opts.ttlMs ?? DEFAULT_TTL_MS; +export async function createPairing(intent: PairingIntent): Promise { return withLock(() => { const store = readStore(); - sweep(store, Date.now()); + sweep(store); // 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. + // `invalidated` and exit on their own. for (const r of store.pairings) { if (r.status === 'pending' && intentEquals(r.intent, intent)) { - r.status = 'expired'; + r.status = 'invalidated'; 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 = { code: generateCode(active), intent, - createdAt: now.toISOString(), - expiresAt: new Date(now.getTime() + ttl).toISOString(), + createdAt: new Date().toISOString(), status: 'pending', }; store.pairings.push(record); writeStore(store); - log.info('Pairing created', { code: record.code, intent, expiresAt: record.expiresAt }); + log.info('Pairing created', { code: record.code, intent }); return record; }); } @@ -195,7 +183,7 @@ export async function tryConsume(input: ConsumeInput): Promise { const store = readStore(); const now = Date.now(); - sweep(store, now); + sweep(store); const record = store.pairings.find((r) => r.code === code && r.status === 'pending'); if (!record) { // Miss: record the attempt on every currently-pending record so each @@ -211,9 +199,9 @@ export async function tryConsume(input: ConsumeInput): Promise p.code === code); if (!r) return 'unknown'; return r.status; @@ -250,13 +238,11 @@ export function getStatus(code: string): PairingStatus { export function getPairing(code: string): PairingRecord | null { const store = readStore(); - sweep(store, Date.now()); + sweep(store); return store.pairings.find((p) => p.code === code) ?? null; } export interface WaitForPairingOptions { - /** Total time to wait. Defaults to the pairing's own TTL (read on each tick). */ - timeoutMs?: number; /** Polling interval as a fallback when fs.watch misses an event. */ pollMs?: number; /** Fires once per new attempt recorded against this pairing (misses only). */ @@ -264,16 +250,14 @@ export interface WaitForPairingOptions { } /** - * Resolve when the pairing is consumed; reject when it expires or the timeout - * elapses. Uses fs.watch as the primary signal with a slow poll fallback — - * fs.watch is unreliable across rename-replace on some filesystems. + * Resolve when the pairing is consumed; reject when it is invalidated + * (wrong code guess). Waits indefinitely — codes do not expire. + * Uses fs.watch as the primary signal with a slow poll fallback. */ export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise { const pollMs = opts.pollMs ?? 1000; - const start = Date.now(); const initial = getPairing(code); if (!initial) throw new Error(`Unknown pairing code: ${code}`); - const deadline = start + (opts.timeoutMs ?? Math.max(0, Date.parse(initial.expiresAt) - start)); return new Promise((resolve, reject) => { let watcher: fs.FSWatcher | null = null; @@ -320,16 +304,15 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions = resolve(r); return; } - if (r.status === 'expired' || Date.now() >= deadline) { + if (r.status === 'invalidated') { cleanup(); 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)); + reject(new Error( + `Pairing ${code} invalidated by wrong code${lastMiss ? ` (${lastMiss.candidate})` : ''}` + )); return; } };