polygram 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.js ADDED
@@ -0,0 +1,1604 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Telegram Bridge for Claude Code — Persistent Sessions
4
+ *
5
+ * Each chat gets a persistent claude process (stream-json multi-turn).
6
+ * Process stays warm: no cold start, full prompt caching.
7
+ *
8
+ * Architecture:
9
+ * Telegram (grammy long-poll) → bridge receives message
10
+ * → looks up per-chat config (model, effort, agent, cwd)
11
+ * → sends to persistent claude process via stdin (stream-json)
12
+ * → reads response from stdout (stream-json)
13
+ * → sends reply to Telegram
14
+ * → writes every in/out message to bridge.db (Phase 1: parallel write)
15
+ *
16
+ * Chat commands: /model <model>, /effort <level>, /config
17
+ */
18
+
19
+ const { Bot } = require('grammy');
20
+ const { spawn } = require('child_process');
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const dbClient = require('./lib/db');
24
+ const { migrateJsonToDb, getClaudeSessionId } = require('./lib/sessions');
25
+ const { buildPrompt } = require('./lib/prompt');
26
+ const { filterAttachments, MAX_FILE_BYTES } = require('./lib/attachments');
27
+ const { ProcessManager } = require('./lib/process-manager');
28
+ const { createSender } = require('./lib/telegram');
29
+ const { drainQueuesForChat: drainQueuesForChatImpl } = require('./lib/queue-utils');
30
+ const { sweepInbox } = require('./lib/inbox');
31
+ const { parseBotArg, parseDbArg, filterConfigToBot } = require('./lib/config-scope');
32
+ const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/pairings');
33
+ const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/voice');
34
+ const { createStreamer } = require('./lib/stream-reply');
35
+ const {
36
+ createStore: createApprovalsStore,
37
+ matchesAnyPattern: matchesApprovalPattern,
38
+ tokensEqual: approvalTokensEqual,
39
+ DEFAULT_TIMEOUT_MS: APPROVAL_DEFAULT_TIMEOUT_MS,
40
+ } = require('./lib/approvals');
41
+ const ipcServer = require('./lib/ipc-server');
42
+
43
+ // ─── Config ──────────────────────────────────────────────────────────
44
+
45
+ const CONFIG_PATH = path.join(__dirname, 'config.json');
46
+ const SESSIONS_JSON_PATH = path.join(__dirname, 'sessions.json'); // legacy, imported once on boot
47
+ const DB_DIR = __dirname;
48
+ // DB_PATH is resolved in main() from --db or <bot>.db default.
49
+ let DB_PATH = null;
50
+ // Paths that vary per deployment are env-configurable. Defaults preserve
51
+ // the author's local layout for back-compat but the repo itself is portable.
52
+ const STICKERS_PATH = process.env.POLYGRAM_STICKERS
53
+ || path.join(process.env.HOME || '', 'polygram-stickers.json');
54
+ const INBOX_DIR = process.env.POLYGRAM_INBOX || path.join(__dirname, 'inbox');
55
+ const CLAUDE_BIN = process.env.POLYGRAM_CLAUDE_BIN
56
+ || path.join(process.env.HOME || '', '.npm-global/bin/claude');
57
+ const CHILD_HOME = process.env.POLYGRAM_CHILD_HOME || process.env.HOME || '';
58
+ const TG_MAX_LEN = 4096;
59
+ const DEFAULT_MAX_WARM_PROCS = 10;
60
+
61
+ let stickerMap = {}; // name → file_id
62
+ let emojiToSticker = {}; // emoji → file_id
63
+
64
+ let config;
65
+ let db;
66
+ let tg; // unified sender, created after db opens
67
+ let pairings; // pairings store, created after db opens
68
+ let approvals; // approvals store, created after db opens
69
+ let approvalWaiters = new Map(); // approval_id -> { resolve, reject, timer }
70
+ let approvalSweepTimer = null;
71
+ let ipcCloser = null;
72
+ // BOT_NAME and bot are set once in main() after filterConfigToBot. Because
73
+ // this process serves exactly one bot (the --bot flag is required and
74
+ // single-valued), we keep them as plain module-level variables — not a map.
75
+ let BOT_NAME = null; // string, frozen after boot
76
+ let bot = null; // grammy Bot for BOT_NAME
77
+ let streamers = new Map(); // sessionKey -> active Streamer (while turn is in flight)
78
+
79
+ // Allowlist of env var names passed through to spawned Claude processes.
80
+ // Anything not listed here is dropped to prevent leaked secrets/ssh agents
81
+ // from being read by a prompt-injected child. Prefixes match any var whose
82
+ // name starts with that string.
83
+ const CHILD_ENV_ALLOWLIST = new Set([
84
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'COLORTERM',
85
+ 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'LANG', 'PWD', 'SHLVL',
86
+ ]);
87
+ const CHILD_ENV_PREFIXES = ['LC_', 'NODE_', 'CLAUDE_', 'ANTHROPIC_'];
88
+
89
+ function filterEnv(src) {
90
+ const out = {};
91
+ for (const [k, v] of Object.entries(src)) {
92
+ if (CHILD_ENV_ALLOWLIST.has(k) || CHILD_ENV_PREFIXES.some((p) => k.startsWith(p))) {
93
+ out[k] = v;
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function loadConfig() {
100
+ config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
101
+ }
102
+
103
+ function saveConfig() {
104
+ // Atomic write: crash between write and rename leaves the old config
105
+ // intact. Exclude the `bot` convenience alias from serialisation — it's
106
+ // a runtime pointer into config.bots[BOT_NAME], not persistent state.
107
+ const tmp = `${CONFIG_PATH}.tmp.${process.pid}`;
108
+ const { bot: _bot, ...serialisable } = config;
109
+ fs.writeFileSync(tmp, JSON.stringify(serialisable, null, 2));
110
+ fs.renameSync(tmp, CONFIG_PATH);
111
+ }
112
+
113
+ function loadStickers() {
114
+ try {
115
+ const data = JSON.parse(fs.readFileSync(STICKERS_PATH, 'utf8'));
116
+ for (const [name, s] of Object.entries(data.stickers || {})) {
117
+ stickerMap[name] = s.file_id;
118
+ if (s.emoji) emojiToSticker[s.emoji] = s.file_id;
119
+ }
120
+ console.log(`Stickers: ${Object.keys(stickerMap).join(', ')}`);
121
+ } catch { console.log('No sticker map found'); }
122
+ }
123
+
124
+ // Quick shape check before we start hashing/DB-writing on an update.
125
+ // Telegram updates are user-controlled; a hostile or malformed payload
126
+ // without chat.id / message_id would throw deep in recordInbound.
127
+ function isWellFormedMessage(msg) {
128
+ return !!(msg
129
+ && msg.chat
130
+ && (typeof msg.chat.id === 'number' || typeof msg.chat.id === 'bigint')
131
+ && typeof msg.message_id === 'number');
132
+ }
133
+
134
+ // ─── Session key — moved to lib/session-key.js so tests can import it. ─
135
+ const { getSessionKey, getChatIdFromKey } = require('./lib/session-key');
136
+
137
+ function getTopicName(chatConfig, threadId) {
138
+ if (!threadId) return null;
139
+ return chatConfig.topics?.[threadId] || threadId;
140
+ }
141
+
142
+ function getSessionLabel(chatConfig, threadId) {
143
+ const topic = getTopicName(chatConfig, threadId);
144
+ return topic ? `${chatConfig.name}/${topic}` : chatConfig.name;
145
+ }
146
+
147
+ // ─── Session context ─────────────────────────────────────────────────
148
+
149
+ async function readSessionContext(sessionKey, cwd) {
150
+ const sessionFile = path.join(cwd, 'sessions', `${sessionKey}.md`);
151
+ // Async read: sessions dir may live on iCloud / slow FS where sync reads
152
+ // stall the event loop and starve grammy's polling.
153
+ try {
154
+ const data = await fs.promises.readFile(sessionFile, 'utf8');
155
+ return data.trim();
156
+ } catch { return ''; }
157
+ }
158
+
159
+ // ─── DB writes (Phase 1 — best-effort, never throws) ────────────────
160
+
161
+ function dbWrite(fn, context) {
162
+ if (!db) return;
163
+ try { fn(); } catch (err) {
164
+ console.error(`[db] ${context} failed: ${err.message}`);
165
+ }
166
+ }
167
+
168
+ function recordInbound(msg) {
169
+ const chatId = msg.chat.id.toString();
170
+ const threadId = msg.message_thread_id?.toString() || null;
171
+ const user = msg.from?.first_name || msg.from?.username || null;
172
+ const attachments = extractAttachments(msg);
173
+ const chatConfig = config.chats[chatId];
174
+
175
+ dbWrite(() => db.insertMessage({
176
+ chat_id: chatId,
177
+ thread_id: threadId,
178
+ msg_id: msg.message_id,
179
+ user,
180
+ user_id: msg.from?.id || null,
181
+ text: msg.text || msg.caption || '',
182
+ reply_to_id: msg.reply_to_message?.message_id || null,
183
+ direction: 'in',
184
+ source: 'bridge',
185
+ bot_name: BOT_NAME,
186
+ attachments_json: attachments.length ? JSON.stringify(attachments) : null,
187
+ model: chatConfig?.model || null,
188
+ effort: chatConfig?.effort || null,
189
+ ts: (msg.date || Math.floor(Date.now() / 1000)) * 1000,
190
+ }), `insert inbound ${chatId}/${msg.message_id}`);
191
+ }
192
+
193
+
194
+ // ─── Attachment download ────────────────────────────────────────────
195
+
196
+ function sanitizeFilename(name) {
197
+ if (!name) return 'file';
198
+ return name.replace(/[\/\\:\0]/g, '_').slice(0, 120);
199
+ }
200
+
201
+ function extractAttachments(msg) {
202
+ const items = [];
203
+ if (msg.document) {
204
+ const d = msg.document;
205
+ items.push({
206
+ file_id: d.file_id,
207
+ file_unique_id: d.file_unique_id,
208
+ name: d.file_name || `document-${msg.message_id}`,
209
+ mime_type: d.mime_type || 'application/octet-stream',
210
+ size: d.file_size || 0,
211
+ kind: 'document',
212
+ });
213
+ }
214
+ if (msg.photo && msg.photo.length > 0) {
215
+ const largest = msg.photo[msg.photo.length - 1];
216
+ items.push({
217
+ file_id: largest.file_id,
218
+ file_unique_id: largest.file_unique_id,
219
+ name: `photo-${msg.message_id}.jpg`,
220
+ mime_type: 'image/jpeg',
221
+ size: largest.file_size || 0,
222
+ kind: 'photo',
223
+ });
224
+ }
225
+ if (msg.voice) {
226
+ items.push({
227
+ file_id: msg.voice.file_id,
228
+ file_unique_id: msg.voice.file_unique_id,
229
+ name: `voice-${msg.message_id}.ogg`,
230
+ mime_type: msg.voice.mime_type || 'audio/ogg',
231
+ size: msg.voice.file_size || 0,
232
+ kind: 'voice',
233
+ });
234
+ }
235
+ if (msg.audio) {
236
+ const a = msg.audio;
237
+ items.push({
238
+ file_id: a.file_id,
239
+ file_unique_id: a.file_unique_id,
240
+ name: a.file_name || `audio-${msg.message_id}.mp3`,
241
+ mime_type: a.mime_type || 'audio/mpeg',
242
+ size: a.file_size || 0,
243
+ kind: 'audio',
244
+ });
245
+ }
246
+ if (msg.video) {
247
+ const v = msg.video;
248
+ items.push({
249
+ file_id: v.file_id,
250
+ file_unique_id: v.file_unique_id,
251
+ name: v.file_name || `video-${msg.message_id}.mp4`,
252
+ mime_type: v.mime_type || 'video/mp4',
253
+ size: v.file_size || 0,
254
+ kind: 'video',
255
+ });
256
+ }
257
+ return items;
258
+ }
259
+
260
+ async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, botApi, threadId }) {
261
+ const voiceCfg = config.bot?.voice || config.voice;
262
+ if (!voiceCfg?.enabled) return;
263
+ const provider = voiceCfg.provider || 'openai';
264
+ const providerCfg = voiceCfg[provider] || {};
265
+ const targets = downloaded.filter((a) => isVoiceAttachment(a) && a.path);
266
+ if (!targets.length) return;
267
+
268
+ // Acknowledge receipt with a reaction so the user knows we heard them.
269
+ // Cheap, robust (no state), and survives transcription failure.
270
+ const ack = voiceCfg.ackReaction || '👂';
271
+ if (ack && botApi) {
272
+ tg(botApi, 'setMessageReaction', {
273
+ chat_id: chatId, message_id: msgId,
274
+ reaction: [{ type: 'emoji', emoji: ack }],
275
+ }, { source: 'voice-ack', botName: BOT_NAME }).catch((err) => {
276
+ console.error(`[${label}] voice ack reaction failed: ${err.message}`);
277
+ });
278
+ }
279
+
280
+ await Promise.all(targets.map(async (a) => {
281
+ try {
282
+ const opts = {
283
+ provider,
284
+ ...providerCfg,
285
+ language: voiceCfg.language || 'auto',
286
+ maxDurationSec: voiceCfg.maxDurationSec,
287
+ maxDurationBytesPerSec: voiceCfg.maxDurationBytesPerSec,
288
+ };
289
+ const r = await transcribeVoice(a.path, opts);
290
+ a.transcription = r;
291
+ console.log(`[${label}] transcribed ${a.kind} (${r.duration_sec?.toFixed?.(1) || '?'}s, ${r.text.length} chars)`);
292
+ dbWrite(() => db.logEvent('voice-transcribed', {
293
+ chat_id: chatId, msg_id: msgId,
294
+ provider: r.provider, language: r.language,
295
+ duration_sec: r.duration_sec, chars: r.text.length,
296
+ cost_usd: r.cost_usd,
297
+ }), 'log voice-transcribed');
298
+ } catch (err) {
299
+ console.error(`[${label}] transcribe failed for ${a.name}: ${err.message}`);
300
+ dbWrite(() => db.logEvent('voice-transcribe-failed', {
301
+ chat_id: chatId, msg_id: msgId, name: a.name, error: err.message,
302
+ }), 'log voice-transcribe-failed');
303
+ }
304
+ }));
305
+
306
+ // Persist transcription into the inbound row so FTS search finds it.
307
+ // Combine all successful transcriptions into `text` and mirror the
308
+ // transcription data back into attachments_json.
309
+ const successful = targets.filter((a) => a.transcription?.text);
310
+ if (!successful.length) return;
311
+ const combinedText = successful.map((a) => a.transcription.text).join(' ').trim();
312
+ const attJson = JSON.stringify(downloaded.map((a) => ({
313
+ kind: a.kind, name: a.name, mime_type: a.mime_type, size: a.size,
314
+ path: a.path, file_unique_id: a.file_unique_id,
315
+ transcription: a.transcription || null,
316
+ })));
317
+ dbWrite(() => db.setMessageText({
318
+ chat_id: chatId, msg_id: msgId,
319
+ text: combinedText, attachments_json: attJson,
320
+ }), 'persist voice transcription');
321
+ }
322
+
323
+ async function downloadAttachments(bot, token, chatId, msg, attachments) {
324
+ if (!attachments.length) return [];
325
+ const chatDir = path.join(INBOX_DIR, String(chatId));
326
+ fs.mkdirSync(chatDir, { recursive: true });
327
+
328
+ const results = [];
329
+ for (const att of attachments) {
330
+ try {
331
+ const fileInfo = await bot.api.getFile(att.file_id);
332
+ if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
333
+ const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
334
+ const res = await fetch(url);
335
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
336
+ // Defense in depth: re-check size at download time. Telegram can
337
+ // omit file_size from the Message, or its value may not match what
338
+ // the CDN actually serves. Trust Content-Length and fall back to
339
+ // buffering with a ceiling.
340
+ const cl = parseInt(res.headers.get('content-length') || '0', 10);
341
+ if (cl > MAX_FILE_BYTES) {
342
+ throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
343
+ }
344
+ const buf = Buffer.from(await res.arrayBuffer());
345
+ if (buf.length > MAX_FILE_BYTES) {
346
+ throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
347
+ }
348
+ const safeName = sanitizeFilename(att.name);
349
+ // Embed file_unique_id so two attachments with the same msg_id+name
350
+ // (album, resend) can't silently overwrite each other. Telegram
351
+ // guarantees file_unique_id is stable and globally unique per file.
352
+ const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
353
+ const localName = `${msg.message_id}${uniq}-${safeName}`;
354
+ const localPath = path.join(chatDir, localName);
355
+ // Atomic write: create a temp with the unique PID+timestamp suffix,
356
+ // fill it, then rename to the canonical name. A crash mid-write leaves
357
+ // a `.tmp.*` file (swept later) rather than a truncated canonical file
358
+ // that the EEXIST dedup branch would happily serve on next request.
359
+ if (fs.existsSync(localPath)) {
360
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
361
+ } else {
362
+ const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
363
+ try {
364
+ fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
365
+ fs.renameSync(tmpPath, localPath);
366
+ } catch (e) {
367
+ // Clean up stray tmp on any failure; if the rename fell through
368
+ // because another process beat us, EEXIST on the target is fine.
369
+ try { fs.unlinkSync(tmpPath); } catch {}
370
+ if (e.code !== 'EEXIST') throw e;
371
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
372
+ }
373
+ }
374
+ results.push({ ...att, path: localPath, size: att.size || buf.length });
375
+ console.log(`[attach] ${chatId} ← ${att.kind} ${safeName} (${buf.length} bytes) → ${localPath}`);
376
+ } catch (err) {
377
+ console.error(`[attach] download failed for ${att.name}: ${err.message}`);
378
+ }
379
+ }
380
+ return results;
381
+ }
382
+
383
+
384
+ // ─── Prompt formatting ──────────────────────────────────────────────
385
+
386
+ function resolveReplyTo(msg) {
387
+ if (!msg.reply_to_message) return null;
388
+ if (msg.reply_to_message.from || msg.reply_to_message.text || msg.reply_to_message.caption) {
389
+ return { telegram: msg.reply_to_message };
390
+ }
391
+ const chatId = msg.chat.id.toString();
392
+ const replyToId = msg.reply_to_message.message_id;
393
+ const row = db ? db.getMessage(chatId, replyToId) : null;
394
+ if (row) return { dbRow: row };
395
+ return { replyToId };
396
+ }
397
+
398
+ function formatPrompt(msg, sessionCtx, attachments = []) {
399
+ const chatId = msg.chat.id.toString();
400
+ const threadId = msg.message_thread_id?.toString() || '';
401
+ const chatConfig = config.chats[chatId];
402
+ const topicName = threadId ? getTopicName(chatConfig, threadId) : '';
403
+ return buildPrompt({
404
+ msg,
405
+ topicName,
406
+ sessionCtx,
407
+ attachments,
408
+ replyTo: resolveReplyTo(msg),
409
+ });
410
+ }
411
+
412
+ // ─── Persistent Claude Process per chat (LRU-bounded) ───────────────
413
+
414
+ let pm = null; // ProcessManager, created in main()
415
+
416
+ function spawnClaude(sessionKey, ctx) {
417
+ const { chatConfig, existingSessionId, label, chatId } = ctx;
418
+ const args = [
419
+ '-p',
420
+ '--input-format', 'stream-json',
421
+ '--output-format', 'stream-json',
422
+ '--verbose',
423
+ '--model', chatConfig.model || config.defaults.model,
424
+ '--effort', chatConfig.effort || config.defaults.effort,
425
+ '--permission-mode', 'bypassPermissions',
426
+ '--no-chrome',
427
+ ];
428
+ if (chatConfig.agent) args.push('--agent', chatConfig.agent);
429
+ if (existingSessionId) args.push('--resume', existingSessionId);
430
+
431
+ console.log(`[${label}] Spawning process (${chatConfig.model}/${chatConfig.effort})`);
432
+
433
+ // Scrub env to an allowlist: under bypassPermissions a prompt-injected
434
+ // child can exfiltrate any env var, so we pass only what Claude Code and
435
+ // normal shell tools need. TELEGRAM_BOT_TOKEN is opt-in per bot via
436
+ // config.bot.needsToken — partner bots go through the bridge for every
437
+ // outbound message and never need direct API access.
438
+ const botConfig = config.bot || {};
439
+ const childEnv = filterEnv(process.env);
440
+ childEnv.HOME = CHILD_HOME;
441
+ childEnv.CLAUDE_CHANNEL_BOT = BOT_NAME;
442
+ // Approval hook integration: the hook runs as a child of Claude and reads
443
+ // these to route its IPC. BRIDGE_TURN_ID isn't set here (one session can
444
+ // run many turns) — the hook treats it as optional.
445
+ childEnv.BRIDGE_BOT = BOT_NAME;
446
+ childEnv.BRIDGE_CHAT_ID = String(chatId || '');
447
+ // Allow the PreToolUse approval hook to authenticate to the IPC socket.
448
+ if (process.env.BRIDGE_IPC_SECRET) childEnv.BRIDGE_IPC_SECRET = process.env.BRIDGE_IPC_SECRET;
449
+ if (botConfig.needsToken) {
450
+ childEnv.TELEGRAM_BOT_TOKEN = botConfig.token || '';
451
+ }
452
+
453
+ const proc = spawn(CLAUDE_BIN, args, {
454
+ cwd: chatConfig.cwd,
455
+ env: childEnv,
456
+ stdio: ['pipe', 'pipe', 'pipe'],
457
+ });
458
+ proc.stderr.on('data', (d) => {
459
+ const m = d.toString().trim();
460
+ if (m) console.error(`[${label}] stderr: ${m.slice(0, 200)}`);
461
+ });
462
+ return proc;
463
+ }
464
+
465
+ function buildSpawnContext(sessionKey) {
466
+ const chatId = getChatIdFromKey(sessionKey);
467
+ const chatConfig = config.chats[chatId];
468
+ if (!chatConfig) return null;
469
+ const threadId = sessionKey.includes(':') ? sessionKey.split(':')[1] : null;
470
+ return {
471
+ chatConfig,
472
+ chatId,
473
+ threadId: threadId || null,
474
+ label: getSessionLabel(chatConfig, threadId),
475
+ existingSessionId: getClaudeSessionId(db, sessionKey),
476
+ };
477
+ }
478
+
479
+ async function getOrSpawnForChat(sessionKey) {
480
+ const ctx = buildSpawnContext(sessionKey);
481
+ if (!ctx) return null;
482
+ return pm.getOrSpawn(sessionKey, ctx);
483
+ }
484
+
485
+ async function sendToProcess(sessionKey, prompt) {
486
+ const entry = await getOrSpawnForChat(sessionKey);
487
+ if (!entry) throw new Error('No process for chat');
488
+ const chatId = getChatIdFromKey(sessionKey);
489
+ const chatConfig = config.chats[chatId];
490
+ const timeoutMs = (chatConfig.timeout || config.defaults.timeout) * 1000;
491
+ return pm.send(sessionKey, prompt, { timeoutMs });
492
+ }
493
+
494
+ // ─── Message queue (per-chat) ───────────────────────────────────────
495
+
496
+ const queues = {};
497
+ const processing = {};
498
+ const MAX_QUEUE_DEPTH = 50; // per chat — cron storm or spammer insurance
499
+
500
+ async function enqueue(sessionKey, chatId, msg, bot) {
501
+ if (!queues[sessionKey]) queues[sessionKey] = [];
502
+ if (queues[sessionKey].length >= MAX_QUEUE_DEPTH) {
503
+ // Drop oldest rather than rejecting newest — the user's freshest
504
+ // intent is more valuable than backlog. Emit an event so operators
505
+ // see this rather than a queue silently degrading.
506
+ queues[sessionKey].shift();
507
+ dbWrite(() => db.logEvent('queue-overflow', {
508
+ chat_id: chatId, session_key: sessionKey, cap: MAX_QUEUE_DEPTH,
509
+ }), 'log queue-overflow');
510
+ }
511
+ queues[sessionKey].push({ msg, bot, chatId });
512
+ if (!processing[sessionKey]) processQueue(sessionKey);
513
+ }
514
+
515
+ async function processQueue(sessionKey) {
516
+ processing[sessionKey] = true;
517
+ while (queues[sessionKey]?.length > 0) {
518
+ const { msg, bot, chatId } = queues[sessionKey].shift();
519
+ try {
520
+ await handleMessage(sessionKey, chatId, msg, bot);
521
+ } catch (err) {
522
+ // Raw err.message can carry host paths, DB columns, internal state.
523
+ // Surface a generic message to the user; log the detail to events
524
+ // so operators can still debug.
525
+ console.error(`[${sessionKey}] Error:`, err.message);
526
+ dbWrite(() => db.logEvent('handler-error', {
527
+ chat_id: chatId, session_key: sessionKey,
528
+ msg_id: msg?.message_id,
529
+ error: err.message?.slice(0, 500),
530
+ stack: err.stack?.split('\n').slice(0, 5).join('\n'),
531
+ }), 'log handler-error');
532
+ try {
533
+ await tg(bot, 'sendMessage', {
534
+ chat_id: chatId,
535
+ text: `Sorry, I couldn't process that message. The operator has been notified.`,
536
+ reply_parameters: { message_id: msg.message_id },
537
+ }, { source: 'error-reply', botName: BOT_NAME });
538
+ } catch (replyErr) {
539
+ console.error(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
540
+ }
541
+ }
542
+ }
543
+ processing[sessionKey] = false;
544
+ }
545
+
546
+ const drainQueuesForChat = (chatId) => drainQueuesForChatImpl(queues, chatId);
547
+
548
+ // ─── Typing indicator ───────────────────────────────────────────────
549
+
550
+ function startTyping(bot, chatId, threadId) {
551
+ const opts = threadId ? { message_thread_id: threadId } : {};
552
+ const send = () => bot.api.sendChatAction(chatId, 'typing', opts).catch(() => {});
553
+ send();
554
+ const interval = setInterval(send, 4000);
555
+ return () => clearInterval(interval);
556
+ }
557
+
558
+ // ─── Response parsing (stickers, reactions) ─────────────────────────
559
+
560
+ function parseResponse(text) {
561
+ const trimmed = text.trim();
562
+ const emojiOnly = /^\p{Emoji_Presentation}$/u.test(trimmed) || /^\p{Emoji}\uFE0F?$/u.test(trimmed);
563
+
564
+ if (emojiOnly && trimmed) {
565
+ if (emojiToSticker[trimmed]) {
566
+ return { text: '', sticker: emojiToSticker[trimmed], stickerLabel: trimmed, reaction: null };
567
+ }
568
+ return { text: '', sticker: null, stickerLabel: null, reaction: trimmed };
569
+ }
570
+
571
+ return { text: trimmed, sticker: null, stickerLabel: null, reaction: null };
572
+ }
573
+
574
+ // ─── Reply chunking ─────────────────────────────────────────────────
575
+
576
+ function chunkText(text, maxLen = TG_MAX_LEN) {
577
+ if (text.length <= maxLen) return [text];
578
+ const chunks = [];
579
+ let remaining = text;
580
+ while (remaining.length > 0) {
581
+ if (remaining.length <= maxLen) { chunks.push(remaining); break; }
582
+ let splitAt = remaining.lastIndexOf('\n', maxLen);
583
+ if (splitAt < maxLen * 0.3) splitAt = maxLen;
584
+ chunks.push(remaining.slice(0, splitAt));
585
+ remaining = remaining.slice(splitAt).replace(/^\n/, '');
586
+ }
587
+ return chunks;
588
+ }
589
+
590
+ // ─── Cron/IPC send ─────────────────────────────────────────────────
591
+
592
+ // Allowlist of Telegram Bot API methods external callers (cron) may invoke.
593
+ // Broader than sendMessage to cover receipts, error reports, quick replies.
594
+ // Deliberately excludes destructive ops (deleteMessage, banChatMember, etc.);
595
+ // cron has no business calling those.
596
+ const IPC_SEND_ALLOWED_METHODS = new Set([
597
+ 'sendMessage',
598
+ 'sendPhoto',
599
+ 'sendDocument',
600
+ 'sendSticker',
601
+ 'sendChatAction',
602
+ 'editMessageText',
603
+ 'setMessageReaction',
604
+ ]);
605
+
606
+ async function handleSendOverIpc(req) {
607
+ const { method, params = {}, source } = req || {};
608
+ if (!method) throw new Error('method required');
609
+ if (!IPC_SEND_ALLOWED_METHODS.has(method)) {
610
+ throw new Error(`method not allowed: ${method}`);
611
+ }
612
+ if (!bot) throw new Error(`bot process not ready`);
613
+
614
+ // Enforce: chat_id must belong to this bot (no cross-bot sends).
615
+ // After filterConfigToBot, config.chats only contains our chats.
616
+ const chatId = params.chat_id != null ? String(params.chat_id) : null;
617
+ if (chatId && !config.chats[chatId]) {
618
+ throw new Error(`chat not owned by ${BOT_NAME}: ${chatId}`);
619
+ }
620
+
621
+ const sendRes = await tg(bot, method, params, {
622
+ source: source || 'ipc',
623
+ botName: BOT_NAME,
624
+ });
625
+ return { result: sendRes };
626
+ }
627
+
628
+ // ─── Approvals ─────────────────────────────────────────────────────
629
+
630
+ // Format a tool_input for the inline keyboard card. Clip aggressively so
631
+ // the card doesn't exceed Telegram's 4096-char limit.
632
+ function formatToolInputForCard(input) {
633
+ let s;
634
+ try { s = typeof input === 'string' ? input : JSON.stringify(input, null, 2); }
635
+ catch { s = String(input); }
636
+ if (s.length <= 1200) return s;
637
+ return s.slice(0, 900) + '\n…[clipped]…\n' + s.slice(-200);
638
+ }
639
+
640
+ function buildApprovalKeyboard(approvalId, token) {
641
+ return {
642
+ inline_keyboard: [[
643
+ { text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
644
+ { text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
645
+ ]],
646
+ };
647
+ }
648
+
649
+ function approvalCardText(row, opts = {}) {
650
+ // No parse_mode is used on this card — tool_name/turn_id/tool_input
651
+ // originate from the Claude subprocess and could contain Markdown special
652
+ // chars or tg:// links crafted for phishing. Plain text renders as-is.
653
+ const heading = opts.resolvedBy
654
+ ? opts.resolvedBy
655
+ : `Approval needed — ${row.tool_name}`;
656
+ const body = formatToolInputForCard(
657
+ typeof row.tool_input_json === 'string'
658
+ ? safeParse(row.tool_input_json)
659
+ : row.tool_input_json,
660
+ );
661
+ const ttl = Math.max(0, Math.round((row.timeout_ts - Date.now()) / 1000));
662
+ const footer = opts.resolvedBy
663
+ ? ''
664
+ : `\n\n⏱ expires in ${ttl}s`;
665
+ return `${heading}\nChat: ${row.requester_chat_id}\nTurn: ${row.turn_id || '-'}\n\n${body}${footer}`;
666
+ }
667
+
668
+ function safeParse(s) {
669
+ try { return JSON.parse(s); } catch { return s; }
670
+ }
671
+
672
+ async function handleApprovalRequest(req) {
673
+ const { bot_name, chat_id, turn_id, tool_name, tool_input } = req;
674
+ if (!chat_id || !tool_name) {
675
+ throw new Error('chat_id, tool_name required');
676
+ }
677
+ // Per-bot process: the caller's bot_name must match ours if provided.
678
+ if (bot_name && bot_name !== BOT_NAME) {
679
+ throw new Error(`wrong bot: socket is ${BOT_NAME}, request is for ${bot_name}`);
680
+ }
681
+
682
+ const apprCfg = config.bot?.approvals;
683
+ if (!apprCfg || !apprCfg.adminChatId) {
684
+ return { decision: 'not-gated', reason: 'approvals not configured for this bot' };
685
+ }
686
+
687
+ const gated = matchesApprovalPattern(tool_name, tool_input, apprCfg.gatedTools || []);
688
+ if (!gated.matched) {
689
+ return { decision: 'not-gated' };
690
+ }
691
+
692
+ // Issue pending row (with dedup). Row persists the bot_name for archive/
693
+ // audit queries across per-bot DBs.
694
+ const row = approvals.issue({
695
+ bot_name: BOT_NAME, turn_id, requester_chat_id: chat_id,
696
+ approver_chat_id: String(apprCfg.adminChatId),
697
+ tool_name, tool_input,
698
+ timeoutMs: apprCfg.timeoutMs || APPROVAL_DEFAULT_TIMEOUT_MS,
699
+ });
700
+
701
+ if (!bot) {
702
+ approvals.resolve({ id: row.id, status: 'cancelled', reason: 'bot process not ready' });
703
+ return { decision: 'denied', reason: 'bot process not ready' };
704
+ }
705
+
706
+ if (!row.reused || !row.approver_msg_id) {
707
+ try {
708
+ const sent = await tg(bot, 'sendMessage', {
709
+ chat_id: apprCfg.adminChatId,
710
+ text: approvalCardText(row),
711
+ reply_markup: buildApprovalKeyboard(row.id, row.callback_token),
712
+ }, { source: 'approval-request', botName: BOT_NAME });
713
+ if (sent?.message_id) {
714
+ approvals.setApproverMsgId(row.id, sent.message_id);
715
+ }
716
+ } catch (err) {
717
+ console.error(`[${BOT_NAME}] failed to post approval card: ${err.message}`);
718
+ approvals.resolve({ id: row.id, status: 'cancelled', reason: `post failed: ${err.message}` });
719
+ return { decision: 'denied', reason: `post failed: ${err.message}` };
720
+ }
721
+ }
722
+
723
+ // Block until callback resolves us, or timeout fires. Multiple dedup'd
724
+ // callers can queue on the same id — they all get the same decision.
725
+ return await new Promise((resolve) => {
726
+ const timer = setTimeout(() => {
727
+ dropWaiter(row.id, wrappedResolve);
728
+ resolve({ decision: 'timeout', reason: 'operator did not respond in time' });
729
+ }, Math.max(1000, row.timeout_ts - Date.now()));
730
+
731
+ const wrappedResolve = (decision, reason) => {
732
+ clearTimeout(timer);
733
+ resolve({ decision, reason });
734
+ };
735
+
736
+ const list = approvalWaiters.get(row.id) || [];
737
+ list.push(wrappedResolve);
738
+ approvalWaiters.set(row.id, list);
739
+ });
740
+ }
741
+
742
+ function dropWaiter(id, fn) {
743
+ const list = approvalWaiters.get(id);
744
+ if (!list) return;
745
+ const i = list.indexOf(fn);
746
+ if (i !== -1) list.splice(i, 1);
747
+ if (list.length === 0) approvalWaiters.delete(id);
748
+ }
749
+
750
+ function resolveApprovalWaiter(id, decision, reason) {
751
+ const list = approvalWaiters.get(id);
752
+ if (!list) return;
753
+ approvalWaiters.delete(id);
754
+ for (const fn of list) {
755
+ try { fn(decision, reason); } catch {}
756
+ }
757
+ }
758
+
759
+ async function handleApprovalCallback(ctx) {
760
+ const data = ctx.callbackQuery?.data || '';
761
+ const m = String(data).match(/^(approve|deny):(\d+):(\S+)$/);
762
+ if (!m) return;
763
+ const decision = m[1];
764
+ const id = parseInt(m[2], 10);
765
+ const token = m[3];
766
+
767
+ const row = approvals.getById(id);
768
+ if (!row) {
769
+ await ctx.answerCallbackQuery({ text: 'Unknown approval.', show_alert: true }).catch(() => {});
770
+ return;
771
+ }
772
+ if (!approvalTokensEqual(row.callback_token, token)) {
773
+ dbWrite(() => db.logEvent('approval-token-mismatch', {
774
+ id, from_user: ctx.from?.id,
775
+ // Don't log the sent_token — attackers guessing it don't need to know
776
+ // which prefix they got close on.
777
+ }), 'log approval-token-mismatch');
778
+ await ctx.answerCallbackQuery({ text: 'Bad token.', show_alert: true }).catch(() => {});
779
+ return;
780
+ }
781
+ if (row.status !== 'pending') {
782
+ await ctx.answerCallbackQuery({ text: `Already ${row.status}.`, show_alert: true }).catch(() => {});
783
+ return;
784
+ }
785
+
786
+ // Only the configured approver chat is authoritative. Our process only
787
+ // serves one bot, so config.bot.approvals is the authoritative source.
788
+ const apprCfg = config.bot?.approvals;
789
+ const expectedChat = String(apprCfg?.adminChatId || '');
790
+ if (String(ctx.chat?.id) !== expectedChat) {
791
+ dbWrite(() => db.logEvent('approval-foreign-chat', {
792
+ id, from_chat: ctx.chat?.id, expected: expectedChat,
793
+ }), 'log approval-foreign-chat');
794
+ await ctx.answerCallbackQuery({ text: 'Not authorised here.', show_alert: true }).catch(() => {});
795
+ return;
796
+ }
797
+
798
+ const status = decision === 'approve' ? 'approved' : 'denied';
799
+ const user = ctx.from?.first_name || ctx.from?.username || null;
800
+ const userId = ctx.from?.id || null;
801
+ // SQL-level atomic resolve: UPDATE ... WHERE status='pending' — so in a
802
+ // double-click race only one of the two callers writes. If ours was
803
+ // second, changes=0 → stale decision; tell the clicker and don't edit
804
+ // the card a second time (the winner already did).
805
+ const changes = approvals.resolve({
806
+ id, status,
807
+ decided_by_user_id: userId, decided_by_user: user,
808
+ });
809
+ if (changes === 0) {
810
+ const fresh = approvals.getById(id);
811
+ await ctx.answerCallbackQuery({
812
+ text: `Already ${fresh?.status || 'resolved'}.`,
813
+ show_alert: true,
814
+ }).catch(() => {});
815
+ return;
816
+ }
817
+ dbWrite(() => db.logEvent('approval-resolved', {
818
+ id, status, by: userId, user, bot: BOT_NAME,
819
+ }), 'log approval-resolved');
820
+
821
+ // Edit the card to show the decision.
822
+ try {
823
+ const fresh = approvals.getById(id);
824
+ await ctx.api.editMessageText(
825
+ row.approver_chat_id,
826
+ row.approver_msg_id,
827
+ approvalCardText(fresh, {
828
+ resolvedBy: `${status === 'approved' ? '✅ Approved' : '❌ Denied'} by ${user || userId}`,
829
+ }),
830
+ );
831
+ } catch (err) {
832
+ console.error(`[${BOT_NAME}] edit approval card failed: ${err.message}`);
833
+ }
834
+ await ctx.answerCallbackQuery({ text: status }).catch(() => {});
835
+
836
+ resolveApprovalWaiter(id, status);
837
+ }
838
+
839
+ function startApprovalSweeper(intervalMs = 30_000) {
840
+ return setInterval(() => {
841
+ let rows;
842
+ try {
843
+ rows = approvals.sweepTimedOut();
844
+ } catch (err) {
845
+ // Silent failure here is invisible death — pending approvals time out
846
+ // with no operator signal. Log loudly.
847
+ console.error(`[approvals] sweeper DB error: ${err.message}`);
848
+ dbWrite(() => db.logEvent('approval-sweep-failed', {
849
+ error: err.message?.slice(0, 300),
850
+ }), 'log approval-sweep-failed');
851
+ return;
852
+ }
853
+ for (const row of rows) {
854
+ approvals.resolve({ id: row.id, status: 'timeout' });
855
+ dbWrite(() => db.logEvent('approval-timeout', {
856
+ id: row.id, bot: BOT_NAME, tool: row.tool_name,
857
+ }), 'log approval-timeout');
858
+ resolveApprovalWaiter(row.id, 'timeout', 'swept');
859
+ // Best-effort: edit the card to show the timeout.
860
+ if (bot && row.approver_msg_id) {
861
+ bot.api.editMessageText(
862
+ row.approver_chat_id,
863
+ row.approver_msg_id,
864
+ approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
865
+ ).catch(() => {});
866
+ }
867
+ }
868
+ }, intervalMs);
869
+ }
870
+
871
+ // Parse /pair-code args: /pair-code [--chat <id>] [--scope user|chat] [--ttl 10m] [--note "..."]
872
+ function parsePairCodeArgs(text) {
873
+ const out = {};
874
+ // Strip command, then walk flags. Notes may contain spaces; parse them last.
875
+ let rest = text.replace(/^\/pair-code\s*/, '').trim();
876
+ const flags = ['--chat', '--scope', '--ttl'];
877
+ for (const flag of flags) {
878
+ const re = new RegExp(`${flag.replace(/-/g, '\\-')}\\s+(\\S+)`);
879
+ const m = rest.match(re);
880
+ if (m) {
881
+ out[flag.slice(2)] = m[1];
882
+ rest = rest.replace(re, '').trim();
883
+ }
884
+ }
885
+ const noteM = rest.match(/--note\s+"([^"]*)"|--note\s+(\S+)/);
886
+ if (noteM) out.note = noteM[1] || noteM[2];
887
+ return out;
888
+ }
889
+
890
+ // ─── Message handler ────────────────────────────────────────────────
891
+
892
+ async function handleMessage(sessionKey, chatId, msg, bot) {
893
+ const chatConfig = config.chats[chatId];
894
+ if (!chatConfig) return;
895
+
896
+ const text = msg.text || msg.caption || '';
897
+ const threadId = msg.message_thread_id;
898
+ const threadIdStr = threadId?.toString() || null;
899
+ const label = getSessionLabel(chatConfig, threadIdStr);
900
+
901
+ const replyOpts = (tid) => ({
902
+ reply_parameters: { message_id: msg.message_id },
903
+ ...(tid && { message_thread_id: tid }),
904
+ });
905
+
906
+ const MODEL_VERSIONS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5' };
907
+
908
+ const botAllowsCommands = !!config.bot?.allowConfigCommands;
909
+ const cmdUser = msg.from?.first_name || msg.from?.username || null;
910
+ const cmdUserId = msg.from?.id || null;
911
+
912
+ const sendReply = (replyText, meta = {}) => tg(bot, 'sendMessage', {
913
+ chat_id: chatId, text: replyText, ...replyOpts(threadId),
914
+ }, { source: 'command-reply', botName: BOT_NAME, model: chatConfig.model, effort: chatConfig.effort, ...meta });
915
+
916
+ if (botAllowsCommands && (text === '/model' || text === '/config' || text === '/effort')) {
917
+ const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
918
+ const ver = MODEL_VERSIONS[chatConfig.model] || chatConfig.model;
919
+ const info = `Model: ${chatConfig.model} (${ver})\nEffort: ${chatConfig.effort}\nAgent: ${chatConfig.agent}\nProcess: ${alive ? 'warm' : 'cold'}\nSession: ${getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new'}`;
920
+ await sendReply(info);
921
+ return;
922
+ }
923
+ if (botAllowsCommands && text.startsWith('/model ')) {
924
+ const newModel = text.slice(7).trim();
925
+ if (['opus', 'sonnet', 'haiku'].includes(newModel)) {
926
+ const oldModel = chatConfig.model;
927
+ chatConfig.model = newModel;
928
+ saveConfig();
929
+ dbWrite(() => db.logConfigChange({
930
+ chat_id: chatId, thread_id: threadIdStr, field: 'model',
931
+ old_value: oldModel, new_value: newModel,
932
+ user: cmdUser, user_id: cmdUserId, source: 'command',
933
+ }), 'log model change');
934
+ const droppedModel = drainQueuesForChat(chatId);
935
+ if (droppedModel) dbWrite(() => db.logEvent('queue-drained', { chat_id: chatId, reason: 'model-change', dropped: droppedModel }), 'log queue-drained');
936
+ await pm.killChat(chatId);
937
+ const ver = MODEL_VERSIONS[newModel] || newModel;
938
+ await sendReply(`Model → ${newModel} (${ver})`);
939
+ } else {
940
+ await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
941
+ }
942
+ return;
943
+ }
944
+ if (botAllowsCommands && text.startsWith('/effort ')) {
945
+ const newEffort = text.slice(8).trim();
946
+ if (['low', 'medium', 'high', 'xhigh', 'max'].includes(newEffort)) {
947
+ const oldEffort = chatConfig.effort;
948
+ chatConfig.effort = newEffort;
949
+ saveConfig();
950
+ dbWrite(() => db.logConfigChange({
951
+ chat_id: chatId, thread_id: threadIdStr, field: 'effort',
952
+ old_value: oldEffort, new_value: newEffort,
953
+ user: cmdUser, user_id: cmdUserId, source: 'command',
954
+ }), 'log effort change');
955
+ const droppedEffort = drainQueuesForChat(chatId);
956
+ if (droppedEffort) dbWrite(() => db.logEvent('queue-drained', { chat_id: chatId, reason: 'effort-change', dropped: droppedEffort }), 'log queue-drained');
957
+ await pm.killChat(chatId);
958
+ await sendReply(`Effort → ${newEffort}`);
959
+ } else {
960
+ await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
961
+ }
962
+ return;
963
+ }
964
+ // Admin-only pairing commands — chat must match config.bot.adminChatId.
965
+ // allowConfigCommands alone is NOT sufficient: that flag gates /model and
966
+ // /effort which only affect the current chat. Pairing issues cross-chat
967
+ // trust and must be narrowed further.
968
+ const adminChatId = config.bot?.adminChatId ? String(config.bot.adminChatId) : null;
969
+ const isAdminChat = adminChatId && String(chatId) === adminChatId;
970
+
971
+ if (botAllowsCommands && text.startsWith('/pair-code')) {
972
+ if (!isAdminChat) { await sendReply('Pairing commands are admin-only; run from the admin chat.'); return; }
973
+ const issuerId = cmdUserId;
974
+ if (!issuerId) { await sendReply('No user id on request'); return; }
975
+ const args = parsePairCodeArgs(text);
976
+ try {
977
+ const out = pairings.issueCode({
978
+ bot_name: BOT_NAME,
979
+ chat_id: args.chat || null,
980
+ scope: args.scope || 'user',
981
+ issued_by_user_id: issuerId,
982
+ ttlMs: args.ttl ? parsePairingTtl(args.ttl) : undefined,
983
+ note: args.note || null,
984
+ });
985
+ dbWrite(() => db.logEvent('pair-code-issued', {
986
+ bot: BOT_NAME, by: issuerId, scope: out.scope,
987
+ chat_id: out.chat_id, note: out.note,
988
+ }), 'log pair-code-issued');
989
+ const ttlLabel = args.ttl || '10m';
990
+ const chatLabel = out.chat_id ? `chat ${out.chat_id}` : 'any chat';
991
+ await sendReply(
992
+ `Code: ${out.code}\nexpires: ${ttlLabel}\nscope: ${out.scope} (${chatLabel})${out.note ? `\nnote: ${out.note}` : ''}\n\nShare with user:\n/pair ${out.code}`,
993
+ );
994
+ } catch (err) {
995
+ await sendReply(`Could not issue code: ${err.message}`);
996
+ }
997
+ return;
998
+ }
999
+ if (botAllowsCommands && text.startsWith('/pairings')) {
1000
+ if (!isAdminChat) { await sendReply('Pairing commands are admin-only; run from the admin chat.'); return; }
1001
+ const rows = pairings.listActive(BOT_NAME);
1002
+ if (!rows.length) { await sendReply('No active pairings.'); return; }
1003
+ const lines = rows.map(r => {
1004
+ const chat = r.chat_id ? `chat ${r.chat_id}` : 'any chat';
1005
+ const granted = new Date(r.granted_ts).toISOString().slice(0, 16).replace('T', ' ');
1006
+ const note = r.note ? ` — ${r.note}` : '';
1007
+ return `• user ${r.user_id} — ${chat} — ${granted}${note}`;
1008
+ });
1009
+ await sendReply(`Active pairings (${rows.length}):\n${lines.join('\n')}`);
1010
+ return;
1011
+ }
1012
+ if (botAllowsCommands && text.startsWith('/unpair ')) {
1013
+ if (!isAdminChat) { await sendReply('Pairing commands are admin-only; run from the admin chat.'); return; }
1014
+ const arg = text.slice(8).trim();
1015
+ const targetId = parseInt(arg, 10);
1016
+ if (!Number.isFinite(targetId)) {
1017
+ await sendReply('Usage: /unpair <user_id>'); return;
1018
+ }
1019
+ const n = pairings.revokeByUser({ bot_name: BOT_NAME, user_id: targetId });
1020
+ dbWrite(() => db.logEvent('pair-revoked', {
1021
+ bot: BOT_NAME, user_id: targetId, by: cmdUserId, count: n,
1022
+ }), 'log pair-revoked');
1023
+ await sendReply(n ? `Revoked ${n} pairing(s) for user ${targetId}.` : `No active pairings for user ${targetId}.`);
1024
+ return;
1025
+ }
1026
+ // /pair <CODE> — open to anyone, no admin gate (the code IS the auth)
1027
+ if (text.startsWith('/pair ') && !text.startsWith('/pair-code') && !text.startsWith('/pairings')) {
1028
+ if (!cmdUserId) { await sendReply('No user id on request'); return; }
1029
+ const code = text.slice(6).trim();
1030
+ const res = pairings.claimCode({
1031
+ code, claimer_user_id: cmdUserId,
1032
+ chat_id: chatId, bot_name: BOT_NAME,
1033
+ });
1034
+ dbWrite(() => db.logEvent('pair-claim-attempt', {
1035
+ bot: BOT_NAME, user_id: cmdUserId, chat_id: chatId,
1036
+ ok: res.ok, reason: res.reason,
1037
+ }), 'log pair-claim-attempt');
1038
+ if (res.ok) {
1039
+ const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${BOT_NAME} is in`;
1040
+ await sendReply(`Paired. You can use me in ${chatLabel}.${res.note ? `\n(${res.note})` : ''}`);
1041
+ } else {
1042
+ // Collapse specific failure reasons into a single "invalid or expired"
1043
+ // response to prevent enumeration: distinguishing "wrong-chat" from
1044
+ // "not-found" would tell an attacker a valid code prefix. The
1045
+ // pair-claim-attempt event above still logs the precise reason for
1046
+ // operator audit.
1047
+ const userMsg = res.reason === 'rate-limited'
1048
+ ? 'Too many attempts. Try again later.'
1049
+ : 'Invalid or expired code.';
1050
+ await sendReply(userMsg);
1051
+ }
1052
+ return;
1053
+ }
1054
+
1055
+ const t0 = Date.now();
1056
+
1057
+ const sessionCtx = !pm.has(sessionKey) ? await readSessionContext(sessionKey, chatConfig.cwd) : '';
1058
+
1059
+ const rawAtts = extractAttachments(msg);
1060
+ const { accepted, rejected } = filterAttachments(rawAtts);
1061
+ for (const { att, reason } of rejected) {
1062
+ console.log(`[${label}] attachment skipped: ${att.name} (${reason})`);
1063
+ dbWrite(() => db.logEvent('attachment-skipped', { chat_id: chatId, msg_id: msg.message_id, name: att.name, reason }), 'log attachment-skipped');
1064
+ }
1065
+ const token = config.bot?.token || '';
1066
+ const downloaded = accepted.length ? await downloadAttachments(bot, token, chatId, msg, accepted) : [];
1067
+ if (rejected.length) {
1068
+ const summary = rejected.map(({ att, reason }) => `${att.name}: ${reason}`).join('; ');
1069
+ try {
1070
+ await tg(bot, 'sendMessage', {
1071
+ chat_id: chatId, text: `Attachment(s) skipped: ${summary.slice(0, 300)}`,
1072
+ ...replyOpts(threadId),
1073
+ }, { source: 'attachment-skipped', botName: BOT_NAME });
1074
+ } catch {}
1075
+ }
1076
+
1077
+ await transcribeVoiceAttachments(downloaded, {
1078
+ chatId, msgId: msg.message_id, label, botApi: bot, threadId,
1079
+ });
1080
+
1081
+ const prompt = formatPrompt(msg, sessionCtx, downloaded);
1082
+ const stopTyping = startTyping(bot, chatId, threadId);
1083
+
1084
+ const botCfg = config.bot || {};
1085
+ const streamEnabled = botCfg.streamReplies === true;
1086
+ const outMetaBase = {
1087
+ source: streamEnabled ? 'bot-reply-stream' : 'bot-reply',
1088
+ botName: BOT_NAME,
1089
+ model: chatConfig.model,
1090
+ effort: chatConfig.effort,
1091
+ };
1092
+
1093
+ let streamer = null;
1094
+ if (streamEnabled) {
1095
+ streamer = createStreamer({
1096
+ send: async (text) => tg(bot, 'sendMessage', {
1097
+ chat_id: chatId, text,
1098
+ reply_parameters: { message_id: msg.message_id },
1099
+ ...(threadId && { message_thread_id: threadId }),
1100
+ }, outMetaBase),
1101
+ edit: async (messageId, text) => {
1102
+ try {
1103
+ return await bot.api.editMessageText(chatId, messageId, text);
1104
+ } catch (err) {
1105
+ // Stream-edit failures would otherwise be invisible — edits bypass
1106
+ // tg() so there's no messages row reflecting the attempt. Log to
1107
+ // events so stuck streams leave a forensic trail.
1108
+ dbWrite(() => db.logEvent('telegram-edit-failed', {
1109
+ chat_id: chatId, msg_id: messageId,
1110
+ api_error: err.message?.slice(0, 200),
1111
+ bot: BOT_NAME,
1112
+ }), 'log telegram-edit-failed');
1113
+ throw err;
1114
+ }
1115
+ },
1116
+ minChars: botCfg.streamMinChars,
1117
+ throttleMs: botCfg.streamThrottleMs,
1118
+ logger: { error: (m) => console.error(`[${label}] ${m}`) },
1119
+ });
1120
+ streamers.set(sessionKey, streamer);
1121
+ }
1122
+
1123
+ try {
1124
+ const result = await sendToProcess(sessionKey, prompt);
1125
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
1126
+
1127
+ stopTyping();
1128
+
1129
+ if (result.error) {
1130
+ console.error(`[${label}] Error (${elapsed}s):`, result.error);
1131
+ if (!result.text) return;
1132
+ }
1133
+
1134
+ if (!result.text || result.text === 'NO_REPLY') return;
1135
+
1136
+ const parsed = parseResponse(result.text);
1137
+ const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
1138
+
1139
+ // Streamed text path: finalise the live-edit and, if the full response
1140
+ // overflows Telegram's 4096 cap, send remainder as follow-up chunks.
1141
+ if (streamer && parsed.text) {
1142
+ const fin = await streamer.finalize(parsed.text);
1143
+ if (fin.streamed) {
1144
+ if (parsed.text.length > TG_MAX_LEN) {
1145
+ const rest = parsed.text.slice(TG_MAX_LEN - 3);
1146
+ for (const chunk of chunkText(rest)) {
1147
+ try {
1148
+ await tg(bot, 'sendMessage', {
1149
+ chat_id: chatId, text: chunk,
1150
+ ...(threadId && { message_thread_id: threadId }),
1151
+ }, outMeta);
1152
+ } catch (err) {
1153
+ console.error(`[${label}] overflow sendMessage failed: ${err.message}`);
1154
+ }
1155
+ }
1156
+ }
1157
+ console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1158
+ return;
1159
+ }
1160
+ // Not streamed (response too short) — fall through to normal path.
1161
+ }
1162
+
1163
+ if (parsed.reaction) {
1164
+ await tg(bot, 'setMessageReaction', {
1165
+ chat_id: chatId,
1166
+ message_id: msg.message_id,
1167
+ reaction: [{ type: 'emoji', emoji: parsed.reaction }],
1168
+ }, outMeta).catch((err) => {
1169
+ console.error(`[${label}] setMessageReaction failed: ${err.message}`);
1170
+ });
1171
+ } else if (parsed.sticker) {
1172
+ await tg(bot, 'sendSticker', {
1173
+ chat_id: chatId,
1174
+ sticker: parsed.sticker,
1175
+ ...(threadId && { message_thread_id: threadId }),
1176
+ }, { ...outMeta, stickerName: parsed.stickerLabel }).catch((err) => {
1177
+ console.error(`[${label}] sendSticker failed: ${err.message}`);
1178
+ });
1179
+ } else if (parsed.text) {
1180
+ const chunks = chunkText(parsed.text);
1181
+ for (let i = 0; i < chunks.length; i++) {
1182
+ const params = {
1183
+ chat_id: chatId, text: chunks[i],
1184
+ ...(threadId && { message_thread_id: threadId }),
1185
+ };
1186
+ if (i === 0) params.reply_parameters = { message_id: msg.message_id };
1187
+ try {
1188
+ await tg(bot, 'sendMessage', params, outMeta);
1189
+ } catch (err) {
1190
+ console.error(`[${label}] sendMessage failed (chunk ${i + 1}/${chunks.length}): ${err.message}`);
1191
+ }
1192
+ }
1193
+ }
1194
+
1195
+ console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1196
+ } catch (err) {
1197
+ if (streamer) {
1198
+ // Generic suffix — err.message can leak internal paths/state.
1199
+ await streamer.finalize('', { errorSuffix: 'stream interrupted' }).catch(() => {});
1200
+ }
1201
+ throw err;
1202
+ } finally {
1203
+ stopTyping();
1204
+ if (streamer) streamers.delete(sessionKey);
1205
+ }
1206
+ }
1207
+
1208
+ // ─── Bot setup ──────────────────────────────────────────────────────
1209
+
1210
+ function shouldHandle(msg, chatConfig, botUsername) {
1211
+ const hasAttachment = !!(msg.document || msg.photo || msg.voice || msg.audio || msg.video);
1212
+ if (!msg.text && !msg.caption && !hasAttachment) return false;
1213
+ const chatId = msg.chat.id.toString();
1214
+ if (!config.chats[chatId]) return false;
1215
+
1216
+ if (chatConfig.requireMention && msg.chat.type !== 'private') {
1217
+ const text = msg.text || msg.caption || '';
1218
+ const isReplyToBot = msg.reply_to_message?.from?.username === botUsername;
1219
+ const hasMention = text.includes(`@${botUsername}`);
1220
+ // Paired users bypass requireMention — they've been explicitly trusted
1221
+ // in this chat by an operator, no need for a mention every time.
1222
+ const paired = pairings && msg.from?.id
1223
+ ? pairings.hasLivePairing({ bot_name: BOT_NAME, user_id: msg.from.id, chat_id: chatId })
1224
+ : false;
1225
+ if (!isReplyToBot && !hasMention && !paired) return false;
1226
+ }
1227
+
1228
+ return true;
1229
+ }
1230
+
1231
+ function createBot(token) {
1232
+ const bot = new Bot(token, {
1233
+ client: { timeoutSeconds: 60 },
1234
+ });
1235
+ let botUsername = '';
1236
+ // Cached once @botUsername is known — was recompiling per inbound msg.
1237
+ let mentionRe = null;
1238
+ // Hoisted admin-command matcher; was re-allocated per message.
1239
+ const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair)(\s|$)/;
1240
+ const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
1241
+
1242
+ // The filter in main() guarantees config.chats only contains chats owned
1243
+ // by BOT_NAME, so any update for a chat not in config.chats is unknown —
1244
+ // not another bot's problem.
1245
+ const knownChat = (chatId) => !!config.chats[chatId];
1246
+
1247
+ bot.on('message', async (ctx) => {
1248
+ if (!isWellFormedMessage(ctx.message)) {
1249
+ dbWrite(() => db.logEvent('malformed-update', {
1250
+ bot: BOT_NAME,
1251
+ update_id: ctx.update?.update_id,
1252
+ reason: 'missing chat.id / message_id',
1253
+ }), 'log malformed-update');
1254
+ return;
1255
+ }
1256
+ const chatId = ctx.chat.id.toString();
1257
+ const chatConfig = config.chats[chatId];
1258
+ if (!chatConfig) return;
1259
+
1260
+ // Record every inbound msg, even unaddressed ones — needed for reply-to
1261
+ // lookups and the transcript skill.
1262
+ recordInbound(ctx.message);
1263
+
1264
+ const rawText = ctx.message.text || '';
1265
+ const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
1266
+
1267
+ const botAllowsCommands = !!config.bot?.allowConfigCommands;
1268
+ const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
1269
+ const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
1270
+ if (isAdminCmd || isPairClaim) {
1271
+ ctx.message.text = cleanText;
1272
+ const threadId = ctx.message.message_thread_id?.toString();
1273
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1274
+ await handleMessage(sessionKey, chatId, ctx.message, bot);
1275
+ return;
1276
+ }
1277
+
1278
+ if (!shouldHandle(ctx.message, chatConfig, botUsername)) return;
1279
+
1280
+ if (botUsername) {
1281
+ ctx.message.text = cleanText;
1282
+ }
1283
+
1284
+ const threadId = ctx.message.message_thread_id?.toString();
1285
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
1286
+
1287
+ await enqueue(sessionKey, chatId, ctx.message, bot);
1288
+ });
1289
+
1290
+ bot.on('callback_query:data', async (ctx) => {
1291
+ try {
1292
+ await handleApprovalCallback(ctx);
1293
+ } catch (err) {
1294
+ console.error(`[${BOT_NAME}] callback_query error: ${err.message}`);
1295
+ }
1296
+ });
1297
+
1298
+ bot.on('edited_message', async (ctx) => {
1299
+ if (!isWellFormedMessage(ctx.editedMessage)) {
1300
+ dbWrite(() => db.logEvent('malformed-update', {
1301
+ bot: BOT_NAME,
1302
+ update_id: ctx.update?.update_id,
1303
+ reason: 'edited_message missing chat.id / message_id',
1304
+ }), 'log malformed-update');
1305
+ return;
1306
+ }
1307
+ const chatId = ctx.editedMessage.chat.id.toString();
1308
+ if (!knownChat(chatId)) return;
1309
+ recordInbound(ctx.editedMessage);
1310
+ dbWrite(() => db.logEvent('message-edited', {
1311
+ chat_id: chatId,
1312
+ msg_id: ctx.editedMessage.message_id,
1313
+ user_id: ctx.editedMessage.from?.id || null,
1314
+ }), 'log message-edited');
1315
+ console.log(`[${BOT_NAME}] edited ${chatId}/${ctx.editedMessage.message_id}`);
1316
+ });
1317
+
1318
+ bot.on('message:migrate_to_chat_id', async (ctx) => {
1319
+ // Defensive: Telegram's grammy filter matches when migrate_to_chat_id is
1320
+ // present, but neither value is guaranteed to be numeric / finite. If
1321
+ // this update is malformed, skip rather than writing garbage to the DB.
1322
+ const rawOld = ctx.chat?.id;
1323
+ const rawNew = ctx.message?.migrate_to_chat_id;
1324
+ const isValidId = (v) => (typeof v === 'number' && Number.isFinite(v)) || typeof v === 'bigint';
1325
+ if (!isValidId(rawOld) || !isValidId(rawNew)) {
1326
+ dbWrite(() => db.logEvent('malformed-update', {
1327
+ bot: BOT_NAME,
1328
+ update_id: ctx.update?.update_id,
1329
+ reason: 'migrate_to_chat_id missing / non-numeric',
1330
+ }), 'log malformed-update');
1331
+ return;
1332
+ }
1333
+ const oldChatId = rawOld.toString();
1334
+ const newChatId = rawNew.toString();
1335
+ if (oldChatId === newChatId) {
1336
+ dbWrite(() => db.logEvent('malformed-update', {
1337
+ bot: BOT_NAME,
1338
+ update_id: ctx.update?.update_id,
1339
+ reason: 'migrate_to_chat_id equals current chat_id',
1340
+ }), 'log malformed-update');
1341
+ return;
1342
+ }
1343
+ console.log(`[${BOT_NAME}] chat migrated: ${oldChatId} → ${newChatId}`);
1344
+ dbWrite(() => db.logChatMigration(oldChatId, newChatId), 'log chat-migration');
1345
+ dbWrite(() => db.logEvent('chat-migrated', { old_chat_id: oldChatId, new_chat_id: newChatId }), 'log chat-migrated event');
1346
+ if (config.chats[oldChatId] && !config.chats[newChatId]) {
1347
+ config.chats[newChatId] = { ...config.chats[oldChatId] };
1348
+ delete config.chats[oldChatId];
1349
+ saveConfig();
1350
+ const droppedMigrate = drainQueuesForChat(oldChatId);
1351
+ if (droppedMigrate) dbWrite(() => db.logEvent('queue-drained', { chat_id: oldChatId, reason: 'chat-migrated', dropped: droppedMigrate }), 'log queue-drained');
1352
+ await pm.killChat(oldChatId);
1353
+ }
1354
+ });
1355
+
1356
+ bot.catch((err) => {
1357
+ const updateId = err.ctx?.update?.update_id;
1358
+ const msgId = err.ctx?.update?.message?.message_id || err.ctx?.update?.edited_message?.message_id;
1359
+ console.error(`[${BOT_NAME}] update ${updateId} msg ${msgId} error: ${err.message}`);
1360
+ dbWrite(() => db.logEvent('update-error', {
1361
+ bot: BOT_NAME,
1362
+ update_id: updateId,
1363
+ msg_id: msgId,
1364
+ error: err.message?.slice(0, 300),
1365
+ }), 'log update-error');
1366
+ });
1367
+
1368
+ bot._setBotUsername = (u) => {
1369
+ botUsername = u;
1370
+ mentionRe = u ? new RegExp(`@${u}\\b`, 'g') : null;
1371
+ };
1372
+
1373
+ return bot;
1374
+ }
1375
+
1376
+ // ─── Manual polling ─────────────────────────────────────────────────
1377
+
1378
+ async function pollBot(bot) {
1379
+ await bot.init();
1380
+ bot._setBotUsername(bot.botInfo.username);
1381
+ console.log(`[${BOT_NAME}] Bot @${bot.botInfo.username} ready`);
1382
+
1383
+ await bot.api.deleteWebhook();
1384
+
1385
+ let offset = 0;
1386
+ let running = true;
1387
+ bot._lastPollTs = Date.now();
1388
+
1389
+ bot._stop = () => { running = false; };
1390
+
1391
+ while (running) {
1392
+ try {
1393
+ const updates = await bot.api.getUpdates({
1394
+ offset,
1395
+ // Long-poll: Telegram holds the connection up to 25s waiting for
1396
+ // updates. When something arrives it returns immediately; empty
1397
+ // windows cost ~0 local CPU. Drops median inbound latency vs the
1398
+ // old short-poll-every-1s.
1399
+ timeout: 25,
1400
+ allowed_updates: ['message', 'edited_message', 'callback_query'],
1401
+ });
1402
+ bot._lastPollTs = Date.now();
1403
+
1404
+ for (const update of updates) {
1405
+ offset = update.update_id + 1;
1406
+ if (update.message && isWellFormedMessage(update.message)) {
1407
+ const m = update.message;
1408
+ const chatId = m.chat.id.toString();
1409
+ const chatConfig = config.chats[chatId];
1410
+ const threadId = m.message_thread_id?.toString();
1411
+ const topicName = threadId && chatConfig?.topics?.[threadId] ? chatConfig.topics[threadId] : threadId;
1412
+ const chatLabel = chatConfig?.name || chatId;
1413
+ const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
1414
+ console.log(`[${BOT_NAME}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
1415
+ }
1416
+ try {
1417
+ await bot.handleUpdate(update);
1418
+ } catch (err) {
1419
+ console.error(`[${BOT_NAME}] Handler error:`, err.message);
1420
+ }
1421
+ }
1422
+ // No sleep on the success path: long-poll already blocks up to 25s
1423
+ // when idle. Sleeping here would add latency with no gain.
1424
+ } catch (err) {
1425
+ if (!running) break;
1426
+ if (err.error_code === 409) {
1427
+ console.log(`[${BOT_NAME}] 409, waiting 3s...`);
1428
+ await new Promise(r => setTimeout(r, 3000));
1429
+ } else {
1430
+ console.error(`[${BOT_NAME}] Poll error:`, err.message);
1431
+ await new Promise(r => setTimeout(r, 3000));
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ // Watchdog: if the poll loop hasn't ticked in POLL_STALL_MS, log an event
1438
+ // so external monitoring (or a human reading `events`) can see it. Launchd
1439
+ // restarts the whole process on death, so we don't exit here — a stalled
1440
+ // grammy poll is usually transient (network flap, Telegram 5xx).
1441
+ const POLL_STALL_MS = 120_000;
1442
+ function startPollWatchdog(bot) {
1443
+ let stalled = false;
1444
+ return setInterval(() => {
1445
+ const now = Date.now();
1446
+ const age = now - (bot._lastPollTs || 0);
1447
+ if (age > POLL_STALL_MS) {
1448
+ if (!stalled) {
1449
+ console.error(`[${BOT_NAME}] poll-stalled: no tick in ${Math.round(age / 1000)}s`);
1450
+ dbWrite(() => db.logEvent('poll-stalled', { bot: BOT_NAME, stall_ms: age }), 'log poll-stalled');
1451
+ stalled = true;
1452
+ }
1453
+ } else if (stalled) {
1454
+ console.log(`[${BOT_NAME}] poll-recovered after stall`);
1455
+ dbWrite(() => db.logEvent('poll-recovered', { bot: BOT_NAME }), 'log poll-recovered');
1456
+ stalled = false;
1457
+ }
1458
+ }, 30_000);
1459
+ }
1460
+
1461
+ // ─── Main ───────────────────────────────────────────────────────────
1462
+
1463
+ async function main() {
1464
+ loadConfig();
1465
+ loadStickers();
1466
+
1467
+ let dbOverride;
1468
+ try {
1469
+ BOT_NAME = parseBotArg(process.argv);
1470
+ dbOverride = parseDbArg(process.argv);
1471
+ } catch (err) {
1472
+ console.error(`[fatal] ${err.message}`);
1473
+ process.exit(2);
1474
+ }
1475
+ if (!BOT_NAME) {
1476
+ console.error('[fatal] --bot <name> is required. See ops/README.md.');
1477
+ process.exit(2);
1478
+ }
1479
+ try {
1480
+ config = filterConfigToBot(config, BOT_NAME);
1481
+ // Convenience: config.bot is the current bot's config block. After the
1482
+ // filter, config.bots has exactly one entry; this alias keeps call sites
1483
+ // from re-indexing by name.
1484
+ config.bot = config.bots[BOT_NAME];
1485
+ } catch (err) {
1486
+ console.error(`[fatal] ${err.message}`);
1487
+ process.exit(2);
1488
+ }
1489
+ DB_PATH = dbOverride || path.join(DB_DIR, `${BOT_NAME}.db`);
1490
+ console.log(`[bridge] bot: ${BOT_NAME} (${Object.keys(config.chats).length} chats) db: ${DB_PATH}`);
1491
+
1492
+ try {
1493
+ db = dbClient.open(DB_PATH);
1494
+ console.log(`[db] opened ${DB_PATH}`);
1495
+ tg = createSender(db, console);
1496
+ pairings = createPairingsStore(db.raw);
1497
+ approvals = createApprovalsStore(db.raw);
1498
+ const migration = migrateJsonToDb(db, SESSIONS_JSON_PATH, config.chats);
1499
+ if (migration.renamed) {
1500
+ console.log(`[db] sessions.json → ${migration.reason} (${migration.imported} imported)`);
1501
+ }
1502
+ const stale = db.markStalePending(60_000, BOT_NAME);
1503
+ if (stale.changes) console.log(`[db] marked ${stale.changes} stale pending rows as failed (bot=${BOT_NAME})`);
1504
+ const inboxRetentionMs = (config.defaults?.inboxRetentionDays || 30) * 86_400_000;
1505
+ const swept = sweepInbox(INBOX_DIR, inboxRetentionMs);
1506
+ if (swept.swept) {
1507
+ console.log(`[inbox] swept ${swept.swept} files (${(swept.bytes / 1_048_576).toFixed(1)} MiB) older than ${inboxRetentionMs / 86_400_000}d`);
1508
+ db.logEvent('inbox-swept', { files: swept.swept, bytes: swept.bytes, retention_days: inboxRetentionMs / 86_400_000 });
1509
+ }
1510
+ db.logEvent('bridge-start', { migration: migration.reason, imported: migration.imported });
1511
+ } catch (err) {
1512
+ console.error(`[db] FATAL: ${err.message}`);
1513
+ console.error('Bridge cannot run without a DB (Phase 2: DB is source of truth).');
1514
+ process.exit(1);
1515
+ }
1516
+
1517
+ const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
1518
+ pm = new ProcessManager({
1519
+ cap,
1520
+ spawnFn: spawnClaude,
1521
+ db,
1522
+ logger: console,
1523
+ onInit: (sessionKey, event, entry) => {
1524
+ dbWrite(() => db.upsertSession({
1525
+ session_key: sessionKey,
1526
+ chat_id: entry.chatId,
1527
+ thread_id: entry.threadId,
1528
+ claude_session_id: event.session_id,
1529
+ agent: config.chats[entry.chatId]?.agent || null,
1530
+ cwd: config.chats[entry.chatId]?.cwd || null,
1531
+ model: config.chats[entry.chatId]?.model || null,
1532
+ effort: config.chats[entry.chatId]?.effort || null,
1533
+ }), `upsert session ${sessionKey}`);
1534
+ },
1535
+ onClose: (sessionKey, code, entry) => {
1536
+ console.log(`[${entry.label}] Process exited (code ${code})`);
1537
+ dbWrite(() => db.logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code }), 'log process-close');
1538
+ },
1539
+ onStreamChunk: (sessionKey, partial) => {
1540
+ const s = streamers.get(sessionKey);
1541
+ if (s) s.onChunk(partial).catch(() => {});
1542
+ },
1543
+ });
1544
+
1545
+ console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
1546
+ console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
1547
+
1548
+ bot = createBot(config.bot.token);
1549
+
1550
+ const shutdown = () => {
1551
+ console.log('\nShutting down...');
1552
+ if (bot && bot._stop) bot._stop();
1553
+ if (approvalSweepTimer) clearInterval(approvalSweepTimer);
1554
+ if (ipcCloser) ipcCloser.close().catch(() => {});
1555
+ try { fs.unlinkSync(ipcServer.secretPathFor(BOT_NAME)); } catch {}
1556
+ // Resolve any blocked hook waiters so Claude processes don't hang.
1557
+ for (const list of approvalWaiters.values()) {
1558
+ for (const fn of list) { try { fn('cancelled', 'bridge shutting down'); } catch {} }
1559
+ }
1560
+ approvalWaiters.clear();
1561
+ if (pm) pm.shutdown().catch(() => {});
1562
+ if (db) {
1563
+ try { db.logEvent('bridge-stop'); db.raw.close(); } catch {}
1564
+ }
1565
+ setTimeout(() => process.exit(0), 1000);
1566
+ };
1567
+ process.on('SIGINT', shutdown);
1568
+ process.on('SIGTERM', shutdown);
1569
+
1570
+ try {
1571
+ // Fresh per-boot secret, persisted 0600 for same-UID readers (cron
1572
+ // scripts, hook); also exported to spawned Claude processes via env.
1573
+ const ipcSecret = ipcServer.writeSecret(BOT_NAME);
1574
+ process.env.BRIDGE_IPC_SECRET = ipcSecret;
1575
+ ipcCloser = await ipcServer.start({
1576
+ path: ipcServer.socketPathFor(BOT_NAME),
1577
+ secret: ipcSecret,
1578
+ handlers: {
1579
+ approval_request: handleApprovalRequest,
1580
+ ping: async () => ({ pong: true, bot: BOT_NAME }),
1581
+ send: (req) => handleSendOverIpc(req),
1582
+ },
1583
+ logger: console,
1584
+ });
1585
+ } catch (err) {
1586
+ console.error(`[ipc] failed to start: ${err.message}`);
1587
+ }
1588
+ approvalSweepTimer = startApprovalSweeper();
1589
+
1590
+ console.log(`[${BOT_NAME}] Starting...`);
1591
+ const pollPromise = pollBot(bot).catch(err => {
1592
+ console.error(`[${BOT_NAME}] Fatal:`, err.message);
1593
+ });
1594
+
1595
+ const watchdogTimer = startPollWatchdog(bot);
1596
+ process.once('exit', () => clearInterval(watchdogTimer));
1597
+
1598
+ await pollPromise;
1599
+ }
1600
+
1601
+ main().catch(err => {
1602
+ console.error('Fatal:', err);
1603
+ process.exit(1);
1604
+ });