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) <noreply@anthropic.com>
This commit is contained in:
gabi-simons
2026-04-16 10:54:48 +00:00
parent 39d2af9981
commit a1a324097e
3 changed files with 57 additions and 82 deletions

View File

@@ -21,14 +21,8 @@ import {
} from '../src/channels/telegram-pairing.js'; } from '../src/channels/telegram-pairing.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
interface Args { function parseArgs(args: string[]): PairingIntent {
intent: PairingIntent;
ttlMs: number;
}
function parseArgs(args: string[]): Args {
let intent: PairingIntent = 'main'; let intent: PairingIntent = 'main';
let ttlMs = 5 * 60 * 1000;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
switch (args[i]) { switch (args[i]) {
case '--intent': { case '--intent': {
@@ -44,12 +38,9 @@ function parseArgs(args: string[]): Args {
} }
break; break;
} }
case '--ttl-ms':
ttlMs = parseInt(args[++i] || '300000', 10);
break;
} }
} }
return { intent, ttlMs }; return intent;
} }
function intentToString(intent: PairingIntent): string { function intentToString(intent: PairingIntent): string {
@@ -58,7 +49,7 @@ function intentToString(intent: PairingIntent): string {
} }
export async function run(args: string[]): Promise<void> { export async function run(args: string[]): Promise<void> {
const { intent, ttlMs } = parseArgs(args); const intent = parseArgs(args);
// Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly // Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly
// required for the pairing primitive itself, but the inbound interceptor // required for the pairing primitive itself, but the inbound interceptor
@@ -68,11 +59,10 @@ export async function run(args: string[]): Promise<void> {
runMigrations(db); runMigrations(db);
const MAX_REGENERATIONS = 5; const MAX_REGENERATIONS = 5;
let record = await createPairing(intent, { ttlMs }); let record = await createPairing(intent);
emitStatus('PAIR_TELEGRAM_ISSUED', { emitStatus('PAIR_TELEGRAM_ISSUED', {
CODE: record.code, CODE: record.code,
INTENT: intentToString(intent), INTENT: intentToString(intent),
EXPIRES_AT: record.expiresAt,
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${record.code}" in a group with privacy on).`, INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${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.`, 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<void> {
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
try { try {
const consumed = await waitForPairing(record.code, { const consumed = await waitForPairing(record.code, {
timeoutMs: ttlMs,
onAttempt: (a) => { onAttempt: (a) => {
emitStatus('PAIR_TELEGRAM_ATTEMPT', { emitStatus('PAIR_TELEGRAM_ATTEMPT', {
EXPECTED_CODE: record.code, EXPECTED_CODE: record.code,
@@ -105,11 +94,10 @@ export async function run(args: string[]): Promise<void> {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
const invalidated = /invalidated by wrong code/.test(message); const invalidated = /invalidated by wrong code/.test(message);
if (invalidated && regen < MAX_REGENERATIONS) { if (invalidated && regen < MAX_REGENERATIONS) {
record = await createPairing(intent, { ttlMs }); record = await createPairing(intent);
emitStatus('PAIR_TELEGRAM_NEW_CODE', { emitStatus('PAIR_TELEGRAM_NEW_CODE', {
CODE: record.code, CODE: record.code,
INTENT: intentToString(intent), INTENT: intentToString(intent),
EXPIRES_AT: record.expiresAt,
REASON: 'previous code invalidated by wrong attempt', REASON: 'previous code invalidated by wrong attempt',
REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1, REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1,
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`, INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`,

View File

@@ -64,11 +64,10 @@ describe('extractCode', () => {
}); });
describe('createPairing', () => { describe('createPairing', () => {
it('generates a 4-digit code with TTL', async () => { it('generates a 4-digit code', async () => {
const r = await createPairing('main', { ttlMs: 60_000 }); const r = await createPairing('main');
expect(r.code).toMatch(/^\d{4}$/); expect(r.code).toMatch(/^\d{4}$/);
expect(r.status).toBe('pending'); expect(r.status).toBe('pending');
expect(Date.parse(r.expiresAt)).toBeGreaterThan(Date.now());
}); });
it('does not collide with active codes', async () => { it('does not collide with active codes', async () => {
@@ -128,12 +127,13 @@ describe('tryConsume', () => {
expect(second).toBeNull(); expect(second).toBeNull();
}); });
it('cannot consume an expired pairing', async () => { it('cannot consume an invalidated pairing', async () => {
const r = await createPairing('main', { ttlMs: 1 }); const r = await createPairing('main');
await new Promise((res) => setTimeout(res, 10)); // 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 }); const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
expect(out).toBeNull(); expect(out).toBeNull();
expect(getStatus(r.code)).toBe('expired'); expect(getStatus(r.code)).toBe('invalidated');
}); });
}); });
@@ -145,7 +145,7 @@ describe('getStatus', () => {
describe('waitForPairing', () => { describe('waitForPairing', () => {
it('resolves when consumed', async () => { it('resolves when consumed', async () => {
const r = await createPairing('main', { ttlMs: 5000 }); const r = await createPairing('main');
const p = waitForPairing(r.code, { pollMs: 50 }); const p = waitForPairing(r.code, { pollMs: 50 });
setTimeout(() => { setTimeout(() => {
tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' }); 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'); expect(consumed.consumed?.name).toBe('Group');
}); });
it('rejects on expiry', async () => { it('rejects on invalidation', async () => {
const r = await createPairing('main', { ttlMs: 100 }); const r = await createPairing('main');
await expect(waitForPairing(r.code, { pollMs: 30 })).rejects.toThrow(/expired/); 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', () => { describe('replace-by-default', () => {
it('supersedes an existing pending pairing with the same intent', async () => { it('supersedes an existing pending pairing with the same intent', async () => {
const first = await createPairing('main', { ttlMs: 60_000 }); const first = await createPairing('main');
const second = await createPairing('main', { ttlMs: 60_000 }); const second = await createPairing('main');
expect(getStatus(first.code)).toBe('expired'); expect(getStatus(first.code)).toBe('invalidated');
expect(getStatus(second.code)).toBe('pending'); expect(getStatus(second.code)).toBe('pending');
}); });
@@ -176,18 +180,18 @@ describe('replace-by-default', () => {
expect(getStatus(b.code)).toBe('pending'); expect(getStatus(b.code)).toBe('pending');
}); });
it('causes waitForPairing on the old code to reject as expired', async () => { it('causes waitForPairing on the old code to reject as invalidated', async () => {
const first = await createPairing('main', { ttlMs: 60_000 }); const first = await createPairing('main');
const waiter = waitForPairing(first.code, { pollMs: 30 }); const waiter = waitForPairing(first.code, { pollMs: 30 });
await new Promise((r) => setTimeout(r, 50)); await new Promise((r) => setTimeout(r, 50));
await createPairing('main', { ttlMs: 60_000 }); await createPairing('main');
await expect(waiter).rejects.toThrow(/expired/); await expect(waiter).rejects.toThrow(/invalidated/);
}); });
}); });
describe('attempt tracking', () => { describe('attempt tracking', () => {
it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => { 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 attempts: string[] = [];
const waiter = waitForPairing(r.code, { const waiter = waitForPairing(r.code, {
pollMs: 30, pollMs: 30,
@@ -198,11 +202,11 @@ describe('attempt tracking', () => {
}, 60); }, 60);
await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/); await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/);
expect(attempts).toEqual(['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 () => { 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 attempts: string[] = [];
const waiter = waitForPairing(r.code, { const waiter = waitForPairing(r.code, {
pollMs: 30, pollMs: 30,
@@ -217,7 +221,7 @@ describe('attempt tracking', () => {
}); });
it('ignores non-code messages and keeps the pairing pending', async () => { 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 }); await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false });
const after = getPairing(r.code); const after = getPairing(r.code);
expect(after?.status).toBe('pending'); expect(after?.status).toBe('pending');
@@ -225,7 +229,7 @@ describe('attempt tracking', () => {
}); });
it('a second code attempt after invalidation does not match', async () => { 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 }); await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false }); const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false });
expect(retry).toBeNull(); expect(retry).toBeNull();

View File

@@ -21,7 +21,7 @@ import { DATA_DIR } from '../config.js';
import { log } from '../log.js'; import { log } from '../log.js';
export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string }; 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 { export interface ConsumedDetails {
platformId: string; platformId: string;
@@ -42,7 +42,6 @@ export interface PairingRecord {
code: string; code: string;
intent: PairingIntent; intent: PairingIntent;
createdAt: string; createdAt: string;
expiresAt: string;
status: Exclude<PairingStatus, 'unknown'>; status: Exclude<PairingStatus, 'unknown'>;
consumed?: ConsumedDetails; consumed?: ConsumedDetails;
/** Recent pairing attempts observed while this record was pending. Capped. */ /** Recent pairing attempts observed while this record was pending. Capped. */
@@ -60,7 +59,7 @@ interface Store {
pairings: PairingRecord[]; 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'; const FILE_NAME = 'telegram-pairings.json';
let storePathOverride: string | null = null; let storePathOverride: string | null = null;
@@ -98,15 +97,11 @@ function writeStore(store: Store): void {
fs.renameSync(tmp, p); fs.renameSync(tmp, p);
} }
function sweep(store: Store, now: number): boolean { /** Clean up old consumed/invalidated records (keep last 50). */
let changed = false; function sweep(store: Store): boolean {
for (const r of store.pairings) { if (store.pairings.length <= 50) return false;
if (r.status === 'pending' && Date.parse(r.expiresAt) <= now) { store.pairings = store.pairings.slice(-50);
r.status = 'expired'; return true;
changed = true;
}
}
return changed;
} }
function generateCode(active: Set<string>): string { function generateCode(active: Set<string>): string {
@@ -120,36 +115,29 @@ function generateCode(active: Set<string>): string {
throw new Error('Could not allocate a free pairing code (too many active).'); throw new Error('Could not allocate a free pairing code (too many active).');
} }
export interface CreatePairingOptions { export async function createPairing(intent: PairingIntent): Promise<PairingRecord> {
ttlMs?: number;
}
export async function createPairing(intent: PairingIntent, opts: CreatePairingOptions = {}): Promise<PairingRecord> {
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
return withLock(() => { return withLock(() => {
const store = readStore(); const store = readStore();
sweep(store, Date.now()); sweep(store);
// Replace-by-default: a new pairing for an intent supersedes any existing // Replace-by-default: a new pairing for an intent supersedes any existing
// pending pairing for the same intent. Old waitForPairing calls observe // 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) { for (const r of store.pairings) {
if (r.status === 'pending' && intentEquals(r.intent, intent)) { 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 }); 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 active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
const now = new Date();
const record: PairingRecord = { const record: PairingRecord = {
code: generateCode(active), code: generateCode(active),
intent, intent,
createdAt: now.toISOString(), createdAt: new Date().toISOString(),
expiresAt: new Date(now.getTime() + ttl).toISOString(),
status: 'pending', status: 'pending',
}; };
store.pairings.push(record); store.pairings.push(record);
writeStore(store); writeStore(store);
log.info('Pairing created', { code: record.code, intent, expiresAt: record.expiresAt }); log.info('Pairing created', { code: record.code, intent });
return record; return record;
}); });
} }
@@ -195,7 +183,7 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
return withLock(() => { return withLock(() => {
const store = readStore(); const store = readStore();
const now = Date.now(); const now = Date.now();
sweep(store, now); sweep(store);
const record = store.pairings.find((r) => r.code === code && r.status === 'pending'); const record = store.pairings.find((r) => r.code === code && r.status === 'pending');
if (!record) { if (!record) {
// Miss: record the attempt on every currently-pending record so each // Miss: record the attempt on every currently-pending record so each
@@ -211,9 +199,9 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
if (r.status !== 'pending') continue; if (r.status !== 'pending') continue;
r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD); r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD);
// One attempt per code. A wrong guess invalidates the pairing // One attempt per code. A wrong guess invalidates the pairing
// immediately — pair-telegram observes the `expired` signal and // immediately — pair-telegram observes the `invalidated` signal and
// auto-issues a fresh code (up to a retry cap). // auto-issues a fresh code (up to a retry cap).
r.status = 'expired'; r.status = 'invalidated';
recorded = true; recorded = true;
} }
writeStore(store); writeStore(store);
@@ -242,7 +230,7 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
export function getStatus(code: string): PairingStatus { export function getStatus(code: string): PairingStatus {
const store = readStore(); const store = readStore();
sweep(store, Date.now()); sweep(store);
const r = store.pairings.find((p) => p.code === code); const r = store.pairings.find((p) => p.code === code);
if (!r) return 'unknown'; if (!r) return 'unknown';
return r.status; return r.status;
@@ -250,13 +238,11 @@ export function getStatus(code: string): PairingStatus {
export function getPairing(code: string): PairingRecord | null { export function getPairing(code: string): PairingRecord | null {
const store = readStore(); const store = readStore();
sweep(store, Date.now()); sweep(store);
return store.pairings.find((p) => p.code === code) ?? null; return store.pairings.find((p) => p.code === code) ?? null;
} }
export interface WaitForPairingOptions { 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. */ /** Polling interval as a fallback when fs.watch misses an event. */
pollMs?: number; pollMs?: number;
/** Fires once per new attempt recorded against this pairing (misses only). */ /** 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 * Resolve when the pairing is consumed; reject when it is invalidated
* elapses. Uses fs.watch as the primary signal with a slow poll fallback — * (wrong code guess). Waits indefinitely — codes do not expire.
* fs.watch is unreliable across rename-replace on some filesystems. * Uses fs.watch as the primary signal with a slow poll fallback.
*/ */
export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> { export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> {
const pollMs = opts.pollMs ?? 1000; const pollMs = opts.pollMs ?? 1000;
const start = Date.now();
const initial = getPairing(code); const initial = getPairing(code);
if (!initial) throw new Error(`Unknown pairing code: ${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<PairingRecord>((resolve, reject) => { return new Promise<PairingRecord>((resolve, reject) => {
let watcher: fs.FSWatcher | null = null; let watcher: fs.FSWatcher | null = null;
@@ -320,16 +304,15 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions =
resolve(r); resolve(r);
return; return;
} }
if (r.status === 'expired' || Date.now() >= deadline) { if (r.status === 'invalidated') {
cleanup(); cleanup();
const lastMiss = r.attempts const lastMiss = r.attempts
?.slice() ?.slice()
.reverse() .reverse()
.find((a) => !a.matched); .find((a) => !a.matched);
const reason = lastMiss reject(new Error(
? `Pairing ${code} invalidated by wrong code (${lastMiss.candidate})` `Pairing ${code} invalidated by wrong code${lastMiss ? ` (${lastMiss.candidate})` : ''}`
: `Pairing ${code} expired`; ));
reject(new Error(reason));
return; return;
} }
}; };