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
+5 -17
View File
@@ -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<void> {
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<void> {
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 "@<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.`,
});
@@ -80,7 +70,6 @@ export async function run(args: string[]): Promise<void> {
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<void> {
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.`,
+27 -23
View File
@@ -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();
+25 -42
View File
@@ -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<PairingStatus, 'unknown'>;
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>): string {
@@ -120,36 +115,29 @@ function generateCode(active: Set<string>): 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<PairingRecord> {
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
export async function createPairing(intent: PairingIntent): Promise<PairingRecord> {
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<PairingRecord | n
return withLock(() => {
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<PairingRecord | n
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
// immediately — pair-telegram observes the `invalidated` signal and
// auto-issues a fresh code (up to a retry cap).
r.status = 'expired';
r.status = 'invalidated';
recorded = true;
}
writeStore(store);
@@ -242,7 +230,7 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
export function getStatus(code: string): PairingStatus {
const store = readStore();
sweep(store, Date.now());
sweep(store);
const r = store.pairings.find((p) => 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<PairingRecord> {
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<PairingRecord>((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;
}
};