@inetafrica/open-claudia 1.6.0 → 1.7.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/bot-agent.js ADDED
@@ -0,0 +1,1487 @@
1
+ const TelegramBot = require("node-telegram-bot-api");
2
+ const { spawn, execSync } = require("child_process");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const https = require("https");
6
+ const cron = require("node-cron");
7
+ const Vault = require("./vault");
8
+ const CONFIG_DIR = require("./config-dir");
9
+
10
+ // ── Graceful shutdown & error handling ─────────────────────────────
11
+ process.on("SIGINT", () => process.exit(0));
12
+ process.on("SIGTERM", () => process.exit(0));
13
+
14
+ // Notify user of crashes via Telegram before exiting
15
+ function notifyError(label, err) {
16
+ const msg = `${label}: ${err?.message || err}`.slice(0, 1000);
17
+ console.error(msg, err?.stack || "");
18
+ try {
19
+ // Synchronous-style notification using the Telegram API directly
20
+ const token = process.env.TELEGRAM_BOT_TOKEN;
21
+ const chatId = process.env.TELEGRAM_CHAT_ID?.split(",")[0];
22
+ if (token && chatId) {
23
+ const data = JSON.stringify({ chat_id: chatId, text: msg });
24
+ const req = require("https").request({
25
+ hostname: "api.telegram.org", path: `/bot${token}/sendMessage`,
26
+ method: "POST", headers: { "Content-Type": "application/json", "Content-Length": data.length },
27
+ });
28
+ req.write(data);
29
+ req.end();
30
+ }
31
+ } catch (e) { /* last resort — ignore */ }
32
+ }
33
+
34
+ process.on("uncaughtException", (err) => {
35
+ notifyError("Uncaught exception", err);
36
+ // Give the notification a moment to send before exiting
37
+ setTimeout(() => process.exit(1), 2000);
38
+ });
39
+
40
+ process.on("unhandledRejection", (reason) => {
41
+ notifyError("Unhandled rejection", reason);
42
+ });
43
+
44
+ // ── Load Config from .env ───────────────────────────────────────────
45
+ function loadEnv() {
46
+ const envPath = path.join(CONFIG_DIR, ".env");
47
+ if (!fs.existsSync(envPath)) {
48
+ console.error("No .env file found. Run: node setup.js");
49
+ process.exit(1);
50
+ }
51
+ const lines = fs.readFileSync(envPath, "utf-8").split("\n");
52
+ const env = {};
53
+ for (const line of lines) {
54
+ const idx = line.indexOf("=");
55
+ if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
56
+ }
57
+ return env;
58
+ }
59
+
60
+ function saveEnvKey(key, value) {
61
+ const envPath = path.join(CONFIG_DIR, ".env");
62
+ const content = fs.readFileSync(envPath, "utf-8");
63
+ const lines = content.split("\n");
64
+ let found = false;
65
+ const updated = lines.map((line) => {
66
+ if (line.startsWith(key + "=")) { found = true; return `${key}=${value}`; }
67
+ return line;
68
+ });
69
+ if (!found) updated.push(`${key}=${value}`);
70
+ fs.writeFileSync(envPath, updated.join("\n"));
71
+ }
72
+
73
+ const config = loadEnv();
74
+ const TOKEN = config.TELEGRAM_BOT_TOKEN;
75
+ const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim()).filter(Boolean);
76
+ const CHAT_ID = CHAT_IDS[0]; // Primary owner chat for crons/notifications
77
+ const WORKSPACE = config.WORKSPACE;
78
+ const CLAUDE_PATH = config.CLAUDE_PATH;
79
+ const WHISPER_CLI = config.WHISPER_CLI || "";
80
+ const WHISPER_MODEL = config.WHISPER_MODEL || "";
81
+ const FFMPEG = config.FFMPEG || "";
82
+ const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
83
+ const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
84
+ const VAULT_FILE = config.VAULT_FILE || path.join(CONFIG_DIR, "vault.enc");
85
+ const AUTH_FILE = config.AUTH_FILE || path.join(CONFIG_DIR, "auth.json");
86
+ const BOT_DIR = __dirname;
87
+
88
+ // Detect PATH for subprocess
89
+ const FULL_PATH = [
90
+ path.dirname(CLAUDE_PATH),
91
+ path.dirname(process.execPath),
92
+ FFMPEG ? path.dirname(FFMPEG) : null,
93
+ WHISPER_CLI ? path.dirname(WHISPER_CLI) : null,
94
+ ...(process.platform === "win32"
95
+ ? [process.env.APPDATA, process.env.LOCALAPPDATA].filter(Boolean).map((p) => path.join(p, "npm"))
96
+ : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
97
+ ),
98
+ ].filter(Boolean).join(path.delimiter);
99
+
100
+ const bot = new TelegramBot(TOKEN, {
101
+ polling: {
102
+ autoStart: true,
103
+ params: { timeout: 30 },
104
+ },
105
+ });
106
+ const vault = new Vault(VAULT_FILE);
107
+
108
+ // ── Auto-reconnect on polling errors ───────────────────────────────
109
+ let reconnectTimer = null;
110
+ bot.on("polling_error", (err) => {
111
+ const msg = err.message || "";
112
+ console.error("Polling error:", msg);
113
+ // Another instance is already running — exit immediately
114
+ if (msg.includes("409 Conflict")) {
115
+ console.error("Another instance is polling. Exiting.");
116
+ process.exit(1);
117
+ }
118
+ if (msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET") || msg.includes("ENOTFOUND") || msg.includes("EFATAL")) {
119
+ if (reconnectTimer) return; // Already scheduled
120
+ console.log("Network lost. Reconnecting in 10s...");
121
+ reconnectTimer = setTimeout(async () => {
122
+ reconnectTimer = null;
123
+ try {
124
+ await bot.stopPolling();
125
+ await new Promise((r) => setTimeout(r, 2000));
126
+ await bot.startPolling();
127
+ console.log("Reconnected.");
128
+ } catch (e) {
129
+ console.error("Reconnect failed:", e.message);
130
+ // launchd will restart us if we exit
131
+ process.exit(1);
132
+ }
133
+ }, 10000);
134
+ }
135
+ });
136
+
137
+ // ── Update checker (every 5 mins) ──────────────────────────────────
138
+ const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
139
+ let lastNotifiedVersion = null;
140
+
141
+ function checkForUpdates() {
142
+ https.get("https://registry.npmjs.org/@inetafrica/open-claudia/latest", (res) => {
143
+ let data = "";
144
+ res.on("data", (d) => { data += d; });
145
+ res.on("end", () => {
146
+ try {
147
+ const latest = JSON.parse(data).version;
148
+ if (latest && latest !== CURRENT_VERSION && latest !== lastNotifiedVersion) {
149
+ lastNotifiedVersion = latest;
150
+ bot.sendMessage(CHAT_ID, `Hey! A new version is available (v${latest}). You're on v${CURRENT_VERSION}.\n\nSend /upgrade to update — I'll be back in a few seconds.`);
151
+ }
152
+ } catch (e) {}
153
+ });
154
+ }).on("error", () => {});
155
+ }
156
+
157
+ // Check on startup (after 30s) and every 5 minutes
158
+ setTimeout(checkForUpdates, 30000);
159
+ setInterval(checkForUpdates, 5 * 60 * 1000);
160
+
161
+ // ── Commands Menu ───────────────────────────────────────────────────
162
+ bot.setMyCommands([
163
+ { command: "session", description: "Pick a project to work on" },
164
+ { command: "projects", description: "Browse all workspace projects" },
165
+ { command: "model", description: "Switch model (opus/sonnet/haiku)" },
166
+ { command: "effort", description: "Set effort level" },
167
+ { command: "budget", description: "Set max spend for next task" },
168
+ { command: "plan", description: "Toggle plan mode" },
169
+ { command: "sessions", description: "List conversations for this project" },
170
+ { command: "compact", description: "Summarize conversation context" },
171
+ { command: "continue", description: "Resume last conversation" },
172
+ { command: "worktree", description: "Toggle isolated git branch" },
173
+ { command: "cron", description: "Manage scheduled tasks" },
174
+ { command: "vault", description: "Manage credentials (password required)" },
175
+ { command: "soul", description: "View/edit assistant identity" },
176
+ { command: "status", description: "Session & settings info" },
177
+ { command: "stop", description: "Cancel running task" },
178
+ { command: "end", description: "End current session" },
179
+ { command: "version", description: "Show current version" },
180
+ { command: "restart", description: "Restart the bot" },
181
+ { command: "upgrade", description: "Upgrade and restart" },
182
+ { command: "help", description: "Show all commands" },
183
+ ]);
184
+
185
+ // Temp dir for media
186
+ const TEMP_DIR = path.join(CONFIG_DIR, "media");
187
+ const FILES_DIR = path.join(CONFIG_DIR, "files");
188
+ if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
189
+ if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
190
+
191
+ // ── Persistent state ───────────────────────────────────────────────
192
+ const STATE_FILE = path.join(CONFIG_DIR, "state.json");
193
+ const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
194
+
195
+ function loadState() {
196
+ try {
197
+ return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8"));
198
+ } catch (e) {
199
+ return {};
200
+ }
201
+ }
202
+
203
+ function saveState() {
204
+ const data = {
205
+ currentSession,
206
+ lastSessionId,
207
+ settings,
208
+ };
209
+ try { fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch (e) {}
210
+ }
211
+
212
+ // ── Message deduplication ──────────────────────────────────────────
213
+ const processedMessages = new Set();
214
+ function isDuplicate(msgId) {
215
+ if (processedMessages.has(msgId)) return true;
216
+ processedMessages.add(msgId);
217
+ // Keep set from growing unbounded
218
+ if (processedMessages.size > 200) {
219
+ const arr = [...processedMessages];
220
+ processedMessages.clear();
221
+ arr.slice(-100).forEach((id) => processedMessages.add(id));
222
+ }
223
+ return false;
224
+ }
225
+
226
+ // ── Per-project session history ────────────────────────────────────
227
+
228
+ function loadSessions() {
229
+ try { return JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return {}; }
230
+ }
231
+
232
+ function saveSessions(sessions) {
233
+ try { fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); } catch (e) {}
234
+ }
235
+
236
+ function recordSession(projectName, sessionId, title) {
237
+ const sessions = loadSessions();
238
+ if (!sessions[projectName]) sessions[projectName] = [];
239
+ const existing = sessions[projectName].find((s) => s.id === sessionId);
240
+ if (existing) {
241
+ if (title) existing.title = title;
242
+ existing.lastUsed = new Date().toISOString();
243
+ } else {
244
+ sessions[projectName].push({
245
+ id: sessionId,
246
+ title: title || "Untitled",
247
+ created: new Date().toISOString(),
248
+ lastUsed: new Date().toISOString(),
249
+ });
250
+ }
251
+ // Keep last 20 sessions per project
252
+ sessions[projectName] = sessions[projectName].slice(-20);
253
+ saveSessions(sessions);
254
+ }
255
+
256
+ function getProjectSessions(projectName) {
257
+ const sessions = loadSessions();
258
+ return (sessions[projectName] || []).slice().reverse();
259
+ }
260
+
261
+ function getLastProjectSession(projectName) {
262
+ const sessions = getProjectSessions(projectName);
263
+ return sessions.length > 0 ? sessions[0] : null;
264
+ }
265
+
266
+ const savedState = loadState();
267
+
268
+ // ── State ───────────────────────────────────────────────────────────
269
+ // Agent mode: heavy tasks run as "background" processes while
270
+ // new messages get handled by short-lived "chat" processes.
271
+ let currentSession = savedState.currentSession || null;
272
+ let runningProcess = null; // The main/heavy task process
273
+ let runningProcessPrompt = null; // What the main task is doing (for context)
274
+ let chatProcesses = new Map(); // msgId -> proc (lightweight chat processes)
275
+ let statusMessageId = null;
276
+ let streamBuffer = "";
277
+ let streamInterval = null;
278
+ let lastSessionId = savedState.lastSessionId || null;
279
+ let messageQueue = []; // Fallback queue (only used if chat process also fails)
280
+ let activeCrons = new Map();
281
+ let pendingVaultUnlock = false;
282
+ let pendingVaultAction = null;
283
+ let isFirstMessage = !lastSessionId;
284
+
285
+ let settings = savedState.settings || {
286
+ model: null, effort: null, budget: null, permissionMode: null, worktree: false,
287
+ };
288
+
289
+ function resetSettings() {
290
+ settings = { model: null, effort: null, budget: null, permissionMode: null, worktree: false };
291
+ }
292
+
293
+ function isAuthorized(msg) {
294
+ const chatId = String(msg.chat.id);
295
+ if (CHAT_IDS.includes(chatId)) return true;
296
+ // Also check auth.json for dynamically added chats
297
+ try {
298
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
299
+ return auth.authorized.some((a) => a.chatId === chatId);
300
+ } catch (e) {}
301
+ return false;
302
+ }
303
+
304
+ function isOwner(msg) {
305
+ return String(msg.chat.id) === CHAT_ID;
306
+ }
307
+
308
+ // ── Auth request handler (for unauthorized users) ──────────────────
309
+ bot.onText(/\/auth$/, async (msg) => {
310
+ if (isAuthorized(msg)) {
311
+ bot.sendMessage(msg.chat.id, "You're already authorized.");
312
+ return;
313
+ }
314
+ const chatId = String(msg.chat.id);
315
+ const name = [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ");
316
+ const username = msg.from?.username || "";
317
+
318
+ // Add to pending in auth.json
319
+ let auth;
320
+ try {
321
+ auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
322
+ } catch (e) {
323
+ auth = { authorized: [], pending: [] };
324
+ }
325
+
326
+ // Check if already pending
327
+ if (auth.pending.some((p) => p.chatId === chatId)) {
328
+ bot.sendMessage(msg.chat.id, "Your request is already pending. The bot owner will review it.");
329
+ return;
330
+ }
331
+
332
+ auth.pending.push({
333
+ chatId,
334
+ name,
335
+ username,
336
+ requestedAt: new Date().toISOString(),
337
+ });
338
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
339
+
340
+ bot.sendMessage(msg.chat.id, "Access requested! The bot owner will review your request.");
341
+
342
+ // Notify owner
343
+ const label = username ? `@${username}` : name;
344
+ bot.sendMessage(CHAT_ID, `New auth request from ${label} (${chatId}).\nRun 'open-claudia auth' to approve or deny.`);
345
+ });
346
+
347
+ // ── Onboarding ──────────────────────────────────────────────────────
348
+ let onboardingStep = null; // null | "name" | "role" | "style" | "done"
349
+ let onboardingData = {};
350
+
351
+ function isOnboarded() {
352
+ return config.ONBOARDED === "true";
353
+ }
354
+
355
+ async function startOnboarding() {
356
+ onboardingStep = "name";
357
+ onboardingData = {};
358
+ await send(
359
+ "Welcome! Let's set me up.\n\nWhat should I call you?"
360
+ );
361
+ }
362
+
363
+ async function handleOnboarding(msg) {
364
+ const text = msg.text;
365
+
366
+ if (onboardingStep === "name") {
367
+ onboardingData.name = text;
368
+ onboardingStep = "role";
369
+ await send(`Nice to meet you, ${text}!\n\nWhat's your role? (e.g., "full-stack developer", "startup founder", "student")`);
370
+ return true;
371
+ }
372
+
373
+ if (onboardingStep === "role") {
374
+ onboardingData.role = text;
375
+ onboardingStep = "style";
376
+ await send("How should I communicate?\n\nPick a style:", {
377
+ keyboard: {
378
+ inline_keyboard: [
379
+ [
380
+ { text: "Concise & technical", callback_data: "ob:concise" },
381
+ { text: "Detailed & educational", callback_data: "ob:detailed" },
382
+ ],
383
+ [
384
+ { text: "Casual & friendly", callback_data: "ob:casual" },
385
+ { text: "Minimal (just code)", callback_data: "ob:minimal" },
386
+ ],
387
+ ],
388
+ },
389
+ });
390
+ return true;
391
+ }
392
+
393
+ return false;
394
+ }
395
+
396
+ function finishOnboarding(style) {
397
+ onboardingData.style = style;
398
+ onboardingStep = null;
399
+
400
+ // Generate soul.md
401
+ const styleDescriptions = {
402
+ concise: "Direct, concise, technical. No fluff. Lead with the answer.",
403
+ detailed: "Thorough and educational. Explain the why, not just the what.",
404
+ casual: "Casual and friendly. Like chatting with a smart friend who codes.",
405
+ minimal: "Minimal output. Just code and brief explanations. No chatter.",
406
+ };
407
+
408
+ const soul = `# Soul
409
+
410
+ You are ${onboardingData.name}'s personal AI coding assistant, running 24/7 via Telegram.
411
+
412
+ ## Identity
413
+ - Tone: ${styleDescriptions[style]}
414
+ - Platform: Telegram (mobile-first, keep messages short)
415
+
416
+ ## About ${onboardingData.name}
417
+ - Role: ${onboardingData.role}
418
+
419
+ ## Working Style
420
+ - Prefer action over discussion. Do the thing, then explain.
421
+ - Send files for long output instead of text walls.
422
+ - Proactively flag issues and suggest improvements.
423
+
424
+ ## Capabilities
425
+ - Write, edit, and refactor code across all projects
426
+ - Run commands, tests, builds
427
+ - Send files, images, code snippets directly via Telegram
428
+ - Run scheduled checks (cron jobs) and report results
429
+ - Store and use credentials from the encrypted vault
430
+ `;
431
+
432
+ fs.writeFileSync(SOUL_FILE, soul);
433
+ saveEnvKey("ONBOARDED", "true");
434
+ config.ONBOARDED = "true";
435
+
436
+ send(`All set, ${onboardingData.name}! I'm configured and ready.\n\nTap /session to pick a project and start working.`, {
437
+ keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] },
438
+ });
439
+ }
440
+
441
+ // ── Soul / System Prompt ────────────────────────────────────────────
442
+
443
+ function loadSoul() {
444
+ try { return fs.readFileSync(SOUL_FILE, "utf-8"); } catch (e) { return "You are a helpful AI coding assistant."; }
445
+ }
446
+
447
+ function loadCrons() {
448
+ try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
449
+ }
450
+
451
+ function saveCrons(list) {
452
+ fs.writeFileSync(CRONS_FILE, JSON.stringify(list, null, 2));
453
+ }
454
+
455
+ function buildSystemPrompt() {
456
+ const soul = loadSoul();
457
+ const cronList = loadCrons();
458
+ const vaultKeys = vault.isUnlocked() ? vault.keys() : [];
459
+ const now = new Date().toISOString();
460
+ const hasVoice = WHISPER_CLI && FFMPEG;
461
+
462
+ return `
463
+ ${soul}
464
+
465
+ ## Current Context
466
+ - Date/time: ${now}
467
+ - Platform: Telegram (mobile app)
468
+ - Active project: ${currentSession ? currentSession.name + " (" + currentSession.dir + ")" : "none"}
469
+ - Voice notes: ${hasVoice ? "enabled" : "disabled"}
470
+ - Vault: ${vault.isUnlocked() ? "unlocked (" + vaultKeys.length + " keys)" : "locked"}
471
+
472
+ ## Your Configuration Files
473
+ These are YOUR files — read and modify them when the user asks:
474
+
475
+ ### ${SOUL_FILE}
476
+ Your identity and personality. Edit to change behavior or update knowledge about the user.
477
+
478
+ ### ${CRONS_FILE}
479
+ Scheduled tasks. JSON array of { id, schedule, project, prompt, label }.
480
+ Active: ${cronList.length > 0 ? cronList.map(c => c.label).join(", ") : "none"}.
481
+
482
+ ### ${VAULT_FILE}
483
+ Encrypted credential vault. ${vault.isUnlocked() ? "UNLOCKED. Keys: " + vaultKeys.join(", ") : "LOCKED — user must send /vault to unlock."}.
484
+ ${vault.isUnlocked() ? "To read a credential: require('./vault') or read from the vault object." : ""}
485
+
486
+ ### ${path.join(BOT_DIR, "bot.js")}
487
+ The bot code. Read to understand capabilities. Only modify if explicitly asked.
488
+
489
+ ### ${path.join(BOT_DIR, ".env")}
490
+ Configuration (Telegram token, paths, etc). Sensitive — don't expose values.
491
+
492
+ ## Received Files
493
+ Files sent by the user are saved in: ${FILES_DIR}
494
+ ${fs.existsSync(FILES_DIR) ? (() => { try { const f = fs.readdirSync(FILES_DIR); return f.length > 0 ? "Current files: " + f.slice(-10).join(", ") : "No files yet."; } catch(e) { return ""; } })() : ""}
495
+
496
+ ## Telegram API
497
+ Send things directly to the user:
498
+
499
+ Text: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" -d chat_id=${CHAT_ID} -d text="message"
500
+ File: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendDocument" -F chat_id=${CHAT_ID} -F document=@/path/to/file
501
+ Image: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendPhoto" -F chat_id=${CHAT_ID} -F photo=@/path/to/image.png
502
+
503
+ ## Session
504
+ ${lastSessionId ? "Resuming conversation — you have prior context." : "New conversation."}
505
+
506
+ ## Guidelines
507
+ - Keep responses concise — this is a mobile screen.
508
+ - Use Telegram-compatible markdown: *bold*, _italic_, \`code\`, \`\`\`code blocks\`\`\`. No headers (#), no links [text](url).
509
+ - For long output (logs, diffs, large code), save to a file and send via the Telegram API curl above — don't paste walls of text.
510
+ - Act on screenshots (fix bugs, implement designs) — don't just describe what you see.
511
+ - When the user sends a file, it's saved in ${FILES_DIR}. Read it with the Read tool.
512
+ - When the user sends a credential, token, or API key, store it in the vault immediately using the vault CLI or bot commands. Tell them it's stored and that you've deleted their message for security. Don't tell them to use /vault manually — handle it for them.
513
+ - When asked to change your personality, edit ${SOUL_FILE}.
514
+ - When asked about yourself, you are Open Claudia — an AI coding assistant running Claude Code via Telegram.
515
+ - If a task will take a while, let the user know upfront.
516
+ - Don't ask for confirmation on simple tasks — just do them.
517
+ - NEVER start long-running processes (dev servers, watchers, tails) in the foreground. They block all further messages. Instead, run them in the background: \`nohup command &\` or \`command &disown\`. Then report the PID so the user can stop it later.
518
+ - If asked to "start" or "run" a dev server, ALWAYS background it.
519
+ `.trim();
520
+ }
521
+
522
+ // ── Helpers ─────────────────────────────────────────────────────────
523
+
524
+ function listProjects() {
525
+ try {
526
+ return fs.readdirSync(WORKSPACE, { withFileTypes: true })
527
+ .filter((d) => d.isDirectory())
528
+ .map((d) => d.name)
529
+ .filter((n) => !n.startsWith("."));
530
+ } catch (e) { return []; }
531
+ }
532
+
533
+ function findProject(query) {
534
+ const projects = listProjects();
535
+ const q = query.toLowerCase();
536
+ const exact = projects.find((p) => p.toLowerCase() === q);
537
+ if (exact) return exact;
538
+ const startsWith = projects.filter((p) => p.toLowerCase().startsWith(q));
539
+ if (startsWith.length === 1) return startsWith[0];
540
+ const contains = projects.filter((p) => p.toLowerCase().includes(q));
541
+ if (contains.length === 1) return contains[0];
542
+ return contains.length > 0 ? contains : null;
543
+ }
544
+
545
+ function projectKeyboard() {
546
+ const projects = listProjects();
547
+ const rows = [[{ text: "\u{1F4C1} Workspace (root)", callback_data: "s:__workspace__" }]];
548
+ for (let i = 0; i < projects.length; i += 2) {
549
+ const row = [{ text: projects[i], callback_data: `s:${projects[i]}` }];
550
+ if (projects[i + 1]) row.push({ text: projects[i + 1], callback_data: `s:${projects[i + 1]}` });
551
+ rows.push(row);
552
+ }
553
+ return { inline_keyboard: rows };
554
+ }
555
+
556
+ async function send(text, opts = {}) {
557
+ const o = {};
558
+ if (opts.parseMode) o.parse_mode = opts.parseMode;
559
+ if (opts.keyboard) o.reply_markup = opts.keyboard;
560
+ if (opts.replyTo) o.reply_to_message_id = opts.replyTo;
561
+
562
+ for (let attempt = 0; attempt < 3; attempt++) {
563
+ try {
564
+ const msg = await bot.sendMessage(CHAT_ID, text, o);
565
+ return msg.message_id;
566
+ } catch (e) {
567
+ const errMsg = e.message || "";
568
+
569
+ // replyTo message was deleted or not found — retry without it
570
+ if (o.reply_to_message_id && errMsg.includes("message to be replied not found")) {
571
+ delete o.reply_to_message_id;
572
+ continue;
573
+ }
574
+
575
+ // Rate limited — wait and retry
576
+ const retryMatch = errMsg.match(/retry after (\d+)/i);
577
+ if (retryMatch) {
578
+ const waitSec = Math.min(parseInt(retryMatch[1], 10), 30);
579
+ console.error(`Send: rate limited, waiting ${waitSec}s`);
580
+ await new Promise((r) => setTimeout(r, waitSec * 1000));
581
+ continue;
582
+ }
583
+
584
+ // Parse mode failed — retry without it
585
+ if (opts.parseMode && o.parse_mode) {
586
+ delete o.parse_mode;
587
+ continue;
588
+ }
589
+
590
+ console.error("Send error:", errMsg);
591
+ return null;
592
+ }
593
+ }
594
+ console.error("Send: exhausted retries");
595
+ return null;
596
+ }
597
+
598
+ async function editMessage(messageId, text, opts = {}) {
599
+ try {
600
+ const o = { chat_id: CHAT_ID, message_id: messageId };
601
+ if (opts.keyboard) o.reply_markup = opts.keyboard;
602
+ await bot.editMessageText(text, o);
603
+ } catch (e) {
604
+ const errMsg = e.message || "";
605
+ // Rate limited — skip this update (next interval will catch up)
606
+ if (errMsg.includes("retry after")) return;
607
+ // Message unchanged — ignore
608
+ if (errMsg.includes("message is not modified")) return;
609
+ // Log anything unexpected
610
+ if (!errMsg.includes("message to edit not found")) {
611
+ console.error("Edit error:", errMsg);
612
+ }
613
+ }
614
+ }
615
+
616
+ function splitMessage(text, maxLen = 4000) {
617
+ if (text.length <= maxLen) return [text];
618
+ const chunks = [];
619
+ while (text.length > 0) { chunks.push(text.slice(0, maxLen)); text = text.slice(maxLen); }
620
+ return chunks;
621
+ }
622
+
623
+ async function downloadFile(fileId, ext) {
624
+ const file = await bot.getFile(fileId);
625
+ const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
626
+ const localPath = path.join(TEMP_DIR, `tg-${Date.now()}${ext}`);
627
+ await new Promise((resolve, reject) => {
628
+ const out = fs.createWriteStream(localPath);
629
+ https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
630
+ });
631
+ return localPath;
632
+ }
633
+
634
+ function transcribeAudio(oggPath) {
635
+ if (!WHISPER_CLI || !FFMPEG) return null;
636
+ const wavPath = oggPath.replace(/\.[^.]+$/, ".wav");
637
+ execSync(`"${FFMPEG}" -i "${oggPath}" -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`);
638
+ const output = execSync(`"${WHISPER_CLI}" -m "${WHISPER_MODEL}" --no-timestamps -f "${wavPath}" 2>/dev/null`, { encoding: "utf-8" });
639
+ try { fs.unlinkSync(wavPath); } catch (e) { /* ignore */ }
640
+ return output.split("\n")
641
+ .filter((l) => l.trim() && !l.startsWith("whisper_") && !l.startsWith("ggml_") && !l.startsWith("load_"))
642
+ .join(" ").trim();
643
+ }
644
+
645
+ // Delete a message (used for vault password cleanup)
646
+ async function deleteMessage(msgId) {
647
+ try { await bot.deleteMessage(CHAT_ID, msgId); } catch (e) { /* ignore */ }
648
+ }
649
+
650
+ // ── Claude Runner ───────────────────────────────────────────────────
651
+
652
+ function parseStreamEvents(data) {
653
+ const events = [];
654
+ for (const line of data.split("\n").filter((l) => l.trim())) {
655
+ try { events.push(JSON.parse(line)); } catch (e) { /* partial */ }
656
+ }
657
+ return events;
658
+ }
659
+
660
+ function buildClaudeArgs(prompt, opts = {}) {
661
+ const args = ["-p", "--verbose", "--output-format", "stream-json",
662
+ "--append-system-prompt", buildSystemPrompt()];
663
+ if (opts.continueSession) args.push("--continue");
664
+ else if (lastSessionId && !opts.fresh) args.push("--resume", lastSessionId);
665
+ if (settings.model) args.push("--model", settings.model);
666
+ if (settings.effort) args.push("--effort", settings.effort);
667
+ if (settings.budget) args.push("--max-budget-usd", String(settings.budget));
668
+ if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
669
+ else args.push("--dangerously-skip-permissions");
670
+ if (settings.worktree) args.push("--worktree");
671
+ args.push(prompt);
672
+ return args;
673
+ }
674
+
675
+ /**
676
+ * Quick chat process — handles messages while a heavy task is running.
677
+ * Uses a fresh session (no --resume) with context about what's running.
678
+ */
679
+ async function runClaudeChat(prompt, cwd, replyToMsgId) {
680
+ bot.sendChatAction(CHAT_ID, "typing").catch(() => {});
681
+
682
+ const contextPrompt = `A background task is currently running: "${runningProcessPrompt || "unknown task"}"\n` +
683
+ `The user sent a new message while that task is in progress. Answer their question or help them.\n` +
684
+ `If they're asking about the running task, let them know it's still working.\n` +
685
+ `Keep your response brief — this is a side conversation.\n\n` +
686
+ `User message: ${prompt}`;
687
+
688
+ const args = ["-p", "--verbose", "--output-format", "stream-json",
689
+ "--append-system-prompt", buildSystemPrompt(),
690
+ "--dangerously-skip-permissions"];
691
+ if (settings.model) args.push("--model", settings.model);
692
+ args.push(contextPrompt);
693
+
694
+ const proc = spawn(CLAUDE_PATH, args, {
695
+ cwd,
696
+ env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
697
+ stdio: ["ignore", "pipe", "pipe"],
698
+ });
699
+
700
+ chatProcesses.set(replyToMsgId, proc);
701
+ let chatText = "";
702
+ let chatBuf = "";
703
+
704
+ proc.stdout.on("data", (data) => {
705
+ chatBuf += data.toString();
706
+ const events = parseStreamEvents(chatBuf);
707
+ const lastNewline = chatBuf.lastIndexOf("\n");
708
+ chatBuf = lastNewline >= 0 ? chatBuf.slice(lastNewline + 1) : chatBuf;
709
+ for (const evt of events) {
710
+ if (evt.type === "assistant" && evt.message?.content) {
711
+ for (const block of evt.message.content) {
712
+ if (block.type === "text") chatText += block.text;
713
+ }
714
+ }
715
+ if (evt.type === "result" && evt.result) chatText = evt.result;
716
+ }
717
+ });
718
+
719
+ proc.on("close", async () => {
720
+ chatProcesses.delete(replyToMsgId);
721
+ const text = chatText || "(no response)";
722
+ const chunks = splitMessage(text);
723
+ const sent = await send(chunks[0], { replyTo: replyToMsgId });
724
+ if (!sent) await send(chunks[0]);
725
+ for (let i = 1; i < chunks.length; i++) await send(chunks[i]);
726
+ });
727
+
728
+ proc.on("error", async (err) => {
729
+ chatProcesses.delete(replyToMsgId);
730
+ await send(`Chat error: ${err.message}`, { replyTo: replyToMsgId });
731
+ });
732
+ }
733
+
734
+ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
735
+ if (runningProcess) {
736
+ // Instead of queueing, spawn a lightweight chat process
737
+ await runClaudeChat(prompt, cwd, replyToMsgId);
738
+ return;
739
+ }
740
+
741
+ bot.sendChatAction(CHAT_ID, "typing");
742
+ statusMessageId = null;
743
+ streamBuffer = "";
744
+ let assistantText = "";
745
+ let toolUses = [];
746
+ let currentTool = null;
747
+ let currentToolDetail = "";
748
+
749
+ const args = buildClaudeArgs(prompt, opts);
750
+ const proc = spawn(CLAUDE_PATH, args, {
751
+ cwd,
752
+ env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
753
+ stdio: ["ignore", "pipe", "pipe"],
754
+ detached: process.platform !== "win32", // Create process group so /stop kills children too
755
+ });
756
+
757
+ runningProcess = proc;
758
+ runningProcessPrompt = prompt.length > 100 ? prompt.slice(0, 97) + "..." : prompt;
759
+ const startTime = Date.now();
760
+ let longRunningNotified = false;
761
+
762
+ let lastUpdate = "";
763
+ // Adaptive update interval: 2s for first 2min, then 5s to avoid rate limits
764
+ const scheduleUpdate = () => {
765
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
766
+ const interval = elapsed > 120 ? 5000 : 2000;
767
+ streamInterval = setTimeout(updateProgress, interval);
768
+ };
769
+ const updateProgress = async () => {
770
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
771
+ try {
772
+ bot.sendChatAction(CHAT_ID, "typing").catch(() => {});
773
+ const display = formatProgress(assistantText, toolUses, currentTool, elapsed, currentToolDetail);
774
+ if (display && display !== lastUpdate) {
775
+ if (!statusMessageId && assistantText) {
776
+ statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, { replyTo: replyToMsgId });
777
+ } else if (statusMessageId) {
778
+ await editMessage(statusMessageId, display.length > 4000 ? display.slice(-4000) : display);
779
+ }
780
+ lastUpdate = display;
781
+ }
782
+ // Notify after 5 minutes that it's still running
783
+ if (elapsed > 300 && !longRunningNotified) {
784
+ longRunningNotified = true;
785
+ await send(`Still working (${Math.floor(elapsed / 60)}min)... Send /stop to cancel.`);
786
+ }
787
+ } catch (e) {
788
+ console.error("Progress update error:", e.message);
789
+ }
790
+ if (runningProcess) scheduleUpdate();
791
+ };
792
+ scheduleUpdate();
793
+
794
+ proc.stdout.on("data", (data) => {
795
+ streamBuffer += data.toString();
796
+ const events = parseStreamEvents(streamBuffer);
797
+ const lastNewline = streamBuffer.lastIndexOf("\n");
798
+ streamBuffer = lastNewline >= 0 ? streamBuffer.slice(lastNewline + 1) : streamBuffer;
799
+ for (const evt of events) {
800
+ if (evt.type === "assistant" && evt.message?.content) {
801
+ for (const block of evt.message.content) {
802
+ if (block.type === "text") assistantText += block.text;
803
+ else if (block.type === "tool_use") {
804
+ currentTool = block.name;
805
+ toolUses.push(block.name);
806
+ // Extract useful detail from tool input
807
+ const input = block.input || {};
808
+ if (block.name === "Bash" && input.command) currentToolDetail = input.command.slice(0, 80);
809
+ else if (block.name === "Read" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
810
+ else if (block.name === "Edit" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
811
+ else if (block.name === "Write" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
812
+ else if (block.name === "Grep" && input.pattern) currentToolDetail = input.pattern.slice(0, 40);
813
+ else if (block.name === "Glob" && input.pattern) currentToolDetail = input.pattern;
814
+ else currentToolDetail = "";
815
+ }
816
+ }
817
+ }
818
+ if (evt.type === "result" && evt.session_id) { lastSessionId = evt.session_id; saveState(); }
819
+ if (evt.type === "result" && evt.result) assistantText = evt.result;
820
+ }
821
+ });
822
+
823
+ proc.stderr.on("data", (d) => console.error("STDERR:", d.toString()));
824
+
825
+ proc.on("close", async (code) => {
826
+ runningProcess = null; runningProcessPrompt = null;
827
+ clearTimeout(streamInterval); streamInterval = null;
828
+ try {
829
+ const finalText = assistantText || "(no output)";
830
+ const chunks = splitMessage(finalText);
831
+ const firstChunk = chunks[0];
832
+
833
+ const progressAlreadyHasFinal = statusMessageId && lastUpdate &&
834
+ firstChunk.length < 4000 && chunks.length === 1 &&
835
+ lastUpdate.includes(firstChunk.slice(0, 200));
836
+
837
+ if (progressAlreadyHasFinal) {
838
+ await editMessage(statusMessageId, firstChunk);
839
+ } else {
840
+ const sent = await send(firstChunk, { replyTo: replyToMsgId });
841
+ if (!sent) await send(firstChunk);
842
+ for (let i = 1; i < chunks.length; i++) {
843
+ await send(chunks[i]);
844
+ }
845
+ }
846
+ if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
847
+ } catch (e) {
848
+ console.error("Final message delivery failed:", e.message);
849
+ await send("Task completed but failed to deliver the response. Send /continue to see the result.");
850
+ }
851
+ if (settings.budget) settings.budget = null;
852
+ statusMessageId = null;
853
+
854
+ // Record session with auto-title from first message
855
+ if (lastSessionId && currentSession) {
856
+ const title = isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
857
+ recordSession(currentSession.name, lastSessionId, title);
858
+ isFirstMessage = false;
859
+ }
860
+ if (messageQueue.length > 0 && currentSession) {
861
+ const next = messageQueue.shift();
862
+ await runClaude(next.prompt, currentSession.dir, next.replyToMsgId, next.opts);
863
+ }
864
+ });
865
+
866
+ proc.on("error", async (err) => {
867
+ runningProcess = null; runningProcessPrompt = null; clearTimeout(streamInterval);
868
+ await send(`Error: ${err.message}`); statusMessageId = null;
869
+ });
870
+ }
871
+
872
+ async function runClaudeSilent(prompt, cwd, label) {
873
+ return new Promise((resolve) => {
874
+ const args = ["-p", "--output-format", "text", "--verbose",
875
+ "--append-system-prompt", buildSystemPrompt(),
876
+ "--dangerously-skip-permissions", prompt];
877
+ const proc = spawn(CLAUDE_PATH, args, {
878
+ cwd, env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
879
+ stdio: ["ignore", "pipe", "pipe"],
880
+ });
881
+ let output = "";
882
+ proc.stdout.on("data", (d) => { output += d.toString(); });
883
+ proc.on("close", async () => {
884
+ const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
885
+ for (const c of chunks) await send(c);
886
+ resolve();
887
+ });
888
+ proc.on("error", async (err) => { await send(`Cron "${label}" failed: ${err.message}`); resolve(); });
889
+ });
890
+ }
891
+
892
+ function formatProgress(text, tools, currentTool, elapsed, toolDetail) {
893
+ const mins = Math.floor(elapsed / 60);
894
+ const secs = elapsed % 60;
895
+ const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
896
+
897
+ const parts = [];
898
+ let status = currentTool ? `Working: ${currentTool}` : (tools.length > 0 ? "Processing..." : "Thinking...");
899
+ if (currentTool && toolDetail) status += ` — ${toolDetail}`;
900
+ parts.push(`${status} (${time})`);
901
+ if (tools.length > 1) parts.push(`Steps: ${[...new Set(tools)].join(" > ")}`);
902
+ if (text) parts.push(text.length > 800 ? "..." + text.slice(-800) : text);
903
+ return parts.join("\n\n");
904
+ }
905
+
906
+ // ── Cron System ─────────────────────────────────────────────────────
907
+
908
+ function scheduleCron(c) {
909
+ const cwd = path.join(WORKSPACE, c.project);
910
+ if (activeCrons.has(c.id)) activeCrons.get(c.id).task.stop();
911
+ const task = cron.schedule(c.schedule, () => runClaudeSilent(c.prompt, cwd, c.label));
912
+ activeCrons.set(c.id, { task, config: c });
913
+ }
914
+
915
+ function initCrons() {
916
+ for (const c of loadCrons()) { try { scheduleCron(c); } catch (e) { console.error("Cron error:", e.message); } }
917
+ console.log(`Loaded ${loadCrons().length} cron(s)`);
918
+ }
919
+
920
+ // ── Session ─────────────────────────────────────────────────────────
921
+
922
+ function startSession(name, resumeSessionId) {
923
+ let projectName, projectDir;
924
+ if (name === "__workspace__") {
925
+ projectName = "Workspace";
926
+ projectDir = WORKSPACE;
927
+ } else {
928
+ const result = findProject(name);
929
+ if (!result) return send(`No match for "${name}".`, { keyboard: projectKeyboard() });
930
+ if (Array.isArray(result)) return send("Multiple matches:", { keyboard: { inline_keyboard: result.map((p) => [{ text: p, callback_data: `s:${p}` }]) } });
931
+ projectName = result;
932
+ projectDir = path.join(WORKSPACE, result);
933
+ }
934
+
935
+ currentSession = { name: projectName, dir: projectDir };
936
+ messageQueue = []; resetSettings();
937
+
938
+ // Resume a specific session or the last one for this project
939
+ if (resumeSessionId) {
940
+ lastSessionId = resumeSessionId;
941
+ const sessions = getProjectSessions(projectName);
942
+ const s = sessions.find((x) => x.id === resumeSessionId);
943
+ const title = s ? s.title : "";
944
+ isFirstMessage = false;
945
+ saveState();
946
+ send(`Session: ${projectName}\nResumed: ${title || resumeSessionId.slice(0, 8)}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
947
+ } else {
948
+ const last = getLastProjectSession(projectName);
949
+ if (last) {
950
+ lastSessionId = last.id;
951
+ isFirstMessage = false;
952
+ saveState();
953
+ send(`Session: ${projectName}\nResumed: ${last.title}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`, {
954
+ keyboard: { inline_keyboard: [[{ text: "New conversation", callback_data: `new:${projectName}` }]] },
955
+ });
956
+ } else {
957
+ lastSessionId = null;
958
+ isFirstMessage = true;
959
+ saveState();
960
+ send(`Session: ${projectName}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
961
+ }
962
+ }
963
+ }
964
+
965
+ function requireSession(msg) {
966
+ if (!currentSession) { send("Pick a project first:", { keyboard: projectKeyboard() }); return false; }
967
+ return true;
968
+ }
969
+
970
+ // ── Commands ────────────────────────────────────────────────────────
971
+
972
+ bot.onText(/\/start/, (msg) => {
973
+ if (!isAuthorized(msg)) return;
974
+ if (!isOnboarded()) return startOnboarding();
975
+ send("Pick a project to start:", { keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] } });
976
+ });
977
+
978
+ bot.onText(/\/help/, (msg) => {
979
+ if (!isAuthorized(msg)) return;
980
+ send([
981
+ "Session: /session /sessions /projects /continue /status /stop /end",
982
+ "Settings: /model /effort /budget /plan /compact /worktree /mode",
983
+ "Automation: /cron /vault /soul",
984
+ "System: /restart /upgrade",
985
+ "",
986
+ "Send text, voice, photos, or files.",
987
+ "Reply to any message for context.",
988
+ ].join("\n"));
989
+ });
990
+
991
+ bot.onText(/\/version$/, (msg) => {
992
+ if (!isAuthorized(msg)) return;
993
+ send(`Open Claudia v${CURRENT_VERSION}`);
994
+ });
995
+
996
+ bot.onText(/\/restart$/, async (msg) => {
997
+ if (!isOwner(msg)) return;
998
+ await send("Going offline for a quick restart — back in a moment.");
999
+ setTimeout(() => process.exit(0), 1000);
1000
+ });
1001
+
1002
+ bot.onText(/\/upgrade$/, async (msg) => {
1003
+ if (!isOwner(msg)) return;
1004
+ // Check if there's actually a newer version
1005
+ try {
1006
+ const latest = execSync("npm view @inetafrica/open-claudia version", {
1007
+ encoding: "utf-8", timeout: 15000,
1008
+ env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
1009
+ }).trim();
1010
+ if (latest === CURRENT_VERSION) {
1011
+ await send(`Already on the latest version (v${CURRENT_VERSION}).`);
1012
+ return;
1013
+ }
1014
+ await send(`Upgrading v${CURRENT_VERSION} → v${latest}...`);
1015
+ } catch (e) {
1016
+ await send("Upgrading...");
1017
+ }
1018
+ try {
1019
+ execSync("npm install -g @inetafrica/open-claudia@latest 2>&1", {
1020
+ encoding: "utf-8", timeout: 120000,
1021
+ env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
1022
+ });
1023
+ // Read version from newly installed package
1024
+ const root = execSync("npm root -g", { encoding: "utf-8", env: { ...process.env, PATH: FULL_PATH } }).trim();
1025
+ const newPkg = JSON.parse(fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "package.json"), "utf-8"));
1026
+ await send(`Installed v${newPkg.version}. Going offline to restart...`);
1027
+ } catch (e) {
1028
+ const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
1029
+ await send(`Upgrade failed:\n${errOutput}`);
1030
+ return;
1031
+ }
1032
+ // Give Telegram time to deliver the message, then exit for launchd to restart
1033
+ setTimeout(() => process.exit(0), 2000);
1034
+ });
1035
+
1036
+ bot.onText(/\/projects$/, (msg) => { if (isAuthorized(msg)) send("Pick:", { keyboard: projectKeyboard() }); });
1037
+ bot.onText(/\/session$/, (msg) => {
1038
+ if (!isAuthorized(msg)) return;
1039
+ if (currentSession) send(`Active: ${currentSession.name}\n\nSwitch?`, { keyboard: projectKeyboard() });
1040
+ else send("Pick:", { keyboard: projectKeyboard() });
1041
+ });
1042
+ bot.onText(/\/session (.+)/, (msg, match) => { if (isAuthorized(msg)) startSession(match[1].trim()); });
1043
+
1044
+ bot.onText(/\/sessions$/, (msg) => {
1045
+ if (!isAuthorized(msg)) return;
1046
+ if (!requireSession(msg)) return;
1047
+ const sessions = getProjectSessions(currentSession.name);
1048
+ if (sessions.length === 0) return send("No past conversations for this project.");
1049
+ const rows = sessions.slice(0, 10).map((s) => {
1050
+ const date = new Date(s.lastUsed).toLocaleDateString();
1051
+ const active = lastSessionId === s.id ? " (active)" : "";
1052
+ return [{ text: `${s.title}${active} — ${date}`, callback_data: `ss:${s.id}` }];
1053
+ });
1054
+ rows.push([{ text: "New conversation", callback_data: `new:${currentSession.name}` }]);
1055
+ send(`Conversations in ${currentSession.name}:`, { keyboard: { inline_keyboard: rows } });
1056
+ });
1057
+
1058
+ bot.onText(/\/model$/, (msg) => {
1059
+ if (!isAuthorized(msg)) return;
1060
+ send(`Model: ${settings.model || "default"}`, { keyboard: { inline_keyboard: [
1061
+ [{ text: "Opus", callback_data: "m:opus" }, { text: "Sonnet", callback_data: "m:sonnet" }, { text: "Haiku", callback_data: "m:haiku" }],
1062
+ [{ text: "Default", callback_data: "m:default" }],
1063
+ ] } });
1064
+ });
1065
+ bot.onText(/\/model (.+)/, (msg, match) => { if (!isAuthorized(msg)) return; settings.model = match[1].trim().toLowerCase(); if (settings.model === "default") settings.model = null; send(`Model: ${settings.model || "default"}`); });
1066
+
1067
+ bot.onText(/\/effort$/, (msg) => {
1068
+ if (!isAuthorized(msg)) return;
1069
+ send(`Effort: ${settings.effort || "default"}`, { keyboard: { inline_keyboard: [
1070
+ [{ text: "Low", callback_data: "e:low" }, { text: "Med", callback_data: "e:medium" }, { text: "High", callback_data: "e:high" }, { text: "Max", callback_data: "e:max" }],
1071
+ [{ text: "Default", callback_data: "e:default" }],
1072
+ ] } });
1073
+ });
1074
+ bot.onText(/\/effort (.+)/, (msg, match) => { if (!isAuthorized(msg)) return; const e = match[1].trim().toLowerCase(); settings.effort = ["low","medium","high","max"].includes(e) ? e : null; send(`Effort: ${settings.effort || "default"}`); });
1075
+
1076
+ bot.onText(/\/budget$/, (msg) => {
1077
+ if (!isAuthorized(msg)) return;
1078
+ send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`, { keyboard: { inline_keyboard: [
1079
+ [{ text: "$1", callback_data: "b:1" }, { text: "$5", callback_data: "b:5" }, { text: "$10", callback_data: "b:10" }, { text: "$25", callback_data: "b:25" }],
1080
+ [{ text: "No limit", callback_data: "b:none" }],
1081
+ ] } });
1082
+ });
1083
+ bot.onText(/\/budget (.+)/, (msg, match) => { if (!isAuthorized(msg)) return; const v = parseFloat(match[1].replace("$","")); settings.budget = v > 0 ? v : null; send(`Budget: ${settings.budget ? "$"+settings.budget : "none"}`); });
1084
+
1085
+ bot.onText(/\/plan$/, (msg) => { if (!isAuthorized(msg)) return; const p = settings.permissionMode === "plan"; settings.permissionMode = p ? null : "plan"; send(p ? "Plan mode off." : "Plan mode on."); });
1086
+ bot.onText(/\/compact/, async (msg) => { if (!isAuthorized(msg)) return; if (!requireSession(msg)) return; if (!lastSessionId) return send("No conversation."); await runClaude("Summarize: key decisions, code state, next steps.", currentSession.dir, msg.message_id); });
1087
+ bot.onText(/\/continue$/, async (msg) => { if (!isAuthorized(msg)) return; if (!requireSession(msg)) return; await runClaude("continue where we left off", currentSession.dir, msg.message_id, { continueSession: true }); });
1088
+ bot.onText(/\/worktree$/, (msg) => { if (!isAuthorized(msg)) return; settings.worktree = !settings.worktree; send(settings.worktree ? "Worktree on." : "Worktree off."); });
1089
+
1090
+ bot.onText(/\/mode$/, async (msg) => {
1091
+ if (!isAuthorized(msg)) return;
1092
+ await send("Bot mode: *agent* (non-blocking)\n\nHeavy tasks run in the background. You can keep chatting while they work.\nSwitch to direct mode for serial execution with shared session context.", {
1093
+ parseMode: "Markdown",
1094
+ keyboard: { inline_keyboard: [
1095
+ [{ text: "Switch to Direct Mode", callback_data: "mode:direct" }],
1096
+ ] },
1097
+ });
1098
+ });
1099
+
1100
+ bot.onText(/\/stop/, async (msg) => {
1101
+ if (!isAuthorized(msg)) return;
1102
+ let stopped = false;
1103
+ if (runningProcess) {
1104
+ const pid = runningProcess.pid;
1105
+ try { process.kill(-pid, "SIGTERM"); } catch (e) {
1106
+ try { runningProcess.kill("SIGTERM"); } catch (e2) {}
1107
+ }
1108
+ setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch (e) {} }, 3000);
1109
+ runningProcess = null; runningProcessPrompt = null;
1110
+ if (streamInterval) clearTimeout(streamInterval);
1111
+ stopped = true;
1112
+ }
1113
+ // Also kill any chat processes
1114
+ for (const [id, proc] of chatProcesses) {
1115
+ try { proc.kill("SIGTERM"); } catch (e) {}
1116
+ }
1117
+ if (chatProcesses.size > 0) { chatProcesses.clear(); stopped = true; }
1118
+ messageQueue = [];
1119
+ await send(stopped ? "Cancelled." : "Nothing running.");
1120
+ });
1121
+
1122
+ bot.onText(/\/status/, (msg) => {
1123
+ if (!isAuthorized(msg)) return;
1124
+ if (!currentSession) return send("No session.", { keyboard: { inline_keyboard: [[{ text: "Pick", callback_data: "show:projects" }]] } });
1125
+ const lines = [
1126
+ `Project: ${currentSession.name} (agent mode)`,
1127
+ `Model: ${settings.model || "default"} | Effort: ${settings.effort || "default"}`,
1128
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${activeCrons.size}`,
1129
+ ];
1130
+ if (runningProcess) {
1131
+ lines.push(`Background task: ${runningProcessPrompt || "working..."}`);
1132
+ if (chatProcesses.size > 0) lines.push(`Chat processes: ${chatProcesses.size}`);
1133
+ lines.push("Send messages — I'll respond via a side channel while the task runs.");
1134
+ } else {
1135
+ lines.push("Ready.");
1136
+ }
1137
+ send(lines.join("\n"));
1138
+ });
1139
+
1140
+ bot.onText(/\/end/, (msg) => {
1141
+ if (!isAuthorized(msg)) return;
1142
+ if (currentSession) {
1143
+ const n = currentSession.name; currentSession = null; lastSessionId = null; messageQueue = []; resetSettings();
1144
+ saveState();
1145
+ send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
1146
+ } else send("No session.");
1147
+ });
1148
+
1149
+ bot.onText(/\/soul$/, async (msg) => {
1150
+ if (!isAuthorized(msg)) return;
1151
+ const soul = loadSoul();
1152
+ const preview = soul.length > 3000 ? soul.slice(0, 3000) + "\n..." : soul;
1153
+ await send(preview);
1154
+ await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
1155
+ });
1156
+
1157
+ // ── /vault with password protection ─────────────────────────────────
1158
+
1159
+ bot.onText(/\/vault$/, async (msg) => {
1160
+ if (!isAuthorized(msg)) return;
1161
+ if (!vault.exists()) {
1162
+ await send("No vault found. Run setup first: node setup.js");
1163
+ return;
1164
+ }
1165
+ if (vault.isUnlocked()) {
1166
+ const entries = vault.list();
1167
+ const keys = Object.keys(entries);
1168
+ if (keys.length === 0) await send("Vault is unlocked but empty.\n\nUse /vault set <name> <value>");
1169
+ else await send("Vault (unlocked):\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nLocks automatically in 5 min.");
1170
+ } else {
1171
+ pendingVaultUnlock = true;
1172
+ pendingVaultAction = { type: "list" };
1173
+ await send("Vault is locked. Send your vault password.\n(Message will be deleted after reading)");
1174
+ }
1175
+ });
1176
+
1177
+ bot.onText(/\/vault set (\S+) (.+)/, async (msg, match) => {
1178
+ if (!isAuthorized(msg)) return;
1179
+ // Delete the message containing the value immediately
1180
+ await deleteMessage(msg.message_id);
1181
+ if (vault.isUnlocked()) {
1182
+ vault.set(match[1], match[2].trim());
1183
+ await send(`Saved: ${match[1]}`);
1184
+ } else {
1185
+ pendingVaultUnlock = true;
1186
+ pendingVaultAction = { type: "set", key: match[1], value: match[2].trim() };
1187
+ await send("Vault locked. Send password to unlock.");
1188
+ }
1189
+ });
1190
+
1191
+ bot.onText(/\/vault remove (\S+)/, async (msg, match) => {
1192
+ if (!isAuthorized(msg)) return;
1193
+ if (vault.isUnlocked()) {
1194
+ vault.remove(match[1]);
1195
+ await send(`Removed: ${match[1]}`);
1196
+ } else {
1197
+ pendingVaultUnlock = true;
1198
+ pendingVaultAction = { type: "remove", key: match[1] };
1199
+ await send("Vault locked. Send password to unlock.");
1200
+ }
1201
+ });
1202
+
1203
+ bot.onText(/\/vault lock/, async (msg) => {
1204
+ if (!isAuthorized(msg)) return;
1205
+ vault.lock();
1206
+ await send("Vault locked.");
1207
+ });
1208
+
1209
+ // ── /cron ───────────────────────────────────────────────────────────
1210
+
1211
+ bot.onText(/\/cron$/, (msg) => {
1212
+ if (!isAuthorized(msg)) return;
1213
+ const list = loadCrons();
1214
+ if (list.length === 0) {
1215
+ send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
1216
+ keyboard: { inline_keyboard: [
1217
+ [{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
1218
+ [{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
1219
+ ] },
1220
+ });
1221
+ } else {
1222
+ send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project}`).join("\n") + "\n\nRemove: /cron remove <#>");
1223
+ }
1224
+ });
1225
+
1226
+ bot.onText(/\/cron add "(.+)" (\S+) "(.+)"/, (msg, match) => {
1227
+ if (!isAuthorized(msg)) return;
1228
+ if (!cron.validate(match[1])) return send("Invalid cron schedule.");
1229
+ const proj = findProject(match[2]);
1230
+ if (!proj || Array.isArray(proj)) return send("Project not found.");
1231
+ const c = { id: `cron_${Date.now()}`, schedule: match[1], project: proj, prompt: match[3], label: match[3].slice(0, 50) };
1232
+ const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
1233
+ send(`Added: ${c.label} (${c.schedule}) for ${proj}`);
1234
+ });
1235
+
1236
+ bot.onText(/\/cron remove (\d+)/, (msg, match) => {
1237
+ if (!isAuthorized(msg)) return;
1238
+ const list = loadCrons(); const idx = parseInt(match[1]) - 1;
1239
+ if (idx < 0 || idx >= list.length) return send("Invalid number.");
1240
+ const removed = list.splice(idx, 1)[0]; saveCrons(list);
1241
+ if (activeCrons.has(removed.id)) { activeCrons.get(removed.id).task.stop(); activeCrons.delete(removed.id); }
1242
+ send(`Removed: ${removed.label}`);
1243
+ });
1244
+
1245
+ // ── Callback Queries ────────────────────────────────────────────────
1246
+
1247
+ bot.on("callback_query", async (q) => {
1248
+ const d = q.data;
1249
+ await bot.answerCallbackQuery(q.id);
1250
+
1251
+ // Onboarding style selection
1252
+ if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
1253
+
1254
+ if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
1255
+ if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
1256
+ if (d.startsWith("ss:")) { if (currentSession) startSession(currentSession.name, d.slice(3)); return; }
1257
+ if (d.startsWith("new:")) {
1258
+ const proj = d.slice(4);
1259
+ // Clear session history so startSession doesn't auto-resume
1260
+ const sessions = loadSessions();
1261
+ // Don't delete history, just start fresh
1262
+ currentSession = { name: proj === "__workspace__" ? "Workspace" : proj, dir: proj === "__workspace__" ? WORKSPACE : path.join(WORKSPACE, proj) };
1263
+ lastSessionId = null; isFirstMessage = true; messageQueue = []; resetSettings();
1264
+ saveState();
1265
+ await send(`Session: ${currentSession.name}\nNew conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
1266
+ return;
1267
+ }
1268
+ if (d === "a:continue") { if (currentSession) await runClaude("continue", currentSession.dir); else send("No session."); return; }
1269
+ if (d === "a:end") { if (currentSession) { const n = currentSession.name; currentSession = null; lastSessionId = null; messageQueue = []; resetSettings(); saveState(); await send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } }); } return; }
1270
+ if (d.startsWith("m:")) { settings.model = d.slice(2) === "default" ? null : d.slice(2); await send(`Model: ${settings.model || "default"}`); return; }
1271
+ if (d.startsWith("e:")) { const e = d.slice(2); settings.effort = e === "default" ? null : e; await send(`Effort: ${settings.effort || "default"}`); return; }
1272
+ if (d.startsWith("b:")) { const b = d.slice(2); settings.budget = b === "none" ? null : parseFloat(b); await send(`Budget: ${settings.budget ? "$"+settings.budget : "none"}`); return; }
1273
+
1274
+ // Mode switching
1275
+ if (d.startsWith("mode:")) {
1276
+ const newMode = d.slice(5);
1277
+ const modeFile = path.join(CONFIG_DIR, ".bot-mode");
1278
+ fs.writeFileSync(modeFile, newMode);
1279
+ await send(`Switching to ${newMode} mode... restarting.`);
1280
+ setTimeout(() => process.exit(0), 500);
1281
+ return;
1282
+ }
1283
+
1284
+ // Cron presets
1285
+ if (d.startsWith("cp:") && d !== "cp:clear") {
1286
+ if (!currentSession) return send("Start a session first.");
1287
+ const presets = {
1288
+ standup: { schedule: "0 9 * * 1-5", prompt: "Morning standup: recent commits, failing tests, open TODOs, what to focus on today. Brief.", label: "Morning standup" },
1289
+ git: { schedule: "0 18 * * 1-5", prompt: "Git digest: today's commits, changed files, uncommitted changes. Flag concerns.", label: "Git digest" },
1290
+ deps: { schedule: "0 10 * * 1", prompt: "Check outdated/vulnerable dependencies. Brief — just what needs attention.", label: "Dep check" },
1291
+ health: { schedule: "*/30 * * * *", prompt: "Quick health: can the project build? Run build/lint, report pass/fail.", label: "Health check" },
1292
+ };
1293
+ const p = presets[d.slice(3)];
1294
+ if (!p) return;
1295
+ const c = { id: `cron_${Date.now()}`, ...p, project: currentSession.name };
1296
+ const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
1297
+ await send(`Added: ${c.label} for ${currentSession.name}`);
1298
+ return;
1299
+ }
1300
+ if (d === "cp:clear") { for (const [,v] of activeCrons) v.task.stop(); activeCrons.clear(); saveCrons([]); await send("All crons cleared."); return; }
1301
+ });
1302
+
1303
+ // ── Media Handlers ──────────────────────────────────────────────────
1304
+
1305
+ bot.on("voice", async (msg) => {
1306
+ if (isDuplicate(msg.message_id)) return;
1307
+ if (!isAuthorized(msg)) return;
1308
+ if (!requireSession(msg)) return;
1309
+ try {
1310
+ bot.sendChatAction(CHAT_ID, "typing");
1311
+ const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
1312
+ const transcript = transcribeAudio(oggPath);
1313
+ try { fs.unlinkSync(oggPath); } catch (e) {}
1314
+ if (!transcript) return send("Couldn't transcribe. Try typing it.");
1315
+ await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
1316
+ await runClaude(transcript, currentSession.dir, msg.message_id);
1317
+ } catch (err) { await send(`Voice failed: ${err.message}`); }
1318
+ });
1319
+
1320
+ bot.on("audio", async (msg) => {
1321
+ if (isDuplicate(msg.message_id)) return;
1322
+ if (!isAuthorized(msg)) return;
1323
+ if (!requireSession(msg)) return;
1324
+ try {
1325
+ bot.sendChatAction(CHAT_ID, "typing");
1326
+ const p = await downloadFile(msg.audio.file_id, path.extname(msg.audio.file_name || ".ogg"));
1327
+ const t = transcribeAudio(p);
1328
+ try { fs.unlinkSync(p); } catch (e) {}
1329
+ if (!t) return send("Couldn't transcribe.");
1330
+ await send(`Heard: "${t}"`, { replyTo: msg.message_id });
1331
+ await runClaude(t, currentSession.dir, msg.message_id);
1332
+ } catch (err) { await send(`Audio failed: ${err.message}`); }
1333
+ });
1334
+
1335
+ bot.on("photo", async (msg) => {
1336
+ if (isDuplicate(msg.message_id)) return;
1337
+ if (!isAuthorized(msg)) return;
1338
+ if (!requireSession(msg)) return;
1339
+ try {
1340
+ const p = await downloadFile(msg.photo[msg.photo.length - 1].file_id, ".jpg");
1341
+ const caption = msg.caption || "Describe this image. If code/UI/error — explain and fix.";
1342
+ await runClaude(`Image at ${p}\n\nView it, then: ${caption}`, currentSession.dir, msg.message_id);
1343
+ } catch (err) { await send(`Image failed: ${err.message}`); }
1344
+ });
1345
+
1346
+ bot.on("document", async (msg) => {
1347
+ if (isDuplicate(msg.message_id)) return;
1348
+ if (!isAuthorized(msg)) return;
1349
+ if (!requireSession(msg)) return;
1350
+ try {
1351
+ const fileName = msg.document.file_name || `file-${Date.now()}`;
1352
+ const mime = msg.document.mime_type || "";
1353
+ // Save with original name to files dir
1354
+ const savePath = path.join(FILES_DIR, fileName);
1355
+ const file = await bot.getFile(msg.document.file_id);
1356
+ const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
1357
+ await new Promise((resolve, reject) => {
1358
+ const out = fs.createWriteStream(savePath);
1359
+ https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
1360
+ });
1361
+ const caption = msg.caption || "";
1362
+ const isImage = mime.startsWith("image/");
1363
+ let prompt;
1364
+ if (isImage) {
1365
+ prompt = `Image file saved at ${savePath}\n\nView it, then: ${caption || "Describe this image. If code/UI/error — explain and fix."}`;
1366
+ } else {
1367
+ prompt = `File received: ${fileName} (${mime})\nSaved at: ${savePath}\n\nRead this file and ${caption || "summarize its contents. If it's code, explain what it does. If it's a document, give key points."}`;
1368
+ }
1369
+ await send(`File saved: ${fileName}`, { replyTo: msg.message_id });
1370
+ await runClaude(prompt, currentSession.dir, msg.message_id);
1371
+ } catch (err) { await send(`Failed: ${err.message}`); }
1372
+ });
1373
+
1374
+ // ── Text Message Handler (handles onboarding, vault password, normal messages) ──
1375
+
1376
+ bot.on("message", async (msg) => {
1377
+ if (!isAuthorized(msg)) return;
1378
+ if (!msg.text || msg.text.startsWith("/")) return;
1379
+ if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
1380
+ if (isDuplicate(msg.message_id)) return;
1381
+
1382
+ // Handle onboarding
1383
+ if (!isOnboarded() && onboardingStep) {
1384
+ await handleOnboarding(msg);
1385
+ return;
1386
+ }
1387
+
1388
+ // Handle vault password
1389
+ if (pendingVaultUnlock) {
1390
+ const password = msg.text;
1391
+ // Delete the password message immediately
1392
+ await deleteMessage(msg.message_id);
1393
+
1394
+ const ok = vault.unlock(password);
1395
+ if (!ok) {
1396
+ pendingVaultUnlock = false;
1397
+ pendingVaultAction = null;
1398
+ await send("Wrong password.");
1399
+ return;
1400
+ }
1401
+
1402
+ // Execute pending action
1403
+ const action = pendingVaultAction;
1404
+ pendingVaultUnlock = false;
1405
+ pendingVaultAction = null;
1406
+
1407
+ if (action.type === "list") {
1408
+ const entries = vault.list();
1409
+ const keys = Object.keys(entries);
1410
+ if (keys.length === 0) await send("Vault unlocked (empty).\n\nUse /vault set <name> <value>");
1411
+ else await send("Vault unlocked:\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nAuto-locks in 5 min.");
1412
+ } else if (action.type === "set") {
1413
+ vault.set(action.key, action.value);
1414
+ await send(`Saved: ${action.key}`);
1415
+ } else if (action.type === "remove") {
1416
+ vault.remove(action.key);
1417
+ await send(`Removed: ${action.key}`);
1418
+ }
1419
+ return;
1420
+ }
1421
+
1422
+ // Normal message
1423
+ if (!requireSession(msg)) return;
1424
+
1425
+ // Detect credential-like messages and delete them from chat
1426
+ const text = msg.text;
1427
+ const credPatterns = [
1428
+ /^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/, // API keys
1429
+ /^[A-Za-z0-9_-]{20,}$/, // Long token-like strings with no spaces
1430
+ /^(Bearer |token:|key:|secret:|password:)/i, // Prefixed credentials
1431
+ ];
1432
+ const looksLikeCredential = credPatterns.some((p) => p.test(text.trim()));
1433
+ if (looksLikeCredential) {
1434
+ await deleteMessage(msg.message_id);
1435
+ }
1436
+
1437
+ let prompt = msg.text;
1438
+ const reply = msg.reply_to_message;
1439
+ if (reply) {
1440
+ let ctx = "";
1441
+ if (reply.text) {
1442
+ ctx = reply.text;
1443
+ }
1444
+ if (reply.caption) {
1445
+ ctx += (ctx ? "\n" : "") + reply.caption;
1446
+ }
1447
+ if (reply.document) {
1448
+ const fileName = reply.document.file_name || "unknown";
1449
+ const filePath = path.join(FILES_DIR, fileName);
1450
+ if (fs.existsSync(filePath)) {
1451
+ ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
1452
+ } else {
1453
+ // Download the file from the replied message
1454
+ try {
1455
+ const file = await bot.getFile(reply.document.file_id);
1456
+ const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
1457
+ await new Promise((resolve, reject) => {
1458
+ const out = fs.createWriteStream(filePath);
1459
+ https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
1460
+ });
1461
+ ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
1462
+ } catch (e) {
1463
+ ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} — could not download]`;
1464
+ }
1465
+ }
1466
+ }
1467
+ if (reply.photo) {
1468
+ ctx += (ctx ? "\n" : "") + "[Attached photo]";
1469
+ }
1470
+ if (ctx) {
1471
+ prompt = `Replying to message:\n---\n${ctx.length > 1000 ? ctx.slice(0, 1000) + "..." : ctx}\n---\n\n${msg.text}`;
1472
+ }
1473
+ }
1474
+
1475
+ await runClaude(prompt, currentSession.dir, msg.message_id);
1476
+ });
1477
+
1478
+ // ── Startup ─────────────────────────────────────────────────────────
1479
+ initCrons();
1480
+ console.log("Claude Code Telegram bot running");
1481
+ console.log(`Workspace: ${WORKSPACE}`);
1482
+ console.log(`Soul: ${SOUL_FILE}`);
1483
+ console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
1484
+ console.log(`Onboarded: ${isOnboarded()}`);
1485
+
1486
+ // Notify owner that bot is back online
1487
+ bot.sendMessage(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`).catch(() => {});