mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
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:
+5
-17
@@ -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.`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user