feat(telegram): self-contained pairing for chat ownership verification
BotFather issues bot tokens with no user binding, so anyone who guesses the bot's username can DM it and get registered as a channel. Pairing closes that gap: setup issues a one-time 4-digit code, the operator echoes it back from the chat they want to register, and the inbound interceptor binds admin_user_id before the message reaches the router. - src/channels/telegram-pairing.ts: JSON-backed store with createPairing, tryConsume, getStatus, waitForPairing (fs.watch + poll fallback) - src/channels/telegram.ts: wraps bridge.setup with an onInbound interceptor that consumes pairing codes and upserts messaging_groups - setup/pair-telegram.ts: CLI step issues a code and waits up to 5 min for the operator to echo it back, emitting PLATFORM_ID/IS_GROUP/ADMIN_USER_ID - Skill docs: /setup reorders mounts -> service -> wire (pairing needs a live polling adapter); /manage-channels and /add-telegram-v2 use pairing instead of asking the user to discover chat IDs All other channels still bind admin via install-time identity (OAuth/QR/token); pairing is Telegram-only. The bridge, router, and other adapters are untouched.
This commit is contained in:
@@ -14,6 +14,7 @@ const STEPS: Record<
|
||||
container: () => import('./container.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
register: () => import('./register.js'),
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
mounts: () => import('./mounts.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
|
||||
97
setup/pair-telegram.ts
Normal file
97
setup/pair-telegram.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Step: pair-telegram — issue a one-time pairing code and wait for the
|
||||
* operator to send `@botname CODE` from the chat they want to register.
|
||||
*
|
||||
* On success, prints platformId / isGroup / adminUserId / intent. The caller
|
||||
* (skill) then runs `setup --step register` with those values.
|
||||
*
|
||||
* The service must already be running so the telegram adapter is polling.
|
||||
*/
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
createPairing,
|
||||
waitForPairing,
|
||||
type PairingIntent,
|
||||
} from '../src/channels/telegram-pairing.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
interface Args {
|
||||
intent: PairingIntent;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Args {
|
||||
let intent: PairingIntent = 'main';
|
||||
let ttlMs = 5 * 60 * 1000;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--intent': {
|
||||
const raw = args[++i] || 'main';
|
||||
if (raw === 'main') {
|
||||
intent = 'main';
|
||||
} else if (raw.startsWith('wire-to:')) {
|
||||
intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) };
|
||||
} else if (raw.startsWith('new-agent:')) {
|
||||
intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) };
|
||||
} else {
|
||||
throw new Error(`Unknown intent: ${raw}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '--ttl-ms':
|
||||
ttlMs = parseInt(args[++i] || '300000', 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { intent, ttlMs };
|
||||
}
|
||||
|
||||
function intentToString(intent: PairingIntent): string {
|
||||
if (intent === 'main') return 'main';
|
||||
return `${intent.kind}:${intent.folder}`;
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { intent, ttlMs } = 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
|
||||
// (running in the live service) needs it. Touch it here so a fresh install
|
||||
// doesn't blow up on the first match.
|
||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(db);
|
||||
|
||||
const record = await createPairing(intent, { ttlMs });
|
||||
|
||||
// Tell the user what to do. The skill prints this as user-facing text.
|
||||
emitStatus('PAIR_TELEGRAM_ISSUED', {
|
||||
CODE: record.code,
|
||||
INTENT: intentToString(intent),
|
||||
EXPIRES_AT: record.expiresAt,
|
||||
INSTRUCTIONS: `Send "@<botname> ${record.code}" from the Telegram chat you want to register.`,
|
||||
});
|
||||
|
||||
try {
|
||||
const consumed = await waitForPairing(record.code, { timeoutMs: ttlMs });
|
||||
emitStatus('PAIR_TELEGRAM', {
|
||||
STATUS: 'success',
|
||||
CODE: record.code,
|
||||
INTENT: intentToString(consumed.intent),
|
||||
PLATFORM_ID: consumed.consumed!.platformId,
|
||||
IS_GROUP: consumed.consumed!.isGroup,
|
||||
ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '',
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
emitStatus('PAIR_TELEGRAM', {
|
||||
STATUS: 'failed',
|
||||
CODE: record.code,
|
||||
ERROR: message,
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user