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/LICENSE +21 -0
- package/README.md +287 -0
- package/bin/bridge-approval-hook.js +113 -0
- package/bridge.js +1604 -0
- package/config.example.json +118 -0
- package/lib/approvals.js +219 -0
- package/lib/attachments.js +56 -0
- package/lib/config-scope.js +49 -0
- package/lib/db.js +291 -0
- package/lib/history.js +149 -0
- package/lib/inbox.js +34 -0
- package/lib/ipc-client.js +114 -0
- package/lib/ipc-server.js +149 -0
- package/lib/pairings.js +215 -0
- package/lib/process-manager.js +287 -0
- package/lib/prompt.js +200 -0
- package/lib/queue-utils.js +27 -0
- package/lib/session-key.js +31 -0
- package/lib/sessions.js +98 -0
- package/lib/stream-reply.js +140 -0
- package/lib/telegram.js +105 -0
- package/lib/voice.js +146 -0
- package/migrations/001-initial.sql +93 -0
- package/migrations/002-fix-fts-triggers.sql +24 -0
- package/migrations/003-pairings.sql +33 -0
- package/migrations/004-approvals.sql +28 -0
- package/ops/README.md +110 -0
- package/ops/polygram.plist.example +58 -0
- package/package.json +55 -0
- package/scripts/ipc-smoke.js +28 -0
- package/scripts/split-db.js +251 -0
- package/skills/telegram-history/SKILL.md +57 -0
- package/skills/telegram-history/scripts/query.js +289 -0
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
|
+
});
|