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:
@@ -68,7 +68,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
|||||||
|
|
||||||
- **type**: `telegram`
|
- **type**: `telegram`
|
||||||
- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot.
|
- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot.
|
||||||
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@<botname> CODE`. The step waits up to 5 minutes and emits a `PAIR_TELEGRAM` block with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID` once the user echoes the code. The service must be running for this to work (the polling adapter is what observes the code).
|
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `npx tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block, and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@<botname> CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code).
|
||||||
- **supports-threads**: no
|
- **supports-threads**: no
|
||||||
- **typical-use**: Interactive chat — direct messages or small groups
|
- **typical-use**: Interactive chat — direct messages or small groups
|
||||||
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
||||||
|
|||||||
@@ -65,18 +65,28 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
|
|
||||||
const record = await createPairing(intent, { ttlMs });
|
const MAX_REGENERATIONS = 5;
|
||||||
|
let record = await createPairing(intent, { ttlMs });
|
||||||
// Tell the user what to do. The skill prints this as user-facing text.
|
|
||||||
emitStatus('PAIR_TELEGRAM_ISSUED', {
|
emitStatus('PAIR_TELEGRAM_ISSUED', {
|
||||||
CODE: record.code,
|
CODE: record.code,
|
||||||
INTENT: intentToString(intent),
|
INTENT: intentToString(intent),
|
||||||
EXPIRES_AT: record.expiresAt,
|
EXPIRES_AT: record.expiresAt,
|
||||||
INSTRUCTIONS: `Send "@<botname> ${record.code}" from the Telegram chat you want to register.`,
|
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${record.code}" in a group with privacy on).`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
|
||||||
try {
|
try {
|
||||||
const consumed = await waitForPairing(record.code, { timeoutMs: ttlMs });
|
const consumed = await waitForPairing(record.code, {
|
||||||
|
timeoutMs: ttlMs,
|
||||||
|
onAttempt: (a) => {
|
||||||
|
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
|
||||||
|
EXPECTED_CODE: record.code,
|
||||||
|
RECEIVED_CODE: a.candidate,
|
||||||
|
PLATFORM_ID: a.platformId,
|
||||||
|
AT: a.at,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
emitStatus('PAIR_TELEGRAM', {
|
emitStatus('PAIR_TELEGRAM', {
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
CODE: record.code,
|
CODE: record.code,
|
||||||
@@ -85,13 +95,28 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
IS_GROUP: consumed.consumed!.isGroup,
|
IS_GROUP: consumed.consumed!.isGroup,
|
||||||
ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '',
|
ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '',
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
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);
|
||||||
|
if (invalidated && regen < MAX_REGENERATIONS) {
|
||||||
|
record = await createPairing(intent, { ttlMs });
|
||||||
|
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.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
emitStatus('PAIR_TELEGRAM', {
|
emitStatus('PAIR_TELEGRAM', {
|
||||||
STATUS: 'failed',
|
STATUS: 'failed',
|
||||||
CODE: record.code,
|
CODE: record.code,
|
||||||
ERROR: message,
|
ERROR: invalidated ? 'max-regenerations-exceeded' : message,
|
||||||
});
|
});
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createPairing,
|
createPairing,
|
||||||
tryConsume,
|
tryConsume,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
getPairing,
|
||||||
waitForPairing,
|
waitForPairing,
|
||||||
extractCode,
|
extractCode,
|
||||||
extractAddressedText,
|
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', () => {
|
describe('intent passthrough', () => {
|
||||||
it('preserves wire-to and new-agent intents', async () => {
|
it('preserves wire-to and new-agent intents', async () => {
|
||||||
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
|
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export interface ConsumedDetails {
|
|||||||
consumedAt: string;
|
consumedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PairingAttempt {
|
||||||
|
candidate: string;
|
||||||
|
platformId: string;
|
||||||
|
at: string;
|
||||||
|
matched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PairingRecord {
|
export interface PairingRecord {
|
||||||
code: string;
|
code: string;
|
||||||
intent: PairingIntent;
|
intent: PairingIntent;
|
||||||
@@ -37,6 +44,15 @@ export interface PairingRecord {
|
|||||||
expiresAt: 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. */
|
||||||
|
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 {
|
interface Store {
|
||||||
@@ -112,6 +128,15 @@ export async function createPairing(intent: PairingIntent, opts: CreatePairingOp
|
|||||||
return withLock(() => {
|
return withLock(() => {
|
||||||
const store = readStore();
|
const store = readStore();
|
||||||
sweep(store, Date.now());
|
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 active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const record: PairingRecord = {
|
const record: PairingRecord = {
|
||||||
@@ -172,7 +197,28 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
|
|||||||
sweep(store, now);
|
sweep(store, now);
|
||||||
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
|
||||||
|
// 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);
|
writeStore(store);
|
||||||
|
if (recorded) {
|
||||||
|
log.info('Pairing invalidated by wrong attempt', { candidate: code, platformId: input.platformId });
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
record.status = 'consumed';
|
record.status = 'consumed';
|
||||||
@@ -183,6 +229,10 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
|
|||||||
adminUserId: input.adminUserId ?? null,
|
adminUserId: input.adminUserId ?? null,
|
||||||
consumedAt: new Date(now).toISOString(),
|
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);
|
writeStore(store);
|
||||||
log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent });
|
log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent });
|
||||||
return record;
|
return record;
|
||||||
@@ -208,6 +258,8 @@ export interface WaitForPairingOptions {
|
|||||||
timeoutMs?: number;
|
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). */
|
||||||
|
onAttempt?: (attempt: PairingAttempt) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,6 +290,7 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions =
|
|||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let seenAttempts = 0;
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
const r = getPairing(code);
|
const r = getPairing(code);
|
||||||
@@ -246,6 +299,21 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions =
|
|||||||
reject(new Error(`Pairing ${code} disappeared`));
|
reject(new Error(`Pairing ${code} disappeared`));
|
||||||
return;
|
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') {
|
if (r.status === 'consumed') {
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve(r);
|
resolve(r);
|
||||||
@@ -253,7 +321,14 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions =
|
|||||||
}
|
}
|
||||||
if (r.status === 'expired' || Date.now() >= deadline) {
|
if (r.status === 'expired' || Date.now() >= deadline) {
|
||||||
cleanup();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user