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:
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user