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) <noreply@anthropic.com>
This commit is contained in:
Koshkoshinsk
2026-04-13 12:27:09 +00:00
parent 2454444f2e
commit 65afcdc946
4 changed files with 196 additions and 24 deletions

View File

@@ -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' });

View File

@@ -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<PairingStatus, 'unknown'>;
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<PairingRecord | n
sweep(store, now);
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
// 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<PairingRecord | n
adminUserId: input.adminUserId ?? null,
consumedAt: new Date(now).toISOString(),
};
record.attempts = [
...(record.attempts ?? []),
{ candidate: code, platformId: input.platformId, at: new Date(now).toISOString(), matched: true },
].slice(-MAX_ATTEMPTS_PER_RECORD);
writeStore(store);
log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent });
return record;
@@ -208,6 +258,8 @@ export interface WaitForPairingOptions {
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). */
onAttempt?: (attempt: PairingAttempt) => 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;
}
};