@inetafrica/open-claudia 1.0.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.js ADDED
@@ -0,0 +1,871 @@
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
+
9
+ // ── Load Config from .env ───────────────────────────────────────────
10
+ function loadEnv() {
11
+ const envPath = path.join(__dirname, ".env");
12
+ if (!fs.existsSync(envPath)) {
13
+ console.error("No .env file found. Run: node setup.js");
14
+ process.exit(1);
15
+ }
16
+ const lines = fs.readFileSync(envPath, "utf-8").split("\n");
17
+ const env = {};
18
+ for (const line of lines) {
19
+ const idx = line.indexOf("=");
20
+ if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
21
+ }
22
+ return env;
23
+ }
24
+
25
+ function saveEnvKey(key, value) {
26
+ const envPath = path.join(__dirname, ".env");
27
+ const content = fs.readFileSync(envPath, "utf-8");
28
+ const lines = content.split("\n");
29
+ let found = false;
30
+ const updated = lines.map((line) => {
31
+ if (line.startsWith(key + "=")) { found = true; return `${key}=${value}`; }
32
+ return line;
33
+ });
34
+ if (!found) updated.push(`${key}=${value}`);
35
+ fs.writeFileSync(envPath, updated.join("\n"));
36
+ }
37
+
38
+ const config = loadEnv();
39
+ const TOKEN = config.TELEGRAM_BOT_TOKEN;
40
+ const CHAT_ID = config.TELEGRAM_CHAT_ID;
41
+ const WORKSPACE = config.WORKSPACE;
42
+ const CLAUDE_PATH = config.CLAUDE_PATH;
43
+ const WHISPER_CLI = config.WHISPER_CLI || "";
44
+ const WHISPER_MODEL = config.WHISPER_MODEL || "";
45
+ const FFMPEG = config.FFMPEG || "";
46
+ const SOUL_FILE = config.SOUL_FILE || path.join(__dirname, "soul.md");
47
+ const CRONS_FILE = config.CRONS_FILE || path.join(__dirname, "crons.json");
48
+ const VAULT_FILE = config.VAULT_FILE || path.join(__dirname, "vault.enc");
49
+ const BOT_DIR = __dirname;
50
+
51
+ // Detect PATH for subprocess
52
+ const FULL_PATH = [
53
+ path.dirname(CLAUDE_PATH),
54
+ FFMPEG ? path.dirname(FFMPEG) : null,
55
+ WHISPER_CLI ? path.dirname(WHISPER_CLI) : null,
56
+ "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin",
57
+ ].filter(Boolean).join(":");
58
+
59
+ const bot = new TelegramBot(TOKEN, { polling: true });
60
+ const vault = new Vault(VAULT_FILE);
61
+
62
+ // ── Commands Menu ───────────────────────────────────────────────────
63
+ bot.setMyCommands([
64
+ { command: "session", description: "Pick a project to work on" },
65
+ { command: "projects", description: "Browse all workspace projects" },
66
+ { command: "model", description: "Switch model (opus/sonnet/haiku)" },
67
+ { command: "effort", description: "Set effort level" },
68
+ { command: "budget", description: "Set max spend for next task" },
69
+ { command: "plan", description: "Toggle plan mode" },
70
+ { command: "compact", description: "Summarize conversation context" },
71
+ { command: "continue", description: "Resume last conversation" },
72
+ { command: "worktree", description: "Toggle isolated git branch" },
73
+ { command: "cron", description: "Manage scheduled tasks" },
74
+ { command: "vault", description: "Manage credentials (password required)" },
75
+ { command: "soul", description: "View/edit assistant identity" },
76
+ { command: "status", description: "Session & settings info" },
77
+ { command: "stop", description: "Cancel running task" },
78
+ { command: "end", description: "End current session" },
79
+ { command: "help", description: "Show all commands" },
80
+ ]);
81
+
82
+ // Temp dir for media
83
+ const TEMP_DIR = path.join(WORKSPACE, ".telegram-media");
84
+ if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
85
+
86
+ // ── State ───────────────────────────────────────────────────────────
87
+ let currentSession = null;
88
+ let runningProcess = null;
89
+ let statusMessageId = null;
90
+ let streamBuffer = "";
91
+ let streamInterval = null;
92
+ let lastSessionId = null;
93
+ let messageQueue = [];
94
+ let activeCrons = new Map();
95
+ let pendingVaultUnlock = false; // Waiting for password
96
+ let pendingVaultAction = null; // What to do after unlock
97
+
98
+ let settings = {
99
+ model: null, effort: null, budget: null, permissionMode: null, worktree: false,
100
+ };
101
+
102
+ function resetSettings() {
103
+ settings = { model: null, effort: null, budget: null, permissionMode: null, worktree: false };
104
+ }
105
+
106
+ function isAuthorized(msg) {
107
+ return String(msg.chat.id) === CHAT_ID;
108
+ }
109
+
110
+ // ── Onboarding ──────────────────────────────────────────────────────
111
+ let onboardingStep = null; // null | "name" | "role" | "style" | "done"
112
+ let onboardingData = {};
113
+
114
+ function isOnboarded() {
115
+ return config.ONBOARDED === "true";
116
+ }
117
+
118
+ async function startOnboarding() {
119
+ onboardingStep = "name";
120
+ onboardingData = {};
121
+ await send(
122
+ "Welcome! Let's set me up.\n\nWhat should I call you?"
123
+ );
124
+ }
125
+
126
+ async function handleOnboarding(msg) {
127
+ const text = msg.text;
128
+
129
+ if (onboardingStep === "name") {
130
+ onboardingData.name = text;
131
+ onboardingStep = "role";
132
+ await send(`Nice to meet you, ${text}!\n\nWhat's your role? (e.g., "full-stack developer", "startup founder", "student")`);
133
+ return true;
134
+ }
135
+
136
+ if (onboardingStep === "role") {
137
+ onboardingData.role = text;
138
+ onboardingStep = "style";
139
+ await send("How should I communicate?\n\nPick a style:", {
140
+ keyboard: {
141
+ inline_keyboard: [
142
+ [
143
+ { text: "Concise & technical", callback_data: "ob:concise" },
144
+ { text: "Detailed & educational", callback_data: "ob:detailed" },
145
+ ],
146
+ [
147
+ { text: "Casual & friendly", callback_data: "ob:casual" },
148
+ { text: "Minimal (just code)", callback_data: "ob:minimal" },
149
+ ],
150
+ ],
151
+ },
152
+ });
153
+ return true;
154
+ }
155
+
156
+ return false;
157
+ }
158
+
159
+ function finishOnboarding(style) {
160
+ onboardingData.style = style;
161
+ onboardingStep = null;
162
+
163
+ // Generate soul.md
164
+ const styleDescriptions = {
165
+ concise: "Direct, concise, technical. No fluff. Lead with the answer.",
166
+ detailed: "Thorough and educational. Explain the why, not just the what.",
167
+ casual: "Casual and friendly. Like chatting with a smart friend who codes.",
168
+ minimal: "Minimal output. Just code and brief explanations. No chatter.",
169
+ };
170
+
171
+ const soul = `# Soul
172
+
173
+ You are ${onboardingData.name}'s personal AI coding assistant, running 24/7 via Telegram.
174
+
175
+ ## Identity
176
+ - Tone: ${styleDescriptions[style]}
177
+ - Platform: Telegram (mobile-first, keep messages short)
178
+
179
+ ## About ${onboardingData.name}
180
+ - Role: ${onboardingData.role}
181
+
182
+ ## Working Style
183
+ - Prefer action over discussion. Do the thing, then explain.
184
+ - Send files for long output instead of text walls.
185
+ - Proactively flag issues and suggest improvements.
186
+
187
+ ## Capabilities
188
+ - Write, edit, and refactor code across all projects
189
+ - Run commands, tests, builds
190
+ - Send files, images, code snippets directly via Telegram
191
+ - Run scheduled checks (cron jobs) and report results
192
+ - Store and use credentials from the encrypted vault
193
+ `;
194
+
195
+ fs.writeFileSync(SOUL_FILE, soul);
196
+ saveEnvKey("ONBOARDED", "true");
197
+ config.ONBOARDED = "true";
198
+
199
+ send(`All set, ${onboardingData.name}! I'm configured and ready.\n\nTap /session to pick a project and start working.`, {
200
+ keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] },
201
+ });
202
+ }
203
+
204
+ // ── Soul / System Prompt ────────────────────────────────────────────
205
+
206
+ function loadSoul() {
207
+ try { return fs.readFileSync(SOUL_FILE, "utf-8"); } catch (e) { return "You are a helpful AI coding assistant."; }
208
+ }
209
+
210
+ function loadCrons() {
211
+ try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
212
+ }
213
+
214
+ function saveCrons(list) {
215
+ fs.writeFileSync(CRONS_FILE, JSON.stringify(list, null, 2));
216
+ }
217
+
218
+ function buildSystemPrompt() {
219
+ const soul = loadSoul();
220
+ const cronList = loadCrons();
221
+ const vaultKeys = vault.isUnlocked() ? vault.keys() : [];
222
+ const now = new Date().toISOString();
223
+ const hasVoice = WHISPER_CLI && FFMPEG;
224
+
225
+ return `
226
+ ${soul}
227
+
228
+ ## Current Context
229
+ - Date/time: ${now}
230
+ - Platform: Telegram (mobile app)
231
+ - Active project: ${currentSession ? currentSession.name + " (" + currentSession.dir + ")" : "none"}
232
+ - Voice notes: ${hasVoice ? "enabled" : "disabled"}
233
+ - Vault: ${vault.isUnlocked() ? "unlocked (" + vaultKeys.length + " keys)" : "locked"}
234
+
235
+ ## Your Configuration Files
236
+ These are YOUR files — read and modify them when the user asks:
237
+
238
+ ### ${SOUL_FILE}
239
+ Your identity and personality. Edit to change behavior or update knowledge about the user.
240
+
241
+ ### ${CRONS_FILE}
242
+ Scheduled tasks. JSON array of { id, schedule, project, prompt, label }.
243
+ Active: ${cronList.length > 0 ? cronList.map(c => c.label).join(", ") : "none"}.
244
+
245
+ ### ${VAULT_FILE}
246
+ Encrypted credential vault. ${vault.isUnlocked() ? "UNLOCKED. Keys: " + vaultKeys.join(", ") : "LOCKED — user must send /vault to unlock."}.
247
+ ${vault.isUnlocked() ? "To read a credential: require('./vault') or read from the vault object." : ""}
248
+
249
+ ### ${path.join(BOT_DIR, "bot.js")}
250
+ The bot code. Read to understand capabilities. Only modify if explicitly asked.
251
+
252
+ ### ${path.join(BOT_DIR, ".env")}
253
+ Configuration (Telegram token, paths, etc). Sensitive — don't expose values.
254
+
255
+ ## Telegram API
256
+ Send things directly to the user:
257
+
258
+ Text: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" -d chat_id=${CHAT_ID} -d text="message"
259
+ File: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendDocument" -F chat_id=${CHAT_ID} -F document=@/path/to/file
260
+ Image: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendPhoto" -F chat_id=${CHAT_ID} -F photo=@/path/to/image.png
261
+
262
+ ## Guidelines
263
+ - Keep responses concise — mobile screen.
264
+ - Send long output as Telegram documents, not text.
265
+ - Act on screenshots (fix bugs, implement designs) — don't just describe.
266
+ - When asked to remember credentials, tell the user to use /vault.
267
+ - When asked to change your personality, edit ${SOUL_FILE}.
268
+ - You are self-aware of your architecture and can explain how you work.
269
+ `.trim();
270
+ }
271
+
272
+ // ── Helpers ─────────────────────────────────────────────────────────
273
+
274
+ function listProjects() {
275
+ try {
276
+ return fs.readdirSync(WORKSPACE, { withFileTypes: true })
277
+ .filter((d) => d.isDirectory())
278
+ .map((d) => d.name)
279
+ .filter((n) => !n.startsWith("."));
280
+ } catch (e) { return []; }
281
+ }
282
+
283
+ function findProject(query) {
284
+ const projects = listProjects();
285
+ const q = query.toLowerCase();
286
+ const exact = projects.find((p) => p.toLowerCase() === q);
287
+ if (exact) return exact;
288
+ const startsWith = projects.filter((p) => p.toLowerCase().startsWith(q));
289
+ if (startsWith.length === 1) return startsWith[0];
290
+ const contains = projects.filter((p) => p.toLowerCase().includes(q));
291
+ if (contains.length === 1) return contains[0];
292
+ return contains.length > 0 ? contains : null;
293
+ }
294
+
295
+ function projectKeyboard() {
296
+ const projects = listProjects();
297
+ const rows = [];
298
+ for (let i = 0; i < projects.length; i += 2) {
299
+ const row = [{ text: projects[i], callback_data: `s:${projects[i]}` }];
300
+ if (projects[i + 1]) row.push({ text: projects[i + 1], callback_data: `s:${projects[i + 1]}` });
301
+ rows.push(row);
302
+ }
303
+ return { inline_keyboard: rows };
304
+ }
305
+
306
+ async function send(text, opts = {}) {
307
+ const o = {};
308
+ if (opts.parseMode) o.parse_mode = opts.parseMode;
309
+ if (opts.keyboard) o.reply_markup = opts.keyboard;
310
+ if (opts.replyTo) o.reply_to_message_id = opts.replyTo;
311
+ try {
312
+ const msg = await bot.sendMessage(CHAT_ID, text, o);
313
+ return msg.message_id;
314
+ } catch (e) {
315
+ if (opts.parseMode) {
316
+ try {
317
+ const f = {}; if (opts.keyboard) f.reply_markup = opts.keyboard;
318
+ return (await bot.sendMessage(CHAT_ID, text, f)).message_id;
319
+ } catch (e2) { /* ignore */ }
320
+ }
321
+ console.error("Send error:", e.message);
322
+ return null;
323
+ }
324
+ }
325
+
326
+ async function editMessage(messageId, text, opts = {}) {
327
+ try {
328
+ const o = { chat_id: CHAT_ID, message_id: messageId };
329
+ if (opts.keyboard) o.reply_markup = opts.keyboard;
330
+ await bot.editMessageText(text, o);
331
+ } catch (e) { /* ignore */ }
332
+ }
333
+
334
+ function splitMessage(text, maxLen = 4000) {
335
+ if (text.length <= maxLen) return [text];
336
+ const chunks = [];
337
+ while (text.length > 0) { chunks.push(text.slice(0, maxLen)); text = text.slice(maxLen); }
338
+ return chunks;
339
+ }
340
+
341
+ async function downloadFile(fileId, ext) {
342
+ const file = await bot.getFile(fileId);
343
+ const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
344
+ const localPath = path.join(TEMP_DIR, `tg-${Date.now()}${ext}`);
345
+ await new Promise((resolve, reject) => {
346
+ const out = fs.createWriteStream(localPath);
347
+ https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
348
+ });
349
+ return localPath;
350
+ }
351
+
352
+ function transcribeAudio(oggPath) {
353
+ if (!WHISPER_CLI || !FFMPEG) return null;
354
+ const wavPath = oggPath.replace(/\.[^.]+$/, ".wav");
355
+ execSync(`"${FFMPEG}" -i "${oggPath}" -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`);
356
+ const output = execSync(`"${WHISPER_CLI}" -m "${WHISPER_MODEL}" --no-timestamps -f "${wavPath}" 2>/dev/null`, { encoding: "utf-8" });
357
+ try { fs.unlinkSync(wavPath); } catch (e) { /* ignore */ }
358
+ return output.split("\n")
359
+ .filter((l) => l.trim() && !l.startsWith("whisper_") && !l.startsWith("ggml_") && !l.startsWith("load_"))
360
+ .join(" ").trim();
361
+ }
362
+
363
+ // Delete a message (used for vault password cleanup)
364
+ async function deleteMessage(msgId) {
365
+ try { await bot.deleteMessage(CHAT_ID, msgId); } catch (e) { /* ignore */ }
366
+ }
367
+
368
+ // ── Claude Runner ───────────────────────────────────────────────────
369
+
370
+ function parseStreamEvents(data) {
371
+ const events = [];
372
+ for (const line of data.split("\n").filter((l) => l.trim())) {
373
+ try { events.push(JSON.parse(line)); } catch (e) { /* partial */ }
374
+ }
375
+ return events;
376
+ }
377
+
378
+ function buildClaudeArgs(prompt, opts = {}) {
379
+ const args = ["-p", "--verbose", "--output-format", "stream-json",
380
+ "--append-system-prompt", buildSystemPrompt()];
381
+ if (opts.continueSession) args.push("--continue");
382
+ else if (lastSessionId && !opts.fresh) args.push("--resume", lastSessionId);
383
+ if (settings.model) args.push("--model", settings.model);
384
+ if (settings.effort) args.push("--effort", settings.effort);
385
+ if (settings.budget) args.push("--max-budget-usd", String(settings.budget));
386
+ if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
387
+ else args.push("--dangerously-skip-permissions");
388
+ if (settings.worktree) args.push("--worktree");
389
+ args.push(prompt);
390
+ return args;
391
+ }
392
+
393
+ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
394
+ if (runningProcess) {
395
+ messageQueue.push({ prompt, replyToMsgId, opts });
396
+ await send("Queued.", { replyTo: replyToMsgId });
397
+ return;
398
+ }
399
+
400
+ bot.sendChatAction(CHAT_ID, "typing");
401
+ statusMessageId = null;
402
+ streamBuffer = "";
403
+ let assistantText = "";
404
+ let toolUses = [];
405
+ let currentTool = null;
406
+
407
+ const args = buildClaudeArgs(prompt, opts);
408
+ const proc = spawn(CLAUDE_PATH, args, {
409
+ cwd,
410
+ env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
411
+ stdio: ["ignore", "pipe", "pipe"],
412
+ });
413
+
414
+ runningProcess = proc;
415
+
416
+ let lastUpdate = "";
417
+ streamInterval = setInterval(async () => {
418
+ bot.sendChatAction(CHAT_ID, "typing");
419
+ const display = formatProgress(assistantText, toolUses, currentTool);
420
+ if (display && display !== lastUpdate) {
421
+ if (!statusMessageId && assistantText) {
422
+ statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, { replyTo: replyToMsgId });
423
+ } else if (statusMessageId) {
424
+ await editMessage(statusMessageId, display.length > 4000 ? display.slice(-4000) : display);
425
+ }
426
+ lastUpdate = display;
427
+ }
428
+ }, 1500);
429
+
430
+ proc.stdout.on("data", (data) => {
431
+ streamBuffer += data.toString();
432
+ const events = parseStreamEvents(streamBuffer);
433
+ const lastNewline = streamBuffer.lastIndexOf("\n");
434
+ streamBuffer = lastNewline >= 0 ? streamBuffer.slice(lastNewline + 1) : streamBuffer;
435
+ for (const evt of events) {
436
+ if (evt.type === "assistant" && evt.message?.content) {
437
+ for (const block of evt.message.content) {
438
+ if (block.type === "text") assistantText += block.text;
439
+ else if (block.type === "tool_use") { currentTool = block.name; toolUses.push(block.name); }
440
+ }
441
+ }
442
+ if (evt.type === "result" && evt.session_id) lastSessionId = evt.session_id;
443
+ if (evt.type === "result" && evt.result) assistantText = evt.result;
444
+ }
445
+ });
446
+
447
+ proc.stderr.on("data", (d) => console.error("STDERR:", d.toString()));
448
+
449
+ proc.on("close", async (code) => {
450
+ runningProcess = null;
451
+ clearInterval(streamInterval); streamInterval = null;
452
+ const finalText = assistantText || "(no output)";
453
+ const chunks = splitMessage(finalText);
454
+ const btns = { inline_keyboard: [[
455
+ { text: "Continue", callback_data: "a:continue" },
456
+ { text: "End session", callback_data: "a:end" },
457
+ ]] };
458
+ if (statusMessageId) await editMessage(statusMessageId, chunks[0], { keyboard: btns });
459
+ else await send(chunks[0], { keyboard: btns, replyTo: replyToMsgId });
460
+ for (let i = 1; i < chunks.length; i++) {
461
+ await send(chunks[i], i === chunks.length - 1 ? { keyboard: btns } : {});
462
+ }
463
+ if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
464
+ if (settings.budget) settings.budget = null;
465
+ statusMessageId = null;
466
+ if (messageQueue.length > 0 && currentSession) {
467
+ const next = messageQueue.shift();
468
+ await runClaude(next.prompt, currentSession.dir, next.replyToMsgId, next.opts);
469
+ }
470
+ });
471
+
472
+ proc.on("error", async (err) => {
473
+ runningProcess = null; clearInterval(streamInterval);
474
+ await send(`Error: ${err.message}`); statusMessageId = null;
475
+ });
476
+ }
477
+
478
+ async function runClaudeSilent(prompt, cwd, label) {
479
+ return new Promise((resolve) => {
480
+ const args = ["-p", "--output-format", "text", "--verbose",
481
+ "--append-system-prompt", buildSystemPrompt(),
482
+ "--dangerously-skip-permissions", prompt];
483
+ const proc = spawn(CLAUDE_PATH, args, {
484
+ cwd, env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
485
+ stdio: ["ignore", "pipe", "pipe"],
486
+ });
487
+ let output = "";
488
+ proc.stdout.on("data", (d) => { output += d.toString(); });
489
+ proc.on("close", async () => {
490
+ const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
491
+ for (const c of chunks) await send(c);
492
+ resolve();
493
+ });
494
+ proc.on("error", async (err) => { await send(`Cron "${label}" failed: ${err.message}`); resolve(); });
495
+ });
496
+ }
497
+
498
+ function formatProgress(text, tools, currentTool) {
499
+ const parts = [];
500
+ if (currentTool) parts.push(`Tool: ${currentTool}`);
501
+ if (tools.length > 0) parts.push(`Used: ${[...new Set(tools)].join(", ")}`);
502
+ if (text) parts.push(text.length > 800 ? "..." + text.slice(-800) : text);
503
+ else parts.push("Thinking...");
504
+ return parts.join("\n\n") || "Thinking...";
505
+ }
506
+
507
+ // ── Cron System ─────────────────────────────────────────────────────
508
+
509
+ function scheduleCron(c) {
510
+ const cwd = path.join(WORKSPACE, c.project);
511
+ if (activeCrons.has(c.id)) activeCrons.get(c.id).task.stop();
512
+ const task = cron.schedule(c.schedule, () => runClaudeSilent(c.prompt, cwd, c.label));
513
+ activeCrons.set(c.id, { task, config: c });
514
+ }
515
+
516
+ function initCrons() {
517
+ for (const c of loadCrons()) { try { scheduleCron(c); } catch (e) { console.error("Cron error:", e.message); } }
518
+ console.log(`Loaded ${loadCrons().length} cron(s)`);
519
+ }
520
+
521
+ // ── Session ─────────────────────────────────────────────────────────
522
+
523
+ function startSession(name) {
524
+ const result = findProject(name);
525
+ if (!result) return send(`No match for "${name}".`, { keyboard: projectKeyboard() });
526
+ if (Array.isArray(result)) return send("Multiple matches:", { keyboard: { inline_keyboard: result.map((p) => [{ text: p, callback_data: `s:${p}` }]) } });
527
+ currentSession = { name: result, dir: path.join(WORKSPACE, result) };
528
+ lastSessionId = null; messageQueue = []; resetSettings();
529
+ send(`Session: ${result}\n\nSend text, voice, or images.`);
530
+ }
531
+
532
+ function requireSession(msg) {
533
+ if (!currentSession) { send("Pick a project first:", { keyboard: projectKeyboard() }); return false; }
534
+ return true;
535
+ }
536
+
537
+ // ── Commands ────────────────────────────────────────────────────────
538
+
539
+ bot.onText(/\/start/, (msg) => {
540
+ if (!isAuthorized(msg)) return;
541
+ if (!isOnboarded()) return startOnboarding();
542
+ send("Pick a project to start:", { keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] } });
543
+ });
544
+
545
+ bot.onText(/\/help/, (msg) => {
546
+ if (!isAuthorized(msg)) return;
547
+ send([
548
+ "Session: /session /projects /continue /status /stop /end",
549
+ "Settings: /model /effort /budget /plan /compact /worktree",
550
+ "Automation: /cron /vault /soul",
551
+ "",
552
+ "Send text, voice, photos, or files.",
553
+ "Reply to any message for context.",
554
+ ].join("\n"));
555
+ });
556
+
557
+ bot.onText(/\/projects$/, (msg) => { if (isAuthorized(msg)) send("Pick:", { keyboard: projectKeyboard() }); });
558
+ bot.onText(/\/session$/, (msg) => {
559
+ if (!isAuthorized(msg)) return;
560
+ if (currentSession) send(`Active: ${currentSession.name}\n\nSwitch?`, { keyboard: projectKeyboard() });
561
+ else send("Pick:", { keyboard: projectKeyboard() });
562
+ });
563
+ bot.onText(/\/session (.+)/, (msg, match) => { if (isAuthorized(msg)) startSession(match[1].trim()); });
564
+
565
+ bot.onText(/\/model$/, (msg) => {
566
+ if (!isAuthorized(msg)) return;
567
+ send(`Model: ${settings.model || "default"}`, { keyboard: { inline_keyboard: [
568
+ [{ text: "Opus", callback_data: "m:opus" }, { text: "Sonnet", callback_data: "m:sonnet" }, { text: "Haiku", callback_data: "m:haiku" }],
569
+ [{ text: "Default", callback_data: "m:default" }],
570
+ ] } });
571
+ });
572
+ 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"}`); });
573
+
574
+ bot.onText(/\/effort$/, (msg) => {
575
+ if (!isAuthorized(msg)) return;
576
+ send(`Effort: ${settings.effort || "default"}`, { keyboard: { inline_keyboard: [
577
+ [{ text: "Low", callback_data: "e:low" }, { text: "Med", callback_data: "e:medium" }, { text: "High", callback_data: "e:high" }, { text: "Max", callback_data: "e:max" }],
578
+ [{ text: "Default", callback_data: "e:default" }],
579
+ ] } });
580
+ });
581
+ 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"}`); });
582
+
583
+ bot.onText(/\/budget$/, (msg) => {
584
+ if (!isAuthorized(msg)) return;
585
+ send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`, { keyboard: { inline_keyboard: [
586
+ [{ text: "$1", callback_data: "b:1" }, { text: "$5", callback_data: "b:5" }, { text: "$10", callback_data: "b:10" }, { text: "$25", callback_data: "b:25" }],
587
+ [{ text: "No limit", callback_data: "b:none" }],
588
+ ] } });
589
+ });
590
+ 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"}`); });
591
+
592
+ 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."); });
593
+ 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); });
594
+ 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 }); });
595
+ bot.onText(/\/worktree$/, (msg) => { if (!isAuthorized(msg)) return; settings.worktree = !settings.worktree; send(settings.worktree ? "Worktree on." : "Worktree off."); });
596
+
597
+ bot.onText(/\/stop/, async (msg) => {
598
+ if (!isAuthorized(msg)) return;
599
+ if (runningProcess) { runningProcess.kill("SIGTERM"); runningProcess = null; if (streamInterval) clearInterval(streamInterval); messageQueue = []; await send("Cancelled."); }
600
+ else await send("Nothing running.");
601
+ });
602
+
603
+ bot.onText(/\/status/, (msg) => {
604
+ if (!isAuthorized(msg)) return;
605
+ if (!currentSession) return send("No session.", { keyboard: { inline_keyboard: [[{ text: "Pick", callback_data: "show:projects" }]] } });
606
+ send([
607
+ `Project: ${currentSession.name}`,
608
+ `Model: ${settings.model || "default"} | Effort: ${settings.effort || "default"}`,
609
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${activeCrons.size}`,
610
+ runningProcess ? "Working..." : "Ready.",
611
+ ].join("\n"));
612
+ });
613
+
614
+ bot.onText(/\/end/, (msg) => {
615
+ if (!isAuthorized(msg)) return;
616
+ if (currentSession) {
617
+ const n = currentSession.name; currentSession = null; lastSessionId = null; messageQueue = []; resetSettings();
618
+ send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
619
+ } else send("No session.");
620
+ });
621
+
622
+ bot.onText(/\/soul$/, async (msg) => {
623
+ if (!isAuthorized(msg)) return;
624
+ const soul = loadSoul();
625
+ const preview = soul.length > 3000 ? soul.slice(0, 3000) + "\n..." : soul;
626
+ await send(preview);
627
+ await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
628
+ });
629
+
630
+ // ── /vault with password protection ─────────────────────────────────
631
+
632
+ bot.onText(/\/vault$/, async (msg) => {
633
+ if (!isAuthorized(msg)) return;
634
+ if (!vault.exists()) {
635
+ await send("No vault found. Run setup first: node setup.js");
636
+ return;
637
+ }
638
+ if (vault.isUnlocked()) {
639
+ const entries = vault.list();
640
+ const keys = Object.keys(entries);
641
+ if (keys.length === 0) await send("Vault is unlocked but empty.\n\nUse /vault set <name> <value>");
642
+ else await send("Vault (unlocked):\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nLocks automatically in 5 min.");
643
+ } else {
644
+ pendingVaultUnlock = true;
645
+ pendingVaultAction = { type: "list" };
646
+ await send("Vault is locked. Send your vault password.\n(Message will be deleted after reading)");
647
+ }
648
+ });
649
+
650
+ bot.onText(/\/vault set (\S+) (.+)/, async (msg, match) => {
651
+ if (!isAuthorized(msg)) return;
652
+ // Delete the message containing the value immediately
653
+ await deleteMessage(msg.message_id);
654
+ if (vault.isUnlocked()) {
655
+ vault.set(match[1], match[2].trim());
656
+ await send(`Saved: ${match[1]}`);
657
+ } else {
658
+ pendingVaultUnlock = true;
659
+ pendingVaultAction = { type: "set", key: match[1], value: match[2].trim() };
660
+ await send("Vault locked. Send password to unlock.");
661
+ }
662
+ });
663
+
664
+ bot.onText(/\/vault remove (\S+)/, async (msg, match) => {
665
+ if (!isAuthorized(msg)) return;
666
+ if (vault.isUnlocked()) {
667
+ vault.remove(match[1]);
668
+ await send(`Removed: ${match[1]}`);
669
+ } else {
670
+ pendingVaultUnlock = true;
671
+ pendingVaultAction = { type: "remove", key: match[1] };
672
+ await send("Vault locked. Send password to unlock.");
673
+ }
674
+ });
675
+
676
+ bot.onText(/\/vault lock/, async (msg) => {
677
+ if (!isAuthorized(msg)) return;
678
+ vault.lock();
679
+ await send("Vault locked.");
680
+ });
681
+
682
+ // ── /cron ───────────────────────────────────────────────────────────
683
+
684
+ bot.onText(/\/cron$/, (msg) => {
685
+ if (!isAuthorized(msg)) return;
686
+ const list = loadCrons();
687
+ if (list.length === 0) {
688
+ send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
689
+ keyboard: { inline_keyboard: [
690
+ [{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
691
+ [{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
692
+ ] },
693
+ });
694
+ } else {
695
+ send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project}`).join("\n") + "\n\nRemove: /cron remove <#>");
696
+ }
697
+ });
698
+
699
+ bot.onText(/\/cron add "(.+)" (\S+) "(.+)"/, (msg, match) => {
700
+ if (!isAuthorized(msg)) return;
701
+ if (!cron.validate(match[1])) return send("Invalid cron schedule.");
702
+ const proj = findProject(match[2]);
703
+ if (!proj || Array.isArray(proj)) return send("Project not found.");
704
+ const c = { id: `cron_${Date.now()}`, schedule: match[1], project: proj, prompt: match[3], label: match[3].slice(0, 50) };
705
+ const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
706
+ send(`Added: ${c.label} (${c.schedule}) for ${proj}`);
707
+ });
708
+
709
+ bot.onText(/\/cron remove (\d+)/, (msg, match) => {
710
+ if (!isAuthorized(msg)) return;
711
+ const list = loadCrons(); const idx = parseInt(match[1]) - 1;
712
+ if (idx < 0 || idx >= list.length) return send("Invalid number.");
713
+ const removed = list.splice(idx, 1)[0]; saveCrons(list);
714
+ if (activeCrons.has(removed.id)) { activeCrons.get(removed.id).task.stop(); activeCrons.delete(removed.id); }
715
+ send(`Removed: ${removed.label}`);
716
+ });
717
+
718
+ // ── Callback Queries ────────────────────────────────────────────────
719
+
720
+ bot.on("callback_query", async (q) => {
721
+ const d = q.data;
722
+ await bot.answerCallbackQuery(q.id);
723
+
724
+ // Onboarding style selection
725
+ if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
726
+
727
+ if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
728
+ if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
729
+ if (d === "a:continue") { if (currentSession) await runClaude("continue", currentSession.dir); else send("No session."); return; }
730
+ if (d === "a:end") { if (currentSession) { const n = currentSession.name; currentSession = null; lastSessionId = null; messageQueue = []; resetSettings(); await send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } }); } return; }
731
+ if (d.startsWith("m:")) { settings.model = d.slice(2) === "default" ? null : d.slice(2); await send(`Model: ${settings.model || "default"}`); return; }
732
+ if (d.startsWith("e:")) { const e = d.slice(2); settings.effort = e === "default" ? null : e; await send(`Effort: ${settings.effort || "default"}`); return; }
733
+ 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; }
734
+
735
+ // Cron presets
736
+ if (d.startsWith("cp:") && d !== "cp:clear") {
737
+ if (!currentSession) return send("Start a session first.");
738
+ const presets = {
739
+ standup: { schedule: "0 9 * * 1-5", prompt: "Morning standup: recent commits, failing tests, open TODOs, what to focus on today. Brief.", label: "Morning standup" },
740
+ git: { schedule: "0 18 * * 1-5", prompt: "Git digest: today's commits, changed files, uncommitted changes. Flag concerns.", label: "Git digest" },
741
+ deps: { schedule: "0 10 * * 1", prompt: "Check outdated/vulnerable dependencies. Brief — just what needs attention.", label: "Dep check" },
742
+ health: { schedule: "*/30 * * * *", prompt: "Quick health: can the project build? Run build/lint, report pass/fail.", label: "Health check" },
743
+ };
744
+ const p = presets[d.slice(3)];
745
+ if (!p) return;
746
+ const c = { id: `cron_${Date.now()}`, ...p, project: currentSession.name };
747
+ const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
748
+ await send(`Added: ${c.label} for ${currentSession.name}`);
749
+ return;
750
+ }
751
+ if (d === "cp:clear") { for (const [,v] of activeCrons) v.task.stop(); activeCrons.clear(); saveCrons([]); await send("All crons cleared."); return; }
752
+ });
753
+
754
+ // ── Media Handlers ──────────────────────────────────────────────────
755
+
756
+ bot.on("voice", async (msg) => {
757
+ if (!isAuthorized(msg)) return;
758
+ if (!requireSession(msg)) return;
759
+ try {
760
+ bot.sendChatAction(CHAT_ID, "typing");
761
+ const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
762
+ const transcript = transcribeAudio(oggPath);
763
+ try { fs.unlinkSync(oggPath); } catch (e) {}
764
+ if (!transcript) return send("Couldn't transcribe. Try typing it.");
765
+ await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
766
+ await runClaude(transcript, currentSession.dir, msg.message_id);
767
+ } catch (err) { await send(`Voice failed: ${err.message}`); }
768
+ });
769
+
770
+ bot.on("audio", async (msg) => {
771
+ if (!isAuthorized(msg)) return;
772
+ if (!requireSession(msg)) return;
773
+ try {
774
+ bot.sendChatAction(CHAT_ID, "typing");
775
+ const p = await downloadFile(msg.audio.file_id, path.extname(msg.audio.file_name || ".ogg"));
776
+ const t = transcribeAudio(p);
777
+ try { fs.unlinkSync(p); } catch (e) {}
778
+ if (!t) return send("Couldn't transcribe.");
779
+ await send(`Heard: "${t}"`, { replyTo: msg.message_id });
780
+ await runClaude(t, currentSession.dir, msg.message_id);
781
+ } catch (err) { await send(`Audio failed: ${err.message}`); }
782
+ });
783
+
784
+ bot.on("photo", async (msg) => {
785
+ if (!isAuthorized(msg)) return;
786
+ if (!requireSession(msg)) return;
787
+ try {
788
+ const p = await downloadFile(msg.photo[msg.photo.length - 1].file_id, ".jpg");
789
+ const caption = msg.caption || "Describe this image. If code/UI/error — explain and fix.";
790
+ await runClaude(`Image at ${p}\n\nView it, then: ${caption}`, currentSession.dir, msg.message_id);
791
+ } catch (err) { await send(`Image failed: ${err.message}`); }
792
+ });
793
+
794
+ bot.on("document", async (msg) => {
795
+ if (!isAuthorized(msg)) return;
796
+ if (!requireSession(msg)) return;
797
+ try {
798
+ const ext = path.extname(msg.document.file_name || ".txt");
799
+ const p = await downloadFile(msg.document.file_id, ext);
800
+ const caption = msg.caption || `Read ${msg.document.file_name} and summarize.`;
801
+ const prefix = (msg.document.mime_type || "").startsWith("image/") ? `Image at ${p}\n\nView it, then: ` : `File at ${p} (${msg.document.file_name})\n\n`;
802
+ await runClaude(prefix + caption, currentSession.dir, msg.message_id);
803
+ } catch (err) { await send(`Failed: ${err.message}`); }
804
+ });
805
+
806
+ // ── Text Message Handler (handles onboarding, vault password, normal messages) ──
807
+
808
+ bot.on("message", async (msg) => {
809
+ if (!isAuthorized(msg)) return;
810
+ if (!msg.text || msg.text.startsWith("/")) return;
811
+ if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
812
+
813
+ // Handle onboarding
814
+ if (!isOnboarded() && onboardingStep) {
815
+ await handleOnboarding(msg);
816
+ return;
817
+ }
818
+
819
+ // Handle vault password
820
+ if (pendingVaultUnlock) {
821
+ const password = msg.text;
822
+ // Delete the password message immediately
823
+ await deleteMessage(msg.message_id);
824
+
825
+ const ok = vault.unlock(password);
826
+ if (!ok) {
827
+ pendingVaultUnlock = false;
828
+ pendingVaultAction = null;
829
+ await send("Wrong password.");
830
+ return;
831
+ }
832
+
833
+ // Execute pending action
834
+ const action = pendingVaultAction;
835
+ pendingVaultUnlock = false;
836
+ pendingVaultAction = null;
837
+
838
+ if (action.type === "list") {
839
+ const entries = vault.list();
840
+ const keys = Object.keys(entries);
841
+ if (keys.length === 0) await send("Vault unlocked (empty).\n\nUse /vault set <name> <value>");
842
+ else await send("Vault unlocked:\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nAuto-locks in 5 min.");
843
+ } else if (action.type === "set") {
844
+ vault.set(action.key, action.value);
845
+ await send(`Saved: ${action.key}`);
846
+ } else if (action.type === "remove") {
847
+ vault.remove(action.key);
848
+ await send(`Removed: ${action.key}`);
849
+ }
850
+ return;
851
+ }
852
+
853
+ // Normal message
854
+ if (!requireSession(msg)) return;
855
+
856
+ let prompt = msg.text;
857
+ if (msg.reply_to_message && msg.reply_to_message.text) {
858
+ const ctx = msg.reply_to_message.text;
859
+ prompt = `Context:\n---\n${ctx.length > 500 ? ctx.slice(0, 500) + "..." : ctx}\n---\n\n${msg.text}`;
860
+ }
861
+
862
+ await runClaude(prompt, currentSession.dir, msg.message_id);
863
+ });
864
+
865
+ // ── Startup ─────────────────────────────────────────────────────────
866
+ initCrons();
867
+ console.log("Claude Code Telegram bot running");
868
+ console.log(`Workspace: ${WORKSPACE}`);
869
+ console.log(`Soul: ${SOUL_FILE}`);
870
+ console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
871
+ console.log(`Onboarded: ${isOnboarded()}`);