@inetafrica/open-claudia 2.15.0 → 3.0.1
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/.env.example +30 -4
- package/CHANGELOG.md +22 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +81 -3
- package/channels/telegram/adapter.js +65 -5
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +155 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +115 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +21 -4
- package/core/handlers.js +542 -224
- package/core/ideas.js +2 -1
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +42 -6
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +121 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +125 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +171 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1562 -1286
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/single-instance.js +46 -0
- package/core/state.js +1084 -97
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +48 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +337 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +509 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +171 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +93 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/web.js +130 -35
package/bot-agent.js
CHANGED
|
@@ -1,2351 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
const { telegramHtml } = require("./channels/telegram/format");
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
10
3
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// Notify user of crashes via Telegram before exiting
|
|
16
|
-
function notifyError(label, err) {
|
|
17
|
-
const msg = `${label}: ${err?.message || err}`.slice(0, 1000);
|
|
18
|
-
console.error(msg, err?.stack || "");
|
|
19
|
-
try {
|
|
20
|
-
// Synchronous-style notification using the Telegram API directly
|
|
21
|
-
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
22
|
-
const chatId = process.env.TELEGRAM_CHAT_ID?.split(",")[0];
|
|
23
|
-
if (token && chatId) {
|
|
24
|
-
const data = JSON.stringify({ chat_id: chatId, text: msg });
|
|
25
|
-
const req = require("https").request({
|
|
26
|
-
hostname: "api.telegram.org", path: `/bot${token}/sendMessage`,
|
|
27
|
-
method: "POST", headers: { "Content-Type": "application/json", "Content-Length": data.length },
|
|
28
|
-
});
|
|
29
|
-
req.write(data);
|
|
30
|
-
req.end();
|
|
31
|
-
}
|
|
32
|
-
} catch (e) { /* last resort — ignore */ }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
process.on("uncaughtException", (err) => {
|
|
36
|
-
notifyError("Uncaught exception", err);
|
|
37
|
-
// Give the notification a moment to send before exiting
|
|
38
|
-
setTimeout(() => process.exit(1), 2000);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
process.on("unhandledRejection", (reason) => {
|
|
42
|
-
notifyError("Unhandled rejection", reason);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
// ── Load Config from .env ───────────────────────────────────────────
|
|
46
|
-
function loadEnv() {
|
|
47
|
-
const envPath = path.join(CONFIG_DIR, ".env");
|
|
48
|
-
if (!fs.existsSync(envPath)) {
|
|
49
|
-
console.error("No .env file found. Run: node setup.js");
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
52
|
-
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
|
|
53
|
-
const env = {};
|
|
54
|
-
for (const line of lines) {
|
|
55
|
-
const idx = line.indexOf("=");
|
|
56
|
-
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
57
|
-
}
|
|
58
|
-
return env;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function saveEnvKey(key, value) {
|
|
62
|
-
const envPath = path.join(CONFIG_DIR, ".env");
|
|
63
|
-
const content = fs.readFileSync(envPath, "utf-8");
|
|
64
|
-
const lines = content.split("\n");
|
|
65
|
-
let found = false;
|
|
66
|
-
const updated = lines.map((line) => {
|
|
67
|
-
if (line.startsWith(key + "=")) { found = true; return `${key}=${value}`; }
|
|
68
|
-
return line;
|
|
69
|
-
});
|
|
70
|
-
if (!found) updated.push(`${key}=${value}`);
|
|
71
|
-
fs.writeFileSync(envPath, updated.join("\n"));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const config = loadEnv();
|
|
75
|
-
const TOKEN = config.TELEGRAM_BOT_TOKEN;
|
|
76
|
-
const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
77
|
-
const CHAT_ID = CHAT_IDS[0]; // Primary owner chat for crons/notifications
|
|
78
|
-
const WORKSPACE = config.WORKSPACE;
|
|
79
|
-
const CLAUDE_PATH = config.CLAUDE_PATH;
|
|
80
|
-
const CURSOR_PATH = config.CURSOR_PATH || null;
|
|
81
|
-
const DEFAULT_CLAUDE_MODEL = config.CLAUDE_MODEL || process.env.CLAUDE_MODEL || "claude-fable-5";
|
|
82
|
-
|
|
83
|
-
// Resolve Cursor Agent CLI (optional)
|
|
84
|
-
let resolvedCursorPath = CURSOR_PATH;
|
|
85
|
-
if (!resolvedCursorPath) {
|
|
86
|
-
try {
|
|
87
|
-
resolvedCursorPath = execSync("which agent 2>/dev/null", { encoding: "utf-8" }).trim() || null;
|
|
88
|
-
} catch (e) { resolvedCursorPath = null; }
|
|
89
|
-
}
|
|
90
|
-
if (resolvedCursorPath) console.log(`Cursor Agent CLI: ${resolvedCursorPath}`);
|
|
91
|
-
|
|
92
|
-
const WHISPER_CLI = config.WHISPER_CLI || "";
|
|
93
|
-
const WHISPER_MODEL = config.WHISPER_MODEL || "";
|
|
94
|
-
const FFMPEG = config.FFMPEG || "";
|
|
95
|
-
const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
|
|
96
|
-
const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
|
|
97
|
-
const VAULT_FILE = config.VAULT_FILE || path.join(CONFIG_DIR, "vault.enc");
|
|
98
|
-
const AUTH_FILE = config.AUTH_FILE || path.join(CONFIG_DIR, "auth.json");
|
|
99
|
-
const BOT_DIR = __dirname;
|
|
100
|
-
|
|
101
|
-
// Detect PATH for subprocess
|
|
102
|
-
const FULL_PATH = [
|
|
103
|
-
path.dirname(CLAUDE_PATH),
|
|
104
|
-
resolvedCursorPath ? path.dirname(resolvedCursorPath) : null,
|
|
105
|
-
path.dirname(process.execPath),
|
|
106
|
-
FFMPEG ? path.dirname(FFMPEG) : null,
|
|
107
|
-
WHISPER_CLI ? path.dirname(WHISPER_CLI) : null,
|
|
108
|
-
...(process.platform === "win32"
|
|
109
|
-
? [process.env.APPDATA, process.env.LOCALAPPDATA].filter(Boolean).map((p) => path.join(p, "npm"))
|
|
110
|
-
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
|
111
|
-
),
|
|
112
|
-
].filter(Boolean).join(path.delimiter);
|
|
113
|
-
|
|
114
|
-
const bot = new TelegramBot(TOKEN, {
|
|
115
|
-
polling: {
|
|
116
|
-
autoStart: true,
|
|
117
|
-
params: { timeout: 30 },
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
const vault = new Vault(VAULT_FILE);
|
|
121
|
-
|
|
122
|
-
// ── Auto-reconnect on polling errors ───────────────────────────────
|
|
123
|
-
let reconnectTimer = null;
|
|
124
|
-
bot.on("polling_error", (err) => {
|
|
125
|
-
const msg = err.message || "";
|
|
126
|
-
console.error("Polling error:", msg);
|
|
127
|
-
// Another instance is already running — exit immediately
|
|
128
|
-
if (msg.includes("409 Conflict")) {
|
|
129
|
-
console.error("Another instance is polling. Exiting.");
|
|
130
|
-
process.exit(1);
|
|
131
|
-
}
|
|
132
|
-
if (msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET") || msg.includes("ENOTFOUND") || msg.includes("EFATAL")) {
|
|
133
|
-
if (reconnectTimer) return; // Already scheduled
|
|
134
|
-
console.log("Network lost. Reconnecting in 10s...");
|
|
135
|
-
reconnectTimer = setTimeout(async () => {
|
|
136
|
-
reconnectTimer = null;
|
|
137
|
-
try {
|
|
138
|
-
await bot.stopPolling();
|
|
139
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
140
|
-
await bot.startPolling();
|
|
141
|
-
console.log("Reconnected.");
|
|
142
|
-
} catch (e) {
|
|
143
|
-
console.error("Reconnect failed:", e.message);
|
|
144
|
-
// launchd will restart us if we exit
|
|
145
|
-
process.exit(1);
|
|
146
|
-
}
|
|
147
|
-
}, 10000);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
// ── Update checker (every 5 mins) ──────────────────────────────────
|
|
152
|
-
const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
|
|
153
|
-
const { isNewerVersion } = require("./core/version");
|
|
154
|
-
let lastNotifiedVersion = null;
|
|
155
|
-
|
|
156
|
-
function checkForUpdates() {
|
|
157
|
-
https.get("https://registry.npmjs.org/@inetafrica/open-claudia/latest", (res) => {
|
|
158
|
-
let data = "";
|
|
159
|
-
res.on("data", (d) => { data += d; });
|
|
160
|
-
res.on("end", () => {
|
|
161
|
-
try {
|
|
162
|
-
const latest = JSON.parse(data).version;
|
|
163
|
-
if (isNewerVersion(latest, CURRENT_VERSION) && latest !== lastNotifiedVersion) {
|
|
164
|
-
lastNotifiedVersion = latest;
|
|
165
|
-
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.`);
|
|
166
|
-
}
|
|
167
|
-
} catch (e) {}
|
|
168
|
-
});
|
|
169
|
-
}).on("error", () => {});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Check on startup (after 30s) and every 5 minutes
|
|
173
|
-
setTimeout(checkForUpdates, 30000);
|
|
174
|
-
setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
175
|
-
|
|
176
|
-
// ── Commands Menu ───────────────────────────────────────────────────
|
|
177
|
-
bot.setMyCommands([
|
|
178
|
-
{ command: "session", description: "Pick a project to work on" },
|
|
179
|
-
{ command: "projects", description: "Browse all workspace projects" },
|
|
180
|
-
{ command: "model", description: "Switch model (opus/sonnet/haiku)" },
|
|
181
|
-
{ command: "effort", description: "Set effort level" },
|
|
182
|
-
{ command: "budget", description: "Set max spend for next task" },
|
|
183
|
-
{ command: "plan", description: "Toggle plan mode" },
|
|
184
|
-
{ command: "ask", description: "Toggle ask mode (Cursor only)" },
|
|
185
|
-
{ command: "sessions", description: "List conversations for this project" },
|
|
186
|
-
{ command: "compact", description: "Summarize conversation context" },
|
|
187
|
-
{ command: "continue", description: "Resume last conversation" },
|
|
188
|
-
{ command: "worktree", description: "Toggle isolated git branch" },
|
|
189
|
-
{ command: "cron", description: "Manage scheduled tasks" },
|
|
190
|
-
{ command: "vault", description: "Manage credentials (password required)" },
|
|
191
|
-
{ command: "soul", description: "View/edit assistant identity" },
|
|
192
|
-
{ command: "status", description: "Session & settings info" },
|
|
193
|
-
{ command: "cursor", description: "Switch to Cursor Agent backend" },
|
|
194
|
-
{ command: "claude", description: "Switch to Claude Code backend" },
|
|
195
|
-
{ command: "backend", description: "Show/switch active backend" },
|
|
196
|
-
{ command: "auth", description: "Request access to this bot" },
|
|
197
|
-
{ command: "auth_status", description: "Check Claude Code auth" },
|
|
198
|
-
{ command: "login", description: "Start Claude Code login" },
|
|
199
|
-
{ command: "setup_token", description: "Create Claude OAuth token" },
|
|
200
|
-
{ command: "stop", description: "Cancel running task" },
|
|
201
|
-
{ command: "end", description: "End current session" },
|
|
202
|
-
{ command: "version", description: "Show current version" },
|
|
203
|
-
{ command: "restart", description: "Restart the bot" },
|
|
204
|
-
{ command: "upgrade", description: "Upgrade and restart" },
|
|
205
|
-
{ command: "help", description: "Show all commands" },
|
|
206
|
-
]);
|
|
207
|
-
|
|
208
|
-
// Temp dir for media
|
|
209
|
-
const TEMP_DIR = path.join(CONFIG_DIR, "media");
|
|
210
|
-
const FILES_DIR = path.join(CONFIG_DIR, "files");
|
|
211
|
-
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
|
|
212
|
-
if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
|
|
213
|
-
|
|
214
|
-
// ── Persistent state ───────────────────────────────────────────────
|
|
215
|
-
const STATE_FILE = path.join(CONFIG_DIR, "state.json");
|
|
216
|
-
const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
|
|
217
|
-
|
|
218
|
-
function loadState() {
|
|
219
|
-
try {
|
|
220
|
-
return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8"));
|
|
221
|
-
} catch (e) {
|
|
222
|
-
return {};
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function saveState() {
|
|
227
|
-
const data = {
|
|
228
|
-
currentSession,
|
|
229
|
-
lastSessionId,
|
|
230
|
-
cursorSessionId,
|
|
231
|
-
settings,
|
|
232
|
-
};
|
|
233
|
-
try { fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch (e) {}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ── Message deduplication ──────────────────────────────────────────
|
|
237
|
-
const processedMessages = new Set();
|
|
238
|
-
function isDuplicate(msgId) {
|
|
239
|
-
if (processedMessages.has(msgId)) return true;
|
|
240
|
-
processedMessages.add(msgId);
|
|
241
|
-
// Keep set from growing unbounded
|
|
242
|
-
if (processedMessages.size > 200) {
|
|
243
|
-
const arr = [...processedMessages];
|
|
244
|
-
processedMessages.clear();
|
|
245
|
-
arr.slice(-100).forEach((id) => processedMessages.add(id));
|
|
246
|
-
}
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ── Per-project session history ────────────────────────────────────
|
|
251
|
-
|
|
252
|
-
function loadSessions() {
|
|
253
|
-
try { return JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return {}; }
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function saveSessions(sessions) {
|
|
257
|
-
try { fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); } catch (e) {}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function recordSession(projectName, sessionId, title) {
|
|
261
|
-
const sessions = loadSessions();
|
|
262
|
-
if (!sessions[projectName]) sessions[projectName] = [];
|
|
263
|
-
const existing = sessions[projectName].find((s) => s.id === sessionId);
|
|
264
|
-
if (existing) {
|
|
265
|
-
if (title) existing.title = title;
|
|
266
|
-
existing.lastUsed = new Date().toISOString();
|
|
267
|
-
} else {
|
|
268
|
-
sessions[projectName].push({
|
|
269
|
-
id: sessionId,
|
|
270
|
-
title: title || "Untitled",
|
|
271
|
-
created: new Date().toISOString(),
|
|
272
|
-
lastUsed: new Date().toISOString(),
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
// Keep last 20 sessions per project
|
|
276
|
-
sessions[projectName] = sessions[projectName].slice(-20);
|
|
277
|
-
saveSessions(sessions);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function getProjectSessions(projectName) {
|
|
281
|
-
const sessions = loadSessions();
|
|
282
|
-
return (sessions[projectName] || []).slice().reverse();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function getLastProjectSession(projectName) {
|
|
286
|
-
const sessions = getProjectSessions(projectName);
|
|
287
|
-
return sessions.length > 0 ? sessions[0] : null;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const savedState = loadState();
|
|
291
|
-
|
|
292
|
-
// ── State ───────────────────────────────────────────────────────────
|
|
293
|
-
// Agent mode: heavy tasks run as "background" processes while
|
|
294
|
-
// new messages get handled by short-lived "chat" processes.
|
|
295
|
-
let currentSession = savedState.currentSession || null;
|
|
296
|
-
let runningProcess = null; // The main/heavy task process
|
|
297
|
-
let runningProcessPrompt = null; // What the main task is doing (for context)
|
|
298
|
-
let chatProcesses = new Map(); // msgId -> proc (lightweight chat processes)
|
|
299
|
-
let statusMessageId = null;
|
|
300
|
-
let streamBuffer = "";
|
|
301
|
-
let streamInterval = null;
|
|
302
|
-
let lastSessionId = savedState.lastSessionId || null;
|
|
303
|
-
let cursorSessionId = savedState.cursorSessionId || null;
|
|
304
|
-
let messageQueue = []; // Fallback queue (only used if chat process also fails)
|
|
305
|
-
let activeCrons = new Map();
|
|
306
|
-
let pendingVaultUnlock = false;
|
|
307
|
-
let pendingVaultAction = null;
|
|
308
|
-
let pendingClaudeAuthProcess = null;
|
|
309
|
-
let pendingClaudeAuthLabel = null;
|
|
310
|
-
let isFirstMessage = !lastSessionId;
|
|
311
|
-
let lastInputWasVoice = false;
|
|
312
|
-
|
|
313
|
-
let settings = savedState.settings || {
|
|
314
|
-
model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude",
|
|
315
|
-
};
|
|
316
|
-
if (!settings.backend) settings.backend = "claude";
|
|
317
|
-
|
|
318
|
-
function resetSettings() {
|
|
319
|
-
settings = { model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude" };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function isAuthorized(msg) {
|
|
323
|
-
const chatId = String(msg.chat.id);
|
|
324
|
-
if (CHAT_IDS.includes(chatId)) return true;
|
|
325
|
-
// Also check auth.json for dynamically added chats
|
|
326
|
-
try {
|
|
327
|
-
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
328
|
-
return Array.isArray(auth.authorized) && auth.authorized.some((a) => String(a.chatId) === chatId);
|
|
329
|
-
} catch (e) {}
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function isOwner(msg) {
|
|
334
|
-
const chatId = String(msg.chat.id);
|
|
335
|
-
if (chatId === CHAT_ID) return true;
|
|
336
|
-
try {
|
|
337
|
-
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
338
|
-
const authorized = Array.isArray(auth.authorized) ? auth.authorized : [];
|
|
339
|
-
if (authorized.some((a) => String(a.chatId) === chatId && a.isOwner === true)) return true;
|
|
340
|
-
if (!authorized.some((a) => a.isOwner === true) && CHAT_IDS.includes(chatId)) return true;
|
|
341
|
-
} catch (e) {}
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function loadAuth() {
|
|
346
|
-
try {
|
|
347
|
-
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
348
|
-
return {
|
|
349
|
-
authorized: Array.isArray(auth.authorized) ? auth.authorized : [],
|
|
350
|
-
pending: Array.isArray(auth.pending) ? auth.pending : [],
|
|
351
|
-
};
|
|
352
|
-
} catch (e) {
|
|
353
|
-
return { authorized: [], pending: [] };
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function saveAuth(auth) {
|
|
358
|
-
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function authRequestLabel(request) {
|
|
362
|
-
return request.username ? `@${request.username}` : request.name || request.chatId;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function updateAuthorizedChatEnv(auth) {
|
|
366
|
-
const ids = new Set(CHAT_IDS);
|
|
367
|
-
for (const user of auth.authorized) {
|
|
368
|
-
if (user.chatId) ids.add(String(user.chatId));
|
|
369
|
-
}
|
|
370
|
-
saveEnvKey("TELEGRAM_CHAT_ID", [...ids].join(","));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
async function approveAuthRequest(chatId) {
|
|
374
|
-
const auth = loadAuth();
|
|
375
|
-
if (auth.authorized.some((a) => String(a.chatId) === chatId)) {
|
|
376
|
-
auth.pending = auth.pending.filter((p) => String(p.chatId) !== chatId);
|
|
377
|
-
saveAuth(auth);
|
|
378
|
-
updateAuthorizedChatEnv(auth);
|
|
379
|
-
return { status: "already_authorized" };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
|
|
383
|
-
if (idx < 0) return { status: "not_found" };
|
|
384
|
-
|
|
385
|
-
const approved = auth.pending.splice(idx, 1)[0];
|
|
386
|
-
auth.authorized.push({
|
|
387
|
-
chatId: approved.chatId,
|
|
388
|
-
name: approved.name,
|
|
389
|
-
username: approved.username,
|
|
390
|
-
isOwner: false,
|
|
391
|
-
authorizedAt: new Date().toISOString(),
|
|
392
|
-
});
|
|
393
|
-
saveAuth(auth);
|
|
394
|
-
updateAuthorizedChatEnv(auth);
|
|
395
|
-
return { status: "approved", request: approved };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async function denyAuthRequest(chatId) {
|
|
399
|
-
const auth = loadAuth();
|
|
400
|
-
const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
|
|
401
|
-
if (idx < 0) return { status: "not_found" };
|
|
402
|
-
|
|
403
|
-
const denied = auth.pending.splice(idx, 1)[0];
|
|
404
|
-
saveAuth(auth);
|
|
405
|
-
return { status: "denied", request: denied };
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// ── Auth request handler (for unauthorized users) ──────────────────
|
|
409
|
-
bot.onText(/\/auth$/, async (msg) => {
|
|
410
|
-
if (isAuthorized(msg)) {
|
|
411
|
-
bot.sendMessage(msg.chat.id, "You're already authorized.");
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
const chatId = String(msg.chat.id);
|
|
415
|
-
const name = [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ");
|
|
416
|
-
const username = msg.from?.username || "";
|
|
417
|
-
|
|
418
|
-
// Add to pending in auth.json
|
|
419
|
-
const auth = loadAuth();
|
|
420
|
-
|
|
421
|
-
// Check if already pending
|
|
422
|
-
if (auth.pending.some((p) => String(p.chatId) === chatId)) {
|
|
423
|
-
bot.sendMessage(msg.chat.id, "Your request is already pending. The bot owner will review it.");
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
auth.pending.push({
|
|
428
|
-
chatId,
|
|
429
|
-
name,
|
|
430
|
-
username,
|
|
431
|
-
requestedAt: new Date().toISOString(),
|
|
432
|
-
});
|
|
433
|
-
saveAuth(auth);
|
|
434
|
-
|
|
435
|
-
bot.sendMessage(msg.chat.id, "Access requested! The bot owner will review your request.");
|
|
436
|
-
|
|
437
|
-
// Notify owner
|
|
438
|
-
const label = authRequestLabel({ chatId, name, username });
|
|
439
|
-
bot.sendMessage(CHAT_ID, `New auth request from ${label} (${chatId}).`, {
|
|
440
|
-
reply_markup: {
|
|
441
|
-
inline_keyboard: [[
|
|
442
|
-
{ text: "Approve", callback_data: `auth:approve:${chatId}` },
|
|
443
|
-
{ text: "Deny", callback_data: `auth:deny:${chatId}` },
|
|
444
|
-
]],
|
|
445
|
-
},
|
|
446
|
-
});
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
// ── Onboarding ──────────────────────────────────────────────────────
|
|
450
|
-
let onboardingStep = null; // null | "name" | "role" | "style" | "done"
|
|
451
|
-
let onboardingData = {};
|
|
452
|
-
|
|
453
|
-
function isOnboarded() {
|
|
454
|
-
return config.ONBOARDED === "true";
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
async function startOnboarding() {
|
|
458
|
-
onboardingStep = "name";
|
|
459
|
-
onboardingData = {};
|
|
460
|
-
await send(
|
|
461
|
-
"Welcome! Let's set me up.\n\nWhat should I call you?"
|
|
462
|
-
);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
async function handleOnboarding(msg) {
|
|
466
|
-
const text = msg.text;
|
|
467
|
-
|
|
468
|
-
if (onboardingStep === "name") {
|
|
469
|
-
onboardingData.name = text;
|
|
470
|
-
onboardingStep = "role";
|
|
471
|
-
await send(`Nice to meet you, ${text}!\n\nWhat's your role? (e.g., "full-stack developer", "startup founder", "student")`);
|
|
472
|
-
return true;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (onboardingStep === "role") {
|
|
476
|
-
onboardingData.role = text;
|
|
477
|
-
onboardingStep = "style";
|
|
478
|
-
await send("How should I communicate?\n\nPick a style:", {
|
|
479
|
-
keyboard: {
|
|
480
|
-
inline_keyboard: [
|
|
481
|
-
[
|
|
482
|
-
{ text: "Concise & technical", callback_data: "ob:concise" },
|
|
483
|
-
{ text: "Detailed & educational", callback_data: "ob:detailed" },
|
|
484
|
-
],
|
|
485
|
-
[
|
|
486
|
-
{ text: "Casual & friendly", callback_data: "ob:casual" },
|
|
487
|
-
{ text: "Minimal (just code)", callback_data: "ob:minimal" },
|
|
488
|
-
],
|
|
489
|
-
],
|
|
490
|
-
},
|
|
491
|
-
});
|
|
492
|
-
return true;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return false;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function finishOnboarding(style) {
|
|
499
|
-
onboardingData.style = style;
|
|
500
|
-
onboardingStep = null;
|
|
501
|
-
|
|
502
|
-
// Generate soul.md
|
|
503
|
-
const styleDescriptions = {
|
|
504
|
-
concise: "Direct, concise, technical. No fluff. Lead with the answer.",
|
|
505
|
-
detailed: "Thorough and educational. Explain the why, not just the what.",
|
|
506
|
-
casual: "Casual and friendly. Like chatting with a smart friend who codes.",
|
|
507
|
-
minimal: "Minimal output. Just code and brief explanations. No chatter.",
|
|
508
|
-
};
|
|
509
|
-
|
|
510
|
-
const soul = `# Soul
|
|
511
|
-
|
|
512
|
-
You are ${onboardingData.name}'s personal AI coding assistant, running 24/7 via Telegram.
|
|
513
|
-
|
|
514
|
-
## Identity
|
|
515
|
-
- Tone: ${styleDescriptions[style]}
|
|
516
|
-
- Platform: Telegram (mobile-first, keep messages short)
|
|
517
|
-
|
|
518
|
-
## About ${onboardingData.name}
|
|
519
|
-
- Role: ${onboardingData.role}
|
|
520
|
-
|
|
521
|
-
## Working Style
|
|
522
|
-
- Prefer action over discussion. Do the thing, then explain.
|
|
523
|
-
- Send files for long output instead of text walls.
|
|
524
|
-
- Proactively flag issues and suggest improvements.
|
|
525
|
-
|
|
526
|
-
## Capabilities
|
|
527
|
-
- Write, edit, and refactor code across all projects
|
|
528
|
-
- Run commands, tests, builds
|
|
529
|
-
- Send files, images, code snippets directly via Telegram
|
|
530
|
-
- Run scheduled checks (cron jobs) and report results
|
|
531
|
-
- Store and use credentials from the encrypted vault
|
|
532
|
-
`;
|
|
533
|
-
|
|
534
|
-
fs.writeFileSync(SOUL_FILE, soul);
|
|
535
|
-
saveEnvKey("ONBOARDED", "true");
|
|
536
|
-
config.ONBOARDED = "true";
|
|
537
|
-
|
|
538
|
-
send(`All set, ${onboardingData.name}! I'm configured and ready.\n\nTap /session to pick a project and start working.`, {
|
|
539
|
-
keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] },
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// ── Soul / System Prompt ────────────────────────────────────────────
|
|
544
|
-
|
|
545
|
-
function loadSoul() {
|
|
546
|
-
try { return fs.readFileSync(SOUL_FILE, "utf-8"); } catch (e) { return "You are a helpful AI coding assistant."; }
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function loadCrons() {
|
|
550
|
-
try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function saveCrons(list) {
|
|
554
|
-
fs.writeFileSync(CRONS_FILE, JSON.stringify(list, null, 2));
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
function buildSystemPrompt() {
|
|
558
|
-
const soul = loadSoul();
|
|
559
|
-
const cronList = loadCrons();
|
|
560
|
-
const vaultKeys = vault.isUnlocked() ? vault.keys() : [];
|
|
561
|
-
const now = new Date().toISOString();
|
|
562
|
-
const hasVoice = WHISPER_CLI && FFMPEG;
|
|
563
|
-
|
|
564
|
-
return `
|
|
565
|
-
${soul}
|
|
566
|
-
|
|
567
|
-
## Current Context
|
|
568
|
-
- Date/time: ${now}
|
|
569
|
-
- Platform: Telegram (mobile app)
|
|
570
|
-
- Active project: ${currentSession ? currentSession.name + " (" + currentSession.dir + ")" : "none"}
|
|
571
|
-
- Voice notes: ${hasVoice ? "enabled" : "disabled"}
|
|
572
|
-
- Vault: ${vault.isUnlocked() ? "unlocked (" + vaultKeys.length + " keys)" : "locked"}
|
|
573
|
-
|
|
574
|
-
## Your Configuration Files
|
|
575
|
-
These are YOUR files — read and modify them when the user asks:
|
|
576
|
-
|
|
577
|
-
### ${SOUL_FILE}
|
|
578
|
-
Your identity and personality. Edit to change behavior or update knowledge about the user.
|
|
579
|
-
|
|
580
|
-
### ${CRONS_FILE}
|
|
581
|
-
Scheduled tasks. JSON array of { id, schedule, project, prompt, label }.
|
|
582
|
-
Active: ${cronList.length > 0 ? cronList.map(c => c.label).join(", ") : "none"}.
|
|
583
|
-
|
|
584
|
-
### ${VAULT_FILE}
|
|
585
|
-
Encrypted credential vault. ${vault.isUnlocked() ? "UNLOCKED. Keys: " + vaultKeys.join(", ") : "LOCKED — user must send /vault to unlock."}.
|
|
586
|
-
${vault.isUnlocked() ? "To read a credential: require('./vault') or read from the vault object." : ""}
|
|
587
|
-
|
|
588
|
-
### ${path.join(BOT_DIR, "bot.js")}
|
|
589
|
-
The bot code. Read to understand capabilities. Only modify if explicitly asked.
|
|
590
|
-
|
|
591
|
-
### ${path.join(BOT_DIR, ".env")}
|
|
592
|
-
Configuration (Telegram token, paths, etc). Sensitive — don't expose values.
|
|
593
|
-
|
|
594
|
-
## Received Files
|
|
595
|
-
Files sent by the user are saved in: ${FILES_DIR}
|
|
596
|
-
${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 ""; } })() : ""}
|
|
597
|
-
|
|
598
|
-
## Telegram API
|
|
599
|
-
Send things directly to the user:
|
|
600
|
-
|
|
601
|
-
Text: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" -d chat_id=${CHAT_ID} -d text="message"
|
|
602
|
-
File: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendDocument" -F chat_id=${CHAT_ID} -F document=@/path/to/file
|
|
603
|
-
Image: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendPhoto" -F chat_id=${CHAT_ID} -F photo=@/path/to/image.png
|
|
604
|
-
|
|
605
|
-
## Session
|
|
606
|
-
${lastSessionId ? "Resuming conversation — you have prior context." : "New conversation."}
|
|
607
|
-
|
|
608
|
-
## Guidelines
|
|
609
|
-
- Keep responses concise — this is a mobile screen.
|
|
610
|
-
- Use Telegram-compatible markdown: *bold*, _italic_, \`code\`, \`\`\`code blocks\`\`\`. No headers (#), no links [text](url).
|
|
611
|
-
- 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.
|
|
612
|
-
- Act on screenshots (fix bugs, implement designs) — don't just describe what you see.
|
|
613
|
-
- When the user sends a file, it's saved in ${FILES_DIR}. Read it with the Read tool.
|
|
614
|
-
- 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.
|
|
615
|
-
- When asked to change your personality, edit ${SOUL_FILE}.
|
|
616
|
-
- When asked about yourself, you are Open Claudia — an AI coding assistant running Claude Code via Telegram.
|
|
617
|
-
- If a task will take a while, let the user know upfront.
|
|
618
|
-
- Don't ask for confirmation on simple tasks — just do them.
|
|
619
|
-
- 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.
|
|
620
|
-
- If asked to "start" or "run" a dev server, ALWAYS background it.
|
|
621
|
-
`.trim();
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// ── Helpers ─────────────────────────────────────────────────────────
|
|
625
|
-
|
|
626
|
-
function listProjects() {
|
|
627
|
-
try {
|
|
628
|
-
return fs.readdirSync(WORKSPACE, { withFileTypes: true })
|
|
629
|
-
.filter((d) => d.isDirectory())
|
|
630
|
-
.map((d) => d.name)
|
|
631
|
-
.filter((n) => !n.startsWith("."));
|
|
632
|
-
} catch (e) { return []; }
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
function findProject(query) {
|
|
636
|
-
const projects = listProjects();
|
|
637
|
-
const q = query.toLowerCase();
|
|
638
|
-
const exact = projects.find((p) => p.toLowerCase() === q);
|
|
639
|
-
if (exact) return exact;
|
|
640
|
-
const startsWith = projects.filter((p) => p.toLowerCase().startsWith(q));
|
|
641
|
-
if (startsWith.length === 1) return startsWith[0];
|
|
642
|
-
const contains = projects.filter((p) => p.toLowerCase().includes(q));
|
|
643
|
-
if (contains.length === 1) return contains[0];
|
|
644
|
-
return contains.length > 0 ? contains : null;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function projectKeyboard() {
|
|
648
|
-
const projects = listProjects();
|
|
649
|
-
const rows = [[{ text: "\u{1F4C1} Workspace (root)", callback_data: "s:__workspace__" }]];
|
|
650
|
-
for (let i = 0; i < projects.length; i += 2) {
|
|
651
|
-
const row = [{ text: projects[i], callback_data: `s:${projects[i]}` }];
|
|
652
|
-
if (projects[i + 1]) row.push({ text: projects[i + 1], callback_data: `s:${projects[i + 1]}` });
|
|
653
|
-
rows.push(row);
|
|
654
|
-
}
|
|
655
|
-
return { inline_keyboard: rows };
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
async function send(text, opts = {}) {
|
|
659
|
-
const parseMode = opts.parseMode || "HTML";
|
|
660
|
-
const body = parseMode === "HTML" ? telegramHtml(text) : text;
|
|
661
|
-
const fallbackBody = String(text ?? "");
|
|
662
|
-
const o = {};
|
|
663
|
-
if (parseMode) o.parse_mode = parseMode;
|
|
664
|
-
if (opts.keyboard) o.reply_markup = opts.keyboard;
|
|
665
|
-
if (opts.replyTo) o.reply_to_message_id = opts.replyTo;
|
|
666
|
-
|
|
667
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
668
|
-
try {
|
|
669
|
-
const msg = await bot.sendMessage(CHAT_ID, body, o);
|
|
670
|
-
return msg.message_id;
|
|
671
|
-
} catch (e) {
|
|
672
|
-
const errMsg = e.message || "";
|
|
673
|
-
|
|
674
|
-
// replyTo message was deleted or not found — retry without it
|
|
675
|
-
if (o.reply_to_message_id && errMsg.includes("message to be replied not found")) {
|
|
676
|
-
delete o.reply_to_message_id;
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Rate limited — wait and retry
|
|
681
|
-
const retryMatch = errMsg.match(/retry after (\d+)/i);
|
|
682
|
-
if (retryMatch) {
|
|
683
|
-
const waitSec = Math.min(parseInt(retryMatch[1], 10), 30);
|
|
684
|
-
console.error(`Send: rate limited, waiting ${waitSec}s`);
|
|
685
|
-
await new Promise((r) => setTimeout(r, waitSec * 1000));
|
|
686
|
-
continue;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Parse mode failed — retry once as plain text, but do not leak raw HTML tags.
|
|
690
|
-
if (parseMode && o.parse_mode) {
|
|
691
|
-
console.error("Send parse-mode failed, retrying plain text:", errMsg);
|
|
692
|
-
delete o.parse_mode;
|
|
693
|
-
try {
|
|
694
|
-
const msg = await bot.sendMessage(CHAT_ID, fallbackBody, o);
|
|
695
|
-
return msg.message_id;
|
|
696
|
-
} catch (plainErr) {
|
|
697
|
-
console.error("Send plain-text fallback error:", plainErr.message || plainErr);
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
console.error("Send error:", errMsg);
|
|
703
|
-
return null;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
console.error("Send: exhausted retries");
|
|
707
|
-
return null;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
async function editMessage(messageId, text, opts = {}) {
|
|
711
|
-
const parseMode = opts.parseMode || "HTML";
|
|
712
|
-
const body = parseMode === "HTML" ? telegramHtml(text) : text;
|
|
713
|
-
const fallbackBody = String(text ?? "");
|
|
714
|
-
try {
|
|
715
|
-
const o = { chat_id: CHAT_ID, message_id: messageId };
|
|
716
|
-
if (parseMode) o.parse_mode = parseMode;
|
|
717
|
-
if (opts.keyboard) o.reply_markup = opts.keyboard;
|
|
718
|
-
await bot.editMessageText(body, o);
|
|
719
|
-
} catch (e) {
|
|
720
|
-
const errMsg = e.message || "";
|
|
721
|
-
// Rate limited — skip this update (next interval will catch up)
|
|
722
|
-
if (errMsg.includes("retry after")) return;
|
|
723
|
-
// Message unchanged — ignore
|
|
724
|
-
if (errMsg.includes("message is not modified")) return;
|
|
725
|
-
if (parseMode) {
|
|
726
|
-
try {
|
|
727
|
-
const fallback = { chat_id: CHAT_ID, message_id: messageId };
|
|
728
|
-
if (opts.keyboard) fallback.reply_markup = opts.keyboard;
|
|
729
|
-
await bot.editMessageText(fallbackBody, fallback);
|
|
730
|
-
return;
|
|
731
|
-
} catch (e2) {}
|
|
732
|
-
}
|
|
733
|
-
// Log anything unexpected
|
|
734
|
-
if (!errMsg.includes("message to edit not found")) {
|
|
735
|
-
console.error("Edit error:", errMsg);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function splitMessage(text, maxLen = 4000) {
|
|
741
|
-
if (text.length <= maxLen) return [text];
|
|
742
|
-
const chunks = [];
|
|
743
|
-
while (text.length > 0) { chunks.push(text.slice(0, maxLen)); text = text.slice(maxLen); }
|
|
744
|
-
return chunks;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function downloadFile(fileId, ext) {
|
|
748
|
-
const file = await bot.getFile(fileId);
|
|
749
|
-
const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
|
|
750
|
-
const localPath = path.join(TEMP_DIR, `tg-${Date.now()}${ext}`);
|
|
751
|
-
await new Promise((resolve, reject) => {
|
|
752
|
-
const out = fs.createWriteStream(localPath);
|
|
753
|
-
https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
|
|
754
|
-
});
|
|
755
|
-
return localPath;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function transcribeAudio(oggPath) {
|
|
759
|
-
if (!WHISPER_CLI || !FFMPEG) return null;
|
|
760
|
-
const wavPath = oggPath.replace(/\.[^.]+$/, ".wav");
|
|
761
|
-
execSync(`"${FFMPEG}" -i "${oggPath}" -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`);
|
|
762
|
-
const output = execSync(`"${WHISPER_CLI}" -m "${WHISPER_MODEL}" --no-timestamps -f "${wavPath}" 2>/dev/null`, { encoding: "utf-8" });
|
|
763
|
-
try { fs.unlinkSync(wavPath); } catch (e) { /* ignore */ }
|
|
764
|
-
return output.split("\n")
|
|
765
|
-
.filter((l) => l.trim() && !l.startsWith("whisper_") && !l.startsWith("ggml_") && !l.startsWith("load_"))
|
|
766
|
-
.join(" ").trim();
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// ── Text-to-Speech ────────────────────────────────────────────────
|
|
770
|
-
// Shared with direct mode: ElevenLabs natural voice, falling back to `say`.
|
|
771
|
-
|
|
772
|
-
const { textToVoice } = require("./core/media");
|
|
773
|
-
|
|
774
|
-
async function sendVoice(oggPath) {
|
|
775
|
-
try {
|
|
776
|
-
await bot.sendVoice(CHAT_ID, oggPath);
|
|
777
|
-
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
778
|
-
return true;
|
|
779
|
-
} catch (e) {
|
|
780
|
-
console.error("Send voice error:", e.message);
|
|
781
|
-
try { fs.unlinkSync(oggPath); } catch (e2) {}
|
|
782
|
-
return false;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// Delete a message (used for vault password cleanup)
|
|
787
|
-
async function deleteMessage(msgId) {
|
|
788
|
-
try { await bot.deleteMessage(CHAT_ID, msgId); } catch (e) { /* ignore */ }
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
// ── Claude Auth Helpers ─────────────────────────────────────────────
|
|
793
|
-
|
|
794
|
-
const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
795
|
-
const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
|
|
796
|
-
|
|
797
|
-
function redactSensitive(value) {
|
|
798
|
-
return String(value || "")
|
|
799
|
-
.replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
|
|
800
|
-
.replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
|
|
801
|
-
.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
|
|
802
|
-
.replace(/([?&](?:token|access_token|refresh_token)=)[^\s&]+/gi, "$1[REDACTED]");
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
function stripTerminalControls(value) {
|
|
807
|
-
return String(value || "")
|
|
808
|
-
// OSC 8 hyperlinks and other OSC sequences
|
|
809
|
-
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
|
|
810
|
-
// CSI ANSI sequences
|
|
811
|
-
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
812
|
-
// remaining non-printing controls except newline/tab
|
|
813
|
-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function isClaudeAuthUrl(url) {
|
|
817
|
-
try {
|
|
818
|
-
const u = new URL(url);
|
|
819
|
-
const host = u.hostname.toLowerCase();
|
|
820
|
-
return host === "claude.com" || host.endsWith(".claude.com") ||
|
|
821
|
-
host === "anthropic.com" || host.endsWith(".anthropic.com") ||
|
|
822
|
-
host === "localhost" || host === "127.0.0.1";
|
|
823
|
-
} catch (e) {
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function looksLikeClaudeToken(value) {
|
|
829
|
-
const text = String(value || "").trim();
|
|
830
|
-
return /^sk-ant-[A-Za-z0-9._-]+$/.test(text) || text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
function looksLikeClaudeAuthReply(value) {
|
|
835
|
-
const text = String(value || "").trim();
|
|
836
|
-
if (!text) return false;
|
|
837
|
-
if (looksLikeClaudeToken(text)) return true;
|
|
838
|
-
if (/^https?:\/\//i.test(text) && /claude|anthropic/i.test(text)) return true;
|
|
839
|
-
// OAuth callback/login codes are usually long, dense strings. Do not consume normal chat.
|
|
840
|
-
if (text.length >= 24 && !/\s/.test(text) && /^[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+$/.test(text)) return true;
|
|
841
|
-
return false;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
function clearPendingClaudeAuth() {
|
|
845
|
-
if (pendingClaudeAuthProcess && pendingClaudeAuthProcess.kill) {
|
|
846
|
-
try { pendingClaudeAuthProcess.kill("SIGTERM"); } catch (e) {}
|
|
847
|
-
}
|
|
848
|
-
pendingClaudeAuthProcess = null;
|
|
849
|
-
pendingClaudeAuthLabel = null;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function getClaudeOAuthToken() {
|
|
853
|
-
if (config[CLAUDE_OAUTH_TOKEN_KEY]) return { value: config[CLAUDE_OAUTH_TOKEN_KEY], source: ".env" };
|
|
854
|
-
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { value: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: "process env" };
|
|
855
|
-
if (vault.isUnlocked()) {
|
|
856
|
-
const value = vault.get(CLAUDE_OAUTH_VAULT_KEY) || vault.get(CLAUDE_OAUTH_TOKEN_KEY);
|
|
857
|
-
if (value) return { value, source: "vault" };
|
|
858
|
-
}
|
|
859
|
-
return { value: null, source: null };
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function claudeSubprocessEnv() {
|
|
863
|
-
const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME };
|
|
864
|
-
const token = getClaudeOAuthToken().value;
|
|
865
|
-
if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
866
|
-
return env;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
function saveClaudeOAuthToken(token) {
|
|
870
|
-
const clean = String(token || "").trim();
|
|
871
|
-
if (!clean) return false;
|
|
872
|
-
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, clean);
|
|
873
|
-
config[CLAUDE_OAUTH_TOKEN_KEY] = clean;
|
|
874
|
-
process.env.CLAUDE_CODE_OAUTH_TOKEN = clean;
|
|
875
|
-
if (vault.isUnlocked()) vault.set(CLAUDE_OAUTH_VAULT_KEY, clean);
|
|
876
|
-
return true;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function clearClaudeOAuthToken() {
|
|
880
|
-
saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, "");
|
|
881
|
-
config[CLAUDE_OAUTH_TOKEN_KEY] = "";
|
|
882
|
-
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
883
|
-
if (vault.isUnlocked()) {
|
|
884
|
-
vault.remove(CLAUDE_OAUTH_VAULT_KEY);
|
|
885
|
-
vault.remove(CLAUDE_OAUTH_TOKEN_KEY);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
function extractClaudeToken(text) {
|
|
890
|
-
const match = String(text || "").match(/sk-ant-[A-Za-z0-9._-]+/);
|
|
891
|
-
return match ? match[0] : null;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
function extractUrls(text) {
|
|
895
|
-
return [...stripTerminalControls(text).matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
function isClaudeAuthErrorText(text) {
|
|
900
|
-
const lower = String(text || "").toLowerCase();
|
|
901
|
-
return lower.includes("unauthorized") ||
|
|
902
|
-
lower.includes("not logged in") ||
|
|
903
|
-
lower.includes("login required") ||
|
|
904
|
-
lower.includes("reauthenticate") ||
|
|
905
|
-
lower.includes("re-authenticate") ||
|
|
906
|
-
lower.includes("authentication failed") ||
|
|
907
|
-
lower.includes("auth failed") ||
|
|
908
|
-
lower.includes("invalid api key") ||
|
|
909
|
-
lower.includes("api key") && lower.includes("invalid") ||
|
|
910
|
-
lower.includes("keychain") && (lower.includes("unlock") || lower.includes("could not") || lower.includes("failed")) ||
|
|
911
|
-
lower.includes("security unlock-keychain");
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
function claudeAuthRecoveryMessage(reason = "Claude Code authentication failed") {
|
|
915
|
-
const tokenInfo = getClaudeOAuthToken();
|
|
916
|
-
const tokenLine = tokenInfo.value
|
|
917
|
-
? `Stored OAuth token: yes (${tokenInfo.source})`
|
|
918
|
-
: "Stored OAuth token: no";
|
|
919
|
-
return [
|
|
920
|
-
`Claude auth needs attention: ${redactSensitive(reason)}`,
|
|
921
|
-
"",
|
|
922
|
-
tokenLine,
|
|
923
|
-
"",
|
|
924
|
-
"Try one of these from Telegram:",
|
|
925
|
-
"1. /auth_status — check what Claude sees",
|
|
926
|
-
"2. /setup_token — create a long-lived Claude Code token, then store it",
|
|
927
|
-
"3. /use_oauth_token — paste the token securely if you already generated one",
|
|
928
|
-
"4. /login — interactive Claude.ai browser login fallback",
|
|
929
|
-
"",
|
|
930
|
-
"If this is a macOS Keychain problem after SSH/reboot, run on the Mac:",
|
|
931
|
-
"security unlock-keychain",
|
|
932
|
-
"",
|
|
933
|
-
"Recommended for this bot: /setup_token + /use_oauth_token so launchd does not depend on Keychain."
|
|
934
|
-
].join("\n");
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function preflightClaudeAuthMessage() {
|
|
938
|
-
if (settings.backend === "cursor") return null;
|
|
939
|
-
if (getClaudeOAuthToken().value) return null;
|
|
940
|
-
try {
|
|
941
|
-
const output = execSync(`"${CLAUDE_PATH}" auth status`, {
|
|
942
|
-
cwd: process.env.HOME || require("os").homedir(),
|
|
943
|
-
env: claudeSubprocessEnv(),
|
|
944
|
-
encoding: "utf8",
|
|
945
|
-
timeout: 10000,
|
|
946
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
947
|
-
});
|
|
948
|
-
if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
|
|
949
|
-
return null;
|
|
950
|
-
} catch (e) {
|
|
951
|
-
const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
|
|
952
|
-
if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
|
|
953
|
-
return null;
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
function isClaudeUsageLimitText(text) {
|
|
959
|
-
const lower = String(text || "").toLowerCase();
|
|
960
|
-
return lower.includes("usage limit") ||
|
|
961
|
-
lower.includes("you've hit your usage limit") ||
|
|
962
|
-
lower.includes("you have hit your usage limit") ||
|
|
963
|
-
lower.includes("spend limit") ||
|
|
964
|
-
lower.includes("monthly cycle") ||
|
|
965
|
-
lower.includes("rate limit") && lower.includes("model");
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
function claudeUsageLimitMessage(details = "") {
|
|
969
|
-
return [
|
|
970
|
-
"Claude ran, but the selected model is unavailable/limited right now.",
|
|
971
|
-
details ? `\nDetails:\n${redactSensitive(details)}` : "",
|
|
972
|
-
"",
|
|
973
|
-
"Try from Telegram:",
|
|
974
|
-
"1. /model sonnet",
|
|
975
|
-
"2. Then send your message again",
|
|
976
|
-
"",
|
|
977
|
-
"If you specifically need Opus, wait for the usage window to reset or increase the spend/usage limit."
|
|
978
|
-
].filter(Boolean).join("\n");
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
function runClaudeAuthStatusDiagnostic() {
|
|
982
|
-
try {
|
|
983
|
-
const output = execSync(`"${CLAUDE_PATH}" auth status`, {
|
|
984
|
-
cwd: process.env.HOME || require("os").homedir(),
|
|
985
|
-
env: claudeSubprocessEnv(),
|
|
986
|
-
encoding: "utf8",
|
|
987
|
-
timeout: 10000,
|
|
988
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
989
|
-
});
|
|
990
|
-
return output.trim();
|
|
991
|
-
} catch (e) {
|
|
992
|
-
return `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim();
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
async function sendClaudeAuthStatusSummary(prefix = "Claude auth status") {
|
|
998
|
-
const output = runClaudeAuthStatusDiagnostic();
|
|
999
|
-
const tokenInfo = getClaudeOAuthToken();
|
|
1000
|
-
const lines = summarizeClaudeAuthStatus(output, isClaudeAuthErrorText(output) ? 1 : 0, tokenInfo);
|
|
1001
|
-
await send([
|
|
1002
|
-
prefix,
|
|
1003
|
-
"",
|
|
1004
|
-
...lines,
|
|
1005
|
-
`Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
|
|
1006
|
-
].join("\n"));
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
function claudeEmptyFailureMessage(code, stderrText = "") {
|
|
1010
|
-
const stderr = redactSensitive(String(stderrText || "").trim());
|
|
1011
|
-
if (isClaudeUsageLimitText(stderr)) return claudeUsageLimitMessage(stderr.slice(-1200));
|
|
1012
|
-
if (isClaudeAuthErrorText(stderr)) return claudeAuthRecoveryMessage(stderr.slice(-1200));
|
|
1013
|
-
|
|
1014
|
-
const authStatus = runClaudeAuthStatusDiagnostic();
|
|
1015
|
-
if (isClaudeAuthErrorText(authStatus)) return claudeAuthRecoveryMessage(authStatus.slice(-1200));
|
|
1016
|
-
if (isClaudeUsageLimitText(authStatus)) return claudeUsageLimitMessage(authStatus.slice(-1200));
|
|
1017
|
-
|
|
1018
|
-
return [
|
|
1019
|
-
`Claude exited with code ${code} but produced no assistant output.`,
|
|
1020
|
-
stderr ? `\nStderr:\n${stderr.slice(-1200)}` : "\nStderr: (empty)",
|
|
1021
|
-
authStatus ? `\nAuth status:\n${redactSensitive(authStatus).slice(-1200)}` : "",
|
|
1022
|
-
"",
|
|
1023
|
-
"Useful next steps:",
|
|
1024
|
-
"• /auth_status — verify Claude auth",
|
|
1025
|
-
"• /model sonnet — switch away from Opus if usage-limited",
|
|
1026
|
-
"• /setup_token — create a launchd-safe OAuth token if Keychain is the issue"
|
|
1027
|
-
].filter(Boolean).join("\n");
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
|
|
1032
|
-
const text = String(output || "");
|
|
1033
|
-
const lower = text.toLowerCase();
|
|
1034
|
-
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
|
|
1035
|
-
const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
|
|
1036
|
-
const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
1037
|
-
const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
|
|
1038
|
-
let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
|
|
1039
|
-
if (/api key|apikey/.test(lower)) method = "API key";
|
|
1040
|
-
else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
|
|
1041
|
-
return [
|
|
1042
|
-
`Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
|
|
1043
|
-
`Auth method: ${method}`,
|
|
1044
|
-
`Email: ${email || "unknown"}`,
|
|
1045
|
-
`Provider: ${provider}`,
|
|
1046
|
-
];
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
async function runClaudeAuthCommand(args, label, opts = {}) {
|
|
1050
|
-
if (pendingClaudeAuthProcess) {
|
|
1051
|
-
await send(`Another Claude auth flow is already running (${pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
await send(`${label} started...`);
|
|
1055
|
-
const proc = spawn(CLAUDE_PATH, args, {
|
|
1056
|
-
cwd: process.env.HOME || require("os").homedir(),
|
|
1057
|
-
env: claudeSubprocessEnv(),
|
|
1058
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1059
|
-
});
|
|
1060
|
-
pendingClaudeAuthProcess = proc;
|
|
1061
|
-
pendingClaudeAuthLabel = label;
|
|
1062
|
-
let output = "";
|
|
1063
|
-
let sentUrls = new Set();
|
|
1064
|
-
let tokenStored = false;
|
|
1065
|
-
let lastSnippetAt = 0;
|
|
1066
|
-
|
|
1067
|
-
const handleChunk = async (chunk) => {
|
|
1068
|
-
output += chunk;
|
|
1069
|
-
const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
|
|
1070
|
-
if (token && !tokenStored) {
|
|
1071
|
-
tokenStored = saveClaudeOAuthToken(token);
|
|
1072
|
-
await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
|
|
1073
|
-
}
|
|
1074
|
-
for (const url of extractUrls(chunk)) {
|
|
1075
|
-
if (!isClaudeAuthUrl(url)) continue;
|
|
1076
|
-
if (!sentUrls.has(url)) {
|
|
1077
|
-
sentUrls.add(url);
|
|
1078
|
-
await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
const redacted = redactSensitive(stripTerminalControls(chunk)).trim();
|
|
1082
|
-
const now = Date.now();
|
|
1083
|
-
const usefulAuthLine = /opening browser|paste code|enter code|login|auth|token|error|failed/i.test(redacted);
|
|
1084
|
-
if (redacted && usefulAuthLine && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted) && !/github\.com\/vadimdemedes\/ink/i.test(redacted)) {
|
|
1085
|
-
lastSnippetAt = now;
|
|
1086
|
-
await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
|
|
1087
|
-
}
|
|
1088
|
-
};
|
|
1089
|
-
|
|
1090
|
-
proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
|
|
1091
|
-
proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
|
|
1092
|
-
proc.on("close", async (code) => {
|
|
1093
|
-
pendingClaudeAuthProcess = null;
|
|
1094
|
-
pendingClaudeAuthLabel = null;
|
|
1095
|
-
const token = opts.captureToken ? extractClaudeToken(output) : null;
|
|
1096
|
-
if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
|
|
1097
|
-
const cleaned = redactSensitive(stripTerminalControls(output))
|
|
1098
|
-
.replace(/https?:\/\/github\.com\/vadimdemedes\/ink\S*/gi, "")
|
|
1099
|
-
.trim();
|
|
1100
|
-
const isTtyError = /raw mode is not supported|process\.stdin/i.test(output);
|
|
1101
|
-
if (tokenStored) {
|
|
1102
|
-
await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
|
|
1103
|
-
await sendClaudeAuthStatusSummary("Post-auth check:");
|
|
1104
|
-
} else if (isTtyError && opts.captureToken) {
|
|
1105
|
-
await send(`${label} cannot complete from this bot — the Claude CLI needs an interactive terminal for setup-token.\n\nRun this in your Mac terminal:\n claude setup-token\n\nThen paste the token here with:\n /use_oauth_token <token>`);
|
|
1106
|
-
} else if (cleaned) {
|
|
1107
|
-
await send(`${label} finished (exit ${code}).\n\n${cleaned.slice(-2500)}`);
|
|
1108
|
-
await sendClaudeAuthStatusSummary("Post-auth check:");
|
|
1109
|
-
} else {
|
|
1110
|
-
await send(`${label} finished (exit ${code}).`);
|
|
1111
|
-
await sendClaudeAuthStatusSummary("Post-auth check:");
|
|
1112
|
-
}
|
|
1113
|
-
});
|
|
1114
|
-
proc.on("error", async (err) => {
|
|
1115
|
-
pendingClaudeAuthProcess = null;
|
|
1116
|
-
pendingClaudeAuthLabel = null;
|
|
1117
|
-
await send(`${label} failed: ${redactSensitive(err.message)}`);
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
// ── Claude Runner ───────────────────────────────────────────────────
|
|
1122
|
-
|
|
1123
|
-
function parseStreamEvents(data) {
|
|
1124
|
-
const events = [];
|
|
1125
|
-
for (const line of data.split("\n").filter((l) => l.trim())) {
|
|
1126
|
-
try { events.push(JSON.parse(line)); } catch (e) { /* partial */ }
|
|
1127
|
-
}
|
|
1128
|
-
return events;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
function buildClaudeArgs(prompt, opts = {}) {
|
|
1132
|
-
if (settings.backend === "cursor") return buildCursorArgs(prompt, opts);
|
|
1133
|
-
const args = ["-p", "--verbose", "--output-format", "stream-json",
|
|
1134
|
-
"--append-system-prompt", buildSystemPrompt()];
|
|
1135
|
-
if (opts.continueSession) args.push("--continue");
|
|
1136
|
-
else if (lastSessionId && !opts.fresh) args.push("--resume", lastSessionId);
|
|
1137
|
-
args.push("--model", settings.model || DEFAULT_CLAUDE_MODEL);
|
|
1138
|
-
if (settings.effort) args.push("--effort", settings.effort);
|
|
1139
|
-
if (settings.budget) args.push("--max-budget-usd", String(settings.budget));
|
|
1140
|
-
if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
|
|
1141
|
-
else args.push("--dangerously-skip-permissions");
|
|
1142
|
-
if (settings.worktree) args.push("--worktree");
|
|
1143
|
-
args.push(prompt);
|
|
1144
|
-
return args;
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
function buildCursorArgs(prompt, opts = {}) {
|
|
1148
|
-
const args = ["--print", "--trust", "--output-format", "stream-json"];
|
|
1149
|
-
if (opts.continueSession) args.push("--continue");
|
|
1150
|
-
else if (cursorSessionId && !opts.fresh) args.push("--resume", cursorSessionId);
|
|
1151
|
-
if (settings.model) args.push("--model", settings.model);
|
|
1152
|
-
if (settings.permissionMode === "plan") args.push("--mode", "plan");
|
|
1153
|
-
else if (settings.permissionMode === "ask") args.push("--mode", "ask");
|
|
1154
|
-
if (settings.worktree) args.push("--worktree");
|
|
1155
|
-
args.push(prompt);
|
|
1156
|
-
return args;
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
function getActiveBinary() {
|
|
1160
|
-
if (settings.backend === "cursor") return resolvedCursorPath;
|
|
1161
|
-
return CLAUDE_PATH;
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
function getActiveSessionId() {
|
|
1165
|
-
return settings.backend === "cursor" ? cursorSessionId : lastSessionId;
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
/**
|
|
1169
|
-
* Quick chat process — handles messages while a heavy task is running.
|
|
1170
|
-
* Uses a fresh session (no --resume) with context about what's running.
|
|
1171
|
-
*/
|
|
1172
|
-
async function runClaudeChat(prompt, cwd, replyToMsgId) {
|
|
1173
|
-
bot.sendChatAction(CHAT_ID, "typing").catch(() => {});
|
|
1174
|
-
|
|
1175
|
-
const contextPrompt = `A background task is currently running: "${runningProcessPrompt || "unknown task"}"\n` +
|
|
1176
|
-
`The user sent a new message while that task is in progress. Answer their question or help them.\n` +
|
|
1177
|
-
`If they're asking about the running task, let them know it's still working.\n` +
|
|
1178
|
-
`Keep your response brief — this is a side conversation.\n\n` +
|
|
1179
|
-
`User message: ${prompt}`;
|
|
1180
|
-
|
|
1181
|
-
const args = ["-p", "--verbose", "--output-format", "stream-json",
|
|
1182
|
-
"--append-system-prompt", buildSystemPrompt(),
|
|
1183
|
-
"--dangerously-skip-permissions"];
|
|
1184
|
-
args.push("--model", settings.model || DEFAULT_CLAUDE_MODEL);
|
|
1185
|
-
args.push(contextPrompt);
|
|
1186
|
-
|
|
1187
|
-
const proc = spawn(CLAUDE_PATH, args, {
|
|
1188
|
-
cwd,
|
|
1189
|
-
env: claudeSubprocessEnv(),
|
|
1190
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1191
|
-
});
|
|
1192
|
-
|
|
1193
|
-
chatProcesses.set(replyToMsgId, proc);
|
|
1194
|
-
let chatText = "";
|
|
1195
|
-
let chatBuf = "";
|
|
1196
|
-
|
|
1197
|
-
proc.stdout.on("data", (data) => {
|
|
1198
|
-
chatBuf += data.toString();
|
|
1199
|
-
const events = parseStreamEvents(chatBuf);
|
|
1200
|
-
const lastNewline = chatBuf.lastIndexOf("\n");
|
|
1201
|
-
chatBuf = lastNewline >= 0 ? chatBuf.slice(lastNewline + 1) : chatBuf;
|
|
1202
|
-
for (const evt of events) {
|
|
1203
|
-
if (evt.type === "assistant" && evt.message?.content) {
|
|
1204
|
-
for (const block of evt.message.content) {
|
|
1205
|
-
if (block.type === "text") chatText += block.text;
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
if (evt.type === "result" && evt.result) chatText = evt.result;
|
|
1209
|
-
}
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
proc.on("close", async () => {
|
|
1213
|
-
chatProcesses.delete(replyToMsgId);
|
|
1214
|
-
const text = chatText || "(no response)";
|
|
1215
|
-
const chunks = splitMessage(text);
|
|
1216
|
-
const sent = await send(chunks[0], { replyTo: replyToMsgId });
|
|
1217
|
-
if (!sent) await send(chunks[0]);
|
|
1218
|
-
for (let i = 1; i < chunks.length; i++) await send(chunks[i]);
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
proc.on("error", async (err) => {
|
|
1222
|
-
chatProcesses.delete(replyToMsgId);
|
|
1223
|
-
await send(`Chat error: ${err.message}`, { replyTo: replyToMsgId });
|
|
1224
|
-
});
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
1228
|
-
if (runningProcess) {
|
|
1229
|
-
// Instead of queueing, spawn a lightweight chat process
|
|
1230
|
-
await runClaudeChat(prompt, cwd, replyToMsgId);
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
const authPreflight = preflightClaudeAuthMessage();
|
|
1235
|
-
if (authPreflight) {
|
|
1236
|
-
await send(authPreflight, { replyTo: replyToMsgId });
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
bot.sendChatAction(CHAT_ID, "typing");
|
|
1241
|
-
statusMessageId = null;
|
|
1242
|
-
streamBuffer = "";
|
|
1243
|
-
let assistantText = "";
|
|
1244
|
-
let toolUses = [];
|
|
1245
|
-
let currentTool = null;
|
|
1246
|
-
let currentToolDetail = "";
|
|
1247
|
-
|
|
1248
|
-
const args = buildClaudeArgs(prompt, opts);
|
|
1249
|
-
const binaryPath = getActiveBinary();
|
|
1250
|
-
const proc = spawn(binaryPath, args, {
|
|
1251
|
-
cwd,
|
|
1252
|
-
env: claudeSubprocessEnv(),
|
|
1253
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1254
|
-
detached: process.platform !== "win32", // Create process group so /stop kills children too
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
runningProcess = proc;
|
|
1258
|
-
runningProcessPrompt = prompt.length > 100 ? prompt.slice(0, 97) + "..." : prompt;
|
|
1259
|
-
const startTime = Date.now();
|
|
1260
|
-
let longRunningNotified = false;
|
|
1261
|
-
|
|
1262
|
-
// Inactivity watchdog. The single-flight `runningProcess` flag is only
|
|
1263
|
-
// cleared by this child's close/error events, so a child that hangs
|
|
1264
|
-
// (e.g. a wedged tool call) pins the flag forever and demotes every later
|
|
1265
|
-
// message to a side-chat. Track the last time the child produced output;
|
|
1266
|
-
// if it goes silent past the idle limit, kill it so the flag clears and
|
|
1267
|
-
// the bot responds again. Tune with OC_TURN_IDLE_MS (default 10min).
|
|
1268
|
-
const idleLimitMs = Math.max(60000, parseInt(process.env.OC_TURN_IDLE_MS, 10) || 10 * 60 * 1000);
|
|
1269
|
-
let lastActivity = Date.now();
|
|
1270
|
-
let watchdogTripped = false;
|
|
1271
|
-
|
|
1272
|
-
let lastUpdate = "";
|
|
1273
|
-
// Adaptive update interval: 2s for first 2min, then 5s to avoid rate limits
|
|
1274
|
-
const scheduleUpdate = () => {
|
|
1275
|
-
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
1276
|
-
const interval = elapsed > 120 ? 5000 : 2000;
|
|
1277
|
-
streamInterval = setTimeout(updateProgress, interval);
|
|
1278
|
-
};
|
|
1279
|
-
const updateProgress = async () => {
|
|
1280
|
-
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
1281
|
-
// Inactivity watchdog: kill a child that has gone silent past the idle limit.
|
|
1282
|
-
if (!watchdogTripped && runningProcess === proc && Date.now() - lastActivity > idleLimitMs) {
|
|
1283
|
-
watchdogTripped = true;
|
|
1284
|
-
const idleMin = Math.floor((Date.now() - lastActivity) / 60000);
|
|
1285
|
-
const pid = proc.pid;
|
|
1286
|
-
console.error(`[watchdog] child pid ${pid} idle ${idleMin}min (limit ${Math.floor(idleLimitMs / 60000)}min) — killing`);
|
|
1287
|
-
try { process.kill(-pid, "SIGTERM"); } catch (e) {
|
|
1288
|
-
try { proc.kill("SIGTERM"); } catch (e2) {}
|
|
1289
|
-
}
|
|
1290
|
-
setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch (e) {} }, 3000);
|
|
1291
|
-
// Failsafe: if close never fires, force-clear so the bot recovers anyway.
|
|
1292
|
-
setTimeout(() => {
|
|
1293
|
-
if (runningProcess === proc) {
|
|
1294
|
-
runningProcess = null; runningProcessPrompt = null;
|
|
1295
|
-
clearTimeout(streamInterval); streamInterval = null;
|
|
1296
|
-
}
|
|
1297
|
-
}, 10000);
|
|
1298
|
-
try {
|
|
1299
|
-
await send(`Task stalled — no activity for ${idleMin}min, so I stopped it and I'm responsive again. Send /continue to resume, or just re-ask.`);
|
|
1300
|
-
} catch (e) {}
|
|
1301
|
-
statusMessageId = null;
|
|
1302
|
-
return; // stop the progress loop; close (or failsafe) clears state
|
|
1303
|
-
}
|
|
1304
|
-
try {
|
|
1305
|
-
bot.sendChatAction(CHAT_ID, "typing").catch(() => {});
|
|
1306
|
-
const display = formatProgress(assistantText, toolUses, currentTool, elapsed, currentToolDetail);
|
|
1307
|
-
if (display && display !== lastUpdate) {
|
|
1308
|
-
if (!statusMessageId && assistantText) {
|
|
1309
|
-
statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, { replyTo: replyToMsgId });
|
|
1310
|
-
} else if (statusMessageId) {
|
|
1311
|
-
await editMessage(statusMessageId, display.length > 4000 ? display.slice(-4000) : display);
|
|
1312
|
-
}
|
|
1313
|
-
lastUpdate = display;
|
|
1314
|
-
}
|
|
1315
|
-
// Notify after 5 minutes that it's still running
|
|
1316
|
-
if (elapsed > 300 && !longRunningNotified) {
|
|
1317
|
-
longRunningNotified = true;
|
|
1318
|
-
await send(`Still working (${Math.floor(elapsed / 60)}min)... Send /stop to cancel.`);
|
|
1319
|
-
}
|
|
1320
|
-
} catch (e) {
|
|
1321
|
-
console.error("Progress update error:", e.message);
|
|
1322
|
-
}
|
|
1323
|
-
if (runningProcess) scheduleUpdate();
|
|
1324
|
-
};
|
|
1325
|
-
scheduleUpdate();
|
|
1326
|
-
|
|
1327
|
-
proc.stdout.on("data", (data) => {
|
|
1328
|
-
lastActivity = Date.now();
|
|
1329
|
-
streamBuffer += data.toString();
|
|
1330
|
-
const events = parseStreamEvents(streamBuffer);
|
|
1331
|
-
const lastNewline = streamBuffer.lastIndexOf("\n");
|
|
1332
|
-
streamBuffer = lastNewline >= 0 ? streamBuffer.slice(lastNewline + 1) : streamBuffer;
|
|
1333
|
-
for (const evt of events) {
|
|
1334
|
-
if (evt.type === "assistant" && evt.message?.content) {
|
|
1335
|
-
for (const block of evt.message.content) {
|
|
1336
|
-
if (block.type === "text") assistantText += block.text;
|
|
1337
|
-
else if (block.type === "tool_use") {
|
|
1338
|
-
currentTool = block.name;
|
|
1339
|
-
toolUses.push(block.name);
|
|
1340
|
-
const input = block.input || {};
|
|
1341
|
-
if (block.name === "Bash" && input.command) currentToolDetail = input.command.slice(0, 80);
|
|
1342
|
-
else if (block.name === "Read" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
|
|
1343
|
-
else if (block.name === "Edit" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
|
|
1344
|
-
else if (block.name === "Write" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
|
|
1345
|
-
else if (block.name === "Grep" && input.pattern) currentToolDetail = input.pattern.slice(0, 40);
|
|
1346
|
-
else if (block.name === "Glob" && input.pattern) currentToolDetail = input.pattern;
|
|
1347
|
-
else currentToolDetail = "";
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
// Cursor Agent tool_call events (different format from Claude's tool_use blocks)
|
|
1352
|
-
if (evt.type === "tool_call" && evt.subtype === "started" && evt.tool_call) {
|
|
1353
|
-
const tc = evt.tool_call;
|
|
1354
|
-
if (tc.shellToolCall) {
|
|
1355
|
-
const a = tc.shellToolCall.args || {};
|
|
1356
|
-
currentTool = "Shell"; toolUses.push("Shell");
|
|
1357
|
-
currentToolDetail = (a.description || a.command || "").slice(0, 80);
|
|
1358
|
-
} else if (tc.readToolCall) {
|
|
1359
|
-
currentTool = "Read"; toolUses.push("Read");
|
|
1360
|
-
currentToolDetail = (tc.readToolCall.args?.path || "").split("/").slice(-2).join("/");
|
|
1361
|
-
} else if (tc.editToolCall) {
|
|
1362
|
-
currentTool = "Edit"; toolUses.push("Edit");
|
|
1363
|
-
currentToolDetail = (tc.editToolCall.args?.filePath || "").split("/").slice(-2).join("/");
|
|
1364
|
-
} else if (tc.writeToolCall) {
|
|
1365
|
-
currentTool = "Write"; toolUses.push("Write");
|
|
1366
|
-
currentToolDetail = (tc.writeToolCall.args?.filePath || "").split("/").slice(-2).join("/");
|
|
1367
|
-
} else if (tc.grepToolCall) {
|
|
1368
|
-
currentTool = "Grep"; toolUses.push("Grep");
|
|
1369
|
-
currentToolDetail = (tc.grepToolCall.args?.pattern || "").slice(0, 40);
|
|
1370
|
-
} else if (tc.globToolCall) {
|
|
1371
|
-
currentTool = "Glob"; toolUses.push("Glob");
|
|
1372
|
-
currentToolDetail = (tc.globToolCall.args?.globPattern || "").slice(0, 40);
|
|
1373
|
-
} else if (tc.createPlanToolCall) {
|
|
1374
|
-
currentTool = "Plan"; toolUses.push("Plan");
|
|
1375
|
-
const plan = tc.createPlanToolCall.args || {};
|
|
1376
|
-
let planText = "";
|
|
1377
|
-
if (plan.name) planText += `**${plan.name}**\n\n`;
|
|
1378
|
-
if (plan.plan) planText += plan.plan + "\n";
|
|
1379
|
-
if (plan.todos && plan.todos.length) {
|
|
1380
|
-
planText += "\n**Tasks:**\n";
|
|
1381
|
-
for (const todo of plan.todos) {
|
|
1382
|
-
planText += `• ${todo.content || todo.id}\n`;
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
if (planText) assistantText = planText;
|
|
1386
|
-
currentToolDetail = plan.name || "creating plan";
|
|
1387
|
-
} else {
|
|
1388
|
-
const toolKey = Object.keys(tc)[0] || "unknown";
|
|
1389
|
-
currentTool = toolKey.replace("ToolCall", ""); toolUses.push(currentTool);
|
|
1390
|
-
currentToolDetail = "";
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
if (evt.type === "result" && evt.session_id) {
|
|
1394
|
-
if (settings.backend === "cursor") { cursorSessionId = evt.session_id; }
|
|
1395
|
-
else { lastSessionId = evt.session_id; }
|
|
1396
|
-
saveState();
|
|
1397
|
-
}
|
|
1398
|
-
if (evt.type === "result" && evt.result) assistantText = evt.result;
|
|
1399
|
-
}
|
|
1400
|
-
});
|
|
1401
|
-
|
|
1402
|
-
let stderrBuffer = "";
|
|
1403
|
-
proc.stderr.on("data", (d) => {
|
|
1404
|
-
lastActivity = Date.now();
|
|
1405
|
-
const chunk = d.toString();
|
|
1406
|
-
stderrBuffer += chunk;
|
|
1407
|
-
console.error("STDERR:", redactSensitive(chunk));
|
|
1408
|
-
});
|
|
1409
|
-
|
|
1410
|
-
proc.on("close", async (code) => {
|
|
1411
|
-
runningProcess = null; runningProcessPrompt = null;
|
|
1412
|
-
clearTimeout(streamInterval); streamInterval = null;
|
|
1413
|
-
if (watchdogTripped) {
|
|
1414
|
-
// The inactivity watchdog already notified the user and killed the
|
|
1415
|
-
// child; don't also deliver partial / "(no output)" text.
|
|
1416
|
-
statusMessageId = null;
|
|
1417
|
-
if (settings.budget) settings.budget = null;
|
|
1418
|
-
return;
|
|
1419
|
-
}
|
|
1420
|
-
if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
|
|
1421
|
-
await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
|
|
1425
|
-
await send("Cursor authentication error. Run `agent login` on this machine, then retry.", { replyTo: replyToMsgId });
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
try {
|
|
1429
|
-
if (code !== 0 && code !== null && !assistantText.trim()) {
|
|
1430
|
-
await send(claudeEmptyFailureMessage(code, stderrBuffer), { replyTo: replyToMsgId });
|
|
1431
|
-
return;
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
const finalText = redactSensitive(assistantText || "(no output)");
|
|
1435
|
-
const chunks = splitMessage(finalText);
|
|
1436
|
-
const firstChunk = chunks[0];
|
|
1437
|
-
|
|
1438
|
-
if (statusMessageId && chunks.length === 1) {
|
|
1439
|
-
await editMessage(statusMessageId, firstChunk);
|
|
1440
|
-
} else {
|
|
1441
|
-
const sent = await send(firstChunk, { replyTo: replyToMsgId });
|
|
1442
|
-
if (!sent) await send(firstChunk);
|
|
1443
|
-
for (let i = 1; i < chunks.length; i++) {
|
|
1444
|
-
await send(chunks[i]);
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
|
|
1448
|
-
|
|
1449
|
-
// Spoken auto-replies on voice input are off by default on chat
|
|
1450
|
-
// transports (they were unwanted noise on Telegram). Opt in with
|
|
1451
|
-
// VOICE_REPLY_ON_VOICE=1. The hands-free voice channel speaks back
|
|
1452
|
-
// regardless via its own path.
|
|
1453
|
-
if (lastInputWasVoice) {
|
|
1454
|
-
lastInputWasVoice = false;
|
|
1455
|
-
if (process.env.VOICE_REPLY_ON_VOICE === "1") {
|
|
1456
|
-
const voicePath = await textToVoice(finalText);
|
|
1457
|
-
if (voicePath) await sendVoice(voicePath);
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
} catch (e) {
|
|
1461
|
-
console.error("Final message delivery failed:", e.message);
|
|
1462
|
-
await send("Task completed but failed to deliver the response. Send /continue to see the result.");
|
|
1463
|
-
}
|
|
1464
|
-
if (settings.budget) settings.budget = null;
|
|
1465
|
-
statusMessageId = null;
|
|
1466
|
-
|
|
1467
|
-
// Record session with auto-title from first message
|
|
1468
|
-
if (lastSessionId && currentSession) {
|
|
1469
|
-
const title = isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
|
|
1470
|
-
recordSession(currentSession.name, lastSessionId, title);
|
|
1471
|
-
isFirstMessage = false;
|
|
1472
|
-
}
|
|
1473
|
-
if (messageQueue.length > 0 && currentSession) {
|
|
1474
|
-
const next = messageQueue.shift();
|
|
1475
|
-
await runClaude(next.prompt, currentSession.dir, next.replyToMsgId, next.opts);
|
|
1476
|
-
}
|
|
1477
|
-
});
|
|
1478
|
-
|
|
1479
|
-
proc.on("error", async (err) => {
|
|
1480
|
-
runningProcess = null; runningProcessPrompt = null; clearTimeout(streamInterval);
|
|
1481
|
-
await send(`Error: ${err.message}`); statusMessageId = null;
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
async function runClaudeSilent(prompt, cwd, label) {
|
|
1486
|
-
return new Promise((resolve) => {
|
|
1487
|
-
const args = ["-p", "--output-format", "text", "--verbose",
|
|
1488
|
-
"--append-system-prompt", buildSystemPrompt(),
|
|
1489
|
-
"--dangerously-skip-permissions", prompt];
|
|
1490
|
-
const proc = spawn(CLAUDE_PATH, args, {
|
|
1491
|
-
cwd, env: claudeSubprocessEnv(),
|
|
1492
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1493
|
-
});
|
|
1494
|
-
let output = "";
|
|
1495
|
-
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
1496
|
-
proc.on("close", async () => {
|
|
1497
|
-
const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
|
|
1498
|
-
for (const c of chunks) await send(c);
|
|
1499
|
-
resolve();
|
|
1500
|
-
});
|
|
1501
|
-
proc.on("error", async (err) => { await send(`Cron "${label}" failed: ${err.message}`); resolve(); });
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
function formatProgress(text, tools, currentTool, elapsed, toolDetail) {
|
|
1506
|
-
const mins = Math.floor(elapsed / 60);
|
|
1507
|
-
const secs = elapsed % 60;
|
|
1508
|
-
const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
1509
|
-
|
|
1510
|
-
const parts = [];
|
|
1511
|
-
let status = currentTool ? `Working: ${currentTool}` : (tools.length > 0 ? "Processing..." : "Thinking...");
|
|
1512
|
-
if (currentTool && toolDetail) status += ` — ${toolDetail}`;
|
|
1513
|
-
parts.push(`${status} (${time})`);
|
|
1514
|
-
if (tools.length > 1) parts.push(`Steps: ${[...new Set(tools)].join(" > ")}`);
|
|
1515
|
-
if (text) parts.push(text.length > 800 ? "..." + text.slice(-800) : text);
|
|
1516
|
-
return parts.join("\n\n");
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// ── Cron System ─────────────────────────────────────────────────────
|
|
1520
|
-
|
|
1521
|
-
function scheduleCron(c) {
|
|
1522
|
-
const cwd = path.join(WORKSPACE, c.project);
|
|
1523
|
-
if (activeCrons.has(c.id)) activeCrons.get(c.id).task.stop();
|
|
1524
|
-
const task = cron.schedule(c.schedule, () => runClaudeSilent(c.prompt, cwd, c.label));
|
|
1525
|
-
activeCrons.set(c.id, { task, config: c });
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
function initCrons() {
|
|
1529
|
-
for (const c of loadCrons()) { try { scheduleCron(c); } catch (e) { console.error("Cron error:", e.message); } }
|
|
1530
|
-
console.log(`Loaded ${loadCrons().length} cron(s)`);
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// ── Session ─────────────────────────────────────────────────────────
|
|
1534
|
-
|
|
1535
|
-
function startSession(name, resumeSessionId) {
|
|
1536
|
-
let projectName, projectDir;
|
|
1537
|
-
if (name === "__workspace__") {
|
|
1538
|
-
projectName = "Workspace";
|
|
1539
|
-
projectDir = WORKSPACE;
|
|
1540
|
-
} else {
|
|
1541
|
-
const result = findProject(name);
|
|
1542
|
-
if (!result) return send(`No match for "${name}".`, { keyboard: projectKeyboard() });
|
|
1543
|
-
if (Array.isArray(result)) return send("Multiple matches:", { keyboard: { inline_keyboard: result.map((p) => [{ text: p, callback_data: `s:${p}` }]) } });
|
|
1544
|
-
projectName = result;
|
|
1545
|
-
projectDir = path.join(WORKSPACE, result);
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
currentSession = { name: projectName, dir: projectDir };
|
|
1549
|
-
messageQueue = []; resetSettings();
|
|
1550
|
-
|
|
1551
|
-
// Resume a specific session or the last one for this project
|
|
1552
|
-
if (resumeSessionId) {
|
|
1553
|
-
lastSessionId = resumeSessionId;
|
|
1554
|
-
const sessions = getProjectSessions(projectName);
|
|
1555
|
-
const s = sessions.find((x) => x.id === resumeSessionId);
|
|
1556
|
-
const title = s ? s.title : "";
|
|
1557
|
-
isFirstMessage = false;
|
|
1558
|
-
saveState();
|
|
1559
|
-
send(`Session: ${projectName}\nResumed: ${title || resumeSessionId.slice(0, 8)}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
1560
|
-
} else {
|
|
1561
|
-
const last = getLastProjectSession(projectName);
|
|
1562
|
-
if (last) {
|
|
1563
|
-
lastSessionId = last.id;
|
|
1564
|
-
isFirstMessage = false;
|
|
1565
|
-
saveState();
|
|
1566
|
-
send(`Session: ${projectName}\nResumed: ${last.title}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`, {
|
|
1567
|
-
keyboard: { inline_keyboard: [[{ text: "New conversation", callback_data: `new:${projectName}` }]] },
|
|
1568
|
-
});
|
|
1569
|
-
} else {
|
|
1570
|
-
lastSessionId = null;
|
|
1571
|
-
isFirstMessage = true;
|
|
1572
|
-
saveState();
|
|
1573
|
-
send(`Session: ${projectName}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
function requireSession(msg) {
|
|
1579
|
-
if (!currentSession) { send("Pick a project first:", { keyboard: projectKeyboard() }); return false; }
|
|
1580
|
-
return true;
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// ── Commands ────────────────────────────────────────────────────────
|
|
1584
|
-
|
|
1585
|
-
bot.onText(/\/start/, (msg) => {
|
|
1586
|
-
if (!isAuthorized(msg)) return;
|
|
1587
|
-
if (!isOnboarded()) return startOnboarding();
|
|
1588
|
-
send("Pick a project to start:", { keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] } });
|
|
1589
|
-
});
|
|
1590
|
-
|
|
1591
|
-
bot.onText(/\/help/, (msg) => {
|
|
1592
|
-
if (!isAuthorized(msg)) return;
|
|
1593
|
-
send([
|
|
1594
|
-
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
1595
|
-
"Settings: /model /effort /budget /plan /compact /worktree /mode",
|
|
1596
|
-
"Automation: /cron /vault /soul",
|
|
1597
|
-
"Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
|
|
1598
|
-
"System: /restart /upgrade",
|
|
1599
|
-
"",
|
|
1600
|
-
"Send text, voice, photos, or files.",
|
|
1601
|
-
"Reply to any message for context.",
|
|
1602
|
-
].join("\n"));
|
|
1603
|
-
});
|
|
1604
|
-
|
|
1605
|
-
bot.onText(/\/version$/, (msg) => {
|
|
1606
|
-
if (!isAuthorized(msg)) return;
|
|
1607
|
-
send(`Open Claudia v${CURRENT_VERSION}`);
|
|
1608
|
-
});
|
|
1609
|
-
|
|
1610
|
-
bot.onText(/\/restart$/, async (msg) => {
|
|
1611
|
-
if (!isOwner(msg)) return;
|
|
1612
|
-
await send("Going offline for a quick restart — back in a moment.");
|
|
1613
|
-
setTimeout(() => process.exit(0), 1000);
|
|
1614
|
-
});
|
|
1615
|
-
|
|
1616
|
-
bot.onText(/\/upgrade$/, async (msg) => {
|
|
1617
|
-
if (!isOwner(msg)) return;
|
|
1618
|
-
try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) { /* already gone */ }
|
|
1619
|
-
// Check if there's actually a newer version
|
|
1620
|
-
let latest = null;
|
|
1621
|
-
try {
|
|
1622
|
-
latest = execSync("npm view @inetafrica/open-claudia version", {
|
|
1623
|
-
encoding: "utf-8", timeout: 15000,
|
|
1624
|
-
env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
|
|
1625
|
-
}).trim();
|
|
1626
|
-
if (!isNewerVersion(latest, CURRENT_VERSION)) {
|
|
1627
|
-
await send(`Already on the latest version (v${CURRENT_VERSION}).`);
|
|
1628
|
-
return;
|
|
1629
|
-
}
|
|
1630
|
-
await send(`Upgrading v${CURRENT_VERSION} → v${latest}...`);
|
|
1631
|
-
} catch (e) {
|
|
1632
|
-
await send("Upgrading...");
|
|
1633
|
-
}
|
|
1634
|
-
try {
|
|
1635
|
-
execSync(`npm install -g @inetafrica/open-claudia@${latest} 2>&1`, {
|
|
1636
|
-
encoding: "utf-8", timeout: 120000,
|
|
1637
|
-
cwd: process.env.HOME || require("os").homedir(),
|
|
1638
|
-
env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
|
|
1639
|
-
});
|
|
1640
|
-
// Read version and changelog from newly installed package
|
|
1641
|
-
const root = execSync("npm root -g", { encoding: "utf-8", cwd: process.env.HOME || require("os").homedir(), env: { ...process.env, PATH: FULL_PATH } }).trim();
|
|
1642
|
-
const newPkg = JSON.parse(fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "package.json"), "utf-8"));
|
|
1643
|
-
let whatsNew = "";
|
|
1644
|
-
try {
|
|
1645
|
-
const changelog = fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "CHANGELOG.md"), "utf-8");
|
|
1646
|
-
let versionHeader = `## v${newPkg.version}`;
|
|
1647
|
-
let start = changelog.indexOf(versionHeader);
|
|
1648
|
-
if (start < 0) { versionHeader = `## ${newPkg.version}`; start = changelog.indexOf(versionHeader); }
|
|
1649
|
-
if (start >= 0) {
|
|
1650
|
-
const afterHeader = changelog.slice(start + versionHeader.length);
|
|
1651
|
-
const nextVersion = afterHeader.indexOf("\n## ");
|
|
1652
|
-
const section = nextVersion >= 0 ? afterHeader.slice(0, nextVersion) : afterHeader;
|
|
1653
|
-
whatsNew = section.trim();
|
|
1654
|
-
}
|
|
1655
|
-
} catch (e) { /* no changelog */ }
|
|
1656
|
-
const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nGoing offline to restart...`;
|
|
1657
|
-
await send(msg);
|
|
1658
|
-
} catch (e) {
|
|
1659
|
-
const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
|
|
1660
|
-
await send(`Upgrade failed:\n${errOutput}`);
|
|
1661
|
-
return;
|
|
1662
|
-
}
|
|
1663
|
-
// Give Telegram time to deliver the message, then exit for launchd to restart
|
|
1664
|
-
setTimeout(() => process.exit(0), 2000);
|
|
1665
|
-
});
|
|
1666
|
-
|
|
1667
|
-
bot.onText(/\/projects$/, (msg) => { if (isAuthorized(msg)) send("Pick:", { keyboard: projectKeyboard() }); });
|
|
1668
|
-
bot.onText(/\/session$/, (msg) => {
|
|
1669
|
-
if (!isAuthorized(msg)) return;
|
|
1670
|
-
if (currentSession) send(`Active: ${currentSession.name}\n\nSwitch?`, { keyboard: projectKeyboard() });
|
|
1671
|
-
else send("Pick:", { keyboard: projectKeyboard() });
|
|
1672
|
-
});
|
|
1673
|
-
bot.onText(/\/session (.+)/, (msg, match) => { if (isAuthorized(msg)) startSession(match[1].trim()); });
|
|
1674
|
-
|
|
1675
|
-
bot.onText(/\/sessions$/, (msg) => {
|
|
1676
|
-
if (!isAuthorized(msg)) return;
|
|
1677
|
-
if (!requireSession(msg)) return;
|
|
1678
|
-
const sessions = getProjectSessions(currentSession.name);
|
|
1679
|
-
if (sessions.length === 0) return send("No past conversations for this project.");
|
|
1680
|
-
const rows = sessions.slice(0, 10).map((s) => {
|
|
1681
|
-
const date = new Date(s.lastUsed).toLocaleDateString();
|
|
1682
|
-
const active = lastSessionId === s.id ? " (active)" : "";
|
|
1683
|
-
return [{ text: `${s.title}${active} — ${date}`, callback_data: `ss:${s.id}` }];
|
|
1684
|
-
});
|
|
1685
|
-
rows.push([{ text: "New conversation", callback_data: `new:${currentSession.name}` }]);
|
|
1686
|
-
send(`Conversations in ${currentSession.name}:`, { keyboard: { inline_keyboard: rows } });
|
|
1687
|
-
});
|
|
1688
|
-
|
|
1689
|
-
bot.onText(/\/model$/, (msg) => {
|
|
1690
|
-
if (!isAuthorized(msg)) return;
|
|
1691
|
-
const keyboard = settings.backend === "cursor" ? [
|
|
1692
|
-
[{ text: "Composer 2", callback_data: "m:composer-2" }, { text: "Composer 2 Fast", callback_data: "m:composer-2-fast" }],
|
|
1693
|
-
[{ text: "Opus 4.6 Thinking", callback_data: "m:claude-4.6-opus-high-thinking" }, { text: "Sonnet 4.6", callback_data: "m:claude-4.6-sonnet-medium" }],
|
|
1694
|
-
[{ text: "GPT-5.4", callback_data: "m:gpt-5.4-medium" }, { text: "GPT-5.4 High", callback_data: "m:gpt-5.4-high" }],
|
|
1695
|
-
[{ text: "Auto", callback_data: "m:auto" }, { text: "Default", callback_data: "m:default" }],
|
|
1696
|
-
] : [
|
|
1697
|
-
[{ text: "Fable 5", callback_data: "m:claude-fable-5" }, { text: "Opus 4.8", callback_data: "m:claude-opus-4-8" }],
|
|
1698
|
-
[{ text: "Opus 4.7", callback_data: "m:claude-opus-4-7" }, { text: "Sonnet", callback_data: "m:sonnet" }],
|
|
1699
|
-
[{ text: "Haiku", callback_data: "m:haiku" }, { text: "Default", callback_data: "m:default" }],
|
|
1700
|
-
];
|
|
1701
|
-
const label = settings.backend === "cursor" ? "Cursor Agent" : "Claude Code";
|
|
1702
|
-
send(`${label} model: ${settings.model || "default"}\n\nOr type /model <name> for any model.`, { keyboard: { inline_keyboard: keyboard } });
|
|
1703
|
-
});
|
|
1704
|
-
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"}`); });
|
|
1705
|
-
|
|
1706
|
-
bot.onText(/\/effort$/, (msg) => {
|
|
1707
|
-
if (!isAuthorized(msg)) return;
|
|
1708
|
-
if (settings.backend === "cursor") return send("Effort levels are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
|
|
1709
|
-
send(`Effort: ${settings.effort || "default"}`, { keyboard: { inline_keyboard: [
|
|
1710
|
-
[{ text: "Low", callback_data: "e:low" }, { text: "Med", callback_data: "e:medium" }, { text: "High", callback_data: "e:high" }, { text: "Max", callback_data: "e:max" }],
|
|
1711
|
-
[{ text: "Default", callback_data: "e:default" }],
|
|
1712
|
-
] } });
|
|
1713
|
-
});
|
|
1714
|
-
bot.onText(/\/effort (.+)/, (msg, match) => {
|
|
1715
|
-
if (!isAuthorized(msg)) return;
|
|
1716
|
-
if (settings.backend === "cursor") return send("Effort levels are not supported on Cursor Agent.");
|
|
1717
|
-
const e = match[1].trim().toLowerCase(); settings.effort = ["low","medium","high","max"].includes(e) ? e : null; send(`Effort: ${settings.effort || "default"}`);
|
|
1718
|
-
});
|
|
1719
|
-
|
|
1720
|
-
bot.onText(/\/budget$/, (msg) => {
|
|
1721
|
-
if (!isAuthorized(msg)) return;
|
|
1722
|
-
if (settings.backend === "cursor") return send("Budget limits are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
|
|
1723
|
-
send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`, { keyboard: { inline_keyboard: [
|
|
1724
|
-
[{ text: "$1", callback_data: "b:1" }, { text: "$5", callback_data: "b:5" }, { text: "$10", callback_data: "b:10" }, { text: "$25", callback_data: "b:25" }],
|
|
1725
|
-
[{ text: "No limit", callback_data: "b:none" }],
|
|
1726
|
-
] } });
|
|
1727
|
-
});
|
|
1728
|
-
bot.onText(/\/budget (.+)/, (msg, match) => {
|
|
1729
|
-
if (!isAuthorized(msg)) return;
|
|
1730
|
-
if (settings.backend === "cursor") return send("Budget limits are not supported on Cursor Agent.");
|
|
1731
|
-
const v = parseFloat(match[1].replace("$","")); settings.budget = v > 0 ? v : null; send(`Budget: ${settings.budget ? "$"+settings.budget : "none"}`);
|
|
1732
|
-
});
|
|
1733
|
-
|
|
1734
|
-
bot.onText(/\/plan$/, (msg) => {
|
|
1735
|
-
if (!isAuthorized(msg)) return;
|
|
1736
|
-
const p = settings.permissionMode === "plan";
|
|
1737
|
-
settings.permissionMode = p ? null : "plan";
|
|
1738
|
-
const label = settings.backend === "cursor" ? "read-only planning, no edits" : "plan permission mode";
|
|
1739
|
-
send(p ? "Plan mode off." : `Plan mode on (${label}).`);
|
|
1740
|
-
});
|
|
1741
|
-
bot.onText(/\/ask$/, (msg) => {
|
|
1742
|
-
if (!isAuthorized(msg)) return;
|
|
1743
|
-
if (settings.backend !== "cursor") return send("Ask mode is only available on Cursor Agent.\nUse /cursor to switch.");
|
|
1744
|
-
const a = settings.permissionMode === "ask";
|
|
1745
|
-
settings.permissionMode = a ? null : "ask";
|
|
1746
|
-
send(a ? "Ask mode off." : "Ask mode on (read-only Q&A, no edits).");
|
|
1747
|
-
});
|
|
1748
|
-
bot.onText(/\/compact/, async (msg) => { if (!isAuthorized(msg)) return; if (!requireSession(msg)) return; if (!getActiveSessionId()) return send("No conversation."); await runClaude("Summarize: key decisions, code state, next steps.", currentSession.dir, msg.message_id); });
|
|
1749
|
-
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 }); });
|
|
1750
|
-
bot.onText(/\/worktree$/, (msg) => { if (!isAuthorized(msg)) return; settings.worktree = !settings.worktree; send(settings.worktree ? "Worktree on." : "Worktree off."); });
|
|
1751
|
-
|
|
1752
|
-
bot.onText(/\/cursor$/, async (msg) => {
|
|
1753
|
-
if (!isAuthorized(msg)) return;
|
|
1754
|
-
if (!resolvedCursorPath) return send("Cursor Agent CLI not found.\nSet CURSOR_PATH in .env or install: https://docs.cursor.com/agent");
|
|
1755
|
-
settings.backend = "cursor";
|
|
1756
|
-
settings.model = null;
|
|
1757
|
-
saveState();
|
|
1758
|
-
const sid = cursorSessionId ? `\nSession: ${cursorSessionId.slice(0, 8)}...` : "\nNew session.";
|
|
1759
|
-
send(`Switched to Cursor Agent.${sid}\n\n/claude — switch back`);
|
|
1760
|
-
});
|
|
1761
|
-
|
|
1762
|
-
bot.onText(/\/claude$/, async (msg) => {
|
|
1763
|
-
if (!isAuthorized(msg)) return;
|
|
1764
|
-
settings.backend = "claude";
|
|
1765
|
-
settings.model = null;
|
|
1766
|
-
saveState();
|
|
1767
|
-
const sid = lastSessionId ? `\nSession: ${lastSessionId.slice(0, 8)}...` : "\nNew session.";
|
|
1768
|
-
send(`Switched to Claude Code.${sid}\n\n/cursor — switch to Cursor`);
|
|
1769
|
-
});
|
|
1770
|
-
|
|
1771
|
-
bot.onText(/\/backend$/, async (msg) => {
|
|
1772
|
-
if (!isAuthorized(msg)) return;
|
|
1773
|
-
const label = settings.backend === "cursor" ? "Cursor Agent" : "Claude Code";
|
|
1774
|
-
const cursorAvail = resolvedCursorPath ? "available" : "not found";
|
|
1775
|
-
send(`Backend: ${label}\n\nClaude Code: available\nCursor Agent: ${cursorAvail}`, { keyboard: { inline_keyboard: [
|
|
1776
|
-
[{ text: "Claude Code", callback_data: "be:claude" }, { text: "Cursor Agent", callback_data: "be:cursor" }],
|
|
1777
|
-
] } });
|
|
1778
|
-
});
|
|
1779
|
-
|
|
1780
|
-
bot.onText(/\/mode$/, async (msg) => {
|
|
1781
|
-
if (!isAuthorized(msg)) return;
|
|
1782
|
-
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.", {
|
|
1783
|
-
parseMode: "Markdown",
|
|
1784
|
-
keyboard: { inline_keyboard: [
|
|
1785
|
-
[{ text: "Switch to Direct Mode", callback_data: "mode:direct" }],
|
|
1786
|
-
] },
|
|
1787
|
-
});
|
|
1788
|
-
});
|
|
1789
|
-
|
|
1790
|
-
bot.onText(/\/stop/, async (msg) => {
|
|
1791
|
-
if (!isAuthorized(msg)) return;
|
|
1792
|
-
let stopped = false;
|
|
1793
|
-
if (runningProcess) {
|
|
1794
|
-
const pid = runningProcess.pid;
|
|
1795
|
-
try { process.kill(-pid, "SIGTERM"); } catch (e) {
|
|
1796
|
-
try { runningProcess.kill("SIGTERM"); } catch (e2) {}
|
|
1797
|
-
}
|
|
1798
|
-
setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch (e) {} }, 3000);
|
|
1799
|
-
runningProcess = null; runningProcessPrompt = null;
|
|
1800
|
-
if (streamInterval) clearTimeout(streamInterval);
|
|
1801
|
-
stopped = true;
|
|
1802
|
-
}
|
|
1803
|
-
// Also kill any chat processes
|
|
1804
|
-
for (const [id, proc] of chatProcesses) {
|
|
1805
|
-
try { proc.kill("SIGTERM"); } catch (e) {}
|
|
1806
|
-
}
|
|
1807
|
-
if (chatProcesses.size > 0) { chatProcesses.clear(); stopped = true; }
|
|
1808
|
-
messageQueue = [];
|
|
1809
|
-
await send(stopped ? "Cancelled." : "Nothing running.");
|
|
1810
|
-
});
|
|
1811
|
-
|
|
1812
|
-
bot.onText(/\/status/, (msg) => {
|
|
1813
|
-
if (!isAuthorized(msg)) return;
|
|
1814
|
-
if (!currentSession) return send("No session.", { keyboard: { inline_keyboard: [[{ text: "Pick", callback_data: "show:projects" }]] } });
|
|
1815
|
-
const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : "Claude Code";
|
|
1816
|
-
const lines = [
|
|
1817
|
-
`Project: ${currentSession.name} (agent mode)`,
|
|
1818
|
-
`Backend: ${backendLabel}`,
|
|
1819
|
-
`Model: ${settings.model || "default"} | Effort: ${settings.effort || "default"}`,
|
|
1820
|
-
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${activeCrons.size}`,
|
|
1821
|
-
];
|
|
1822
|
-
if (runningProcess) {
|
|
1823
|
-
lines.push(`Background task: ${runningProcessPrompt || "working..."}`);
|
|
1824
|
-
if (chatProcesses.size > 0) lines.push(`Chat processes: ${chatProcesses.size}`);
|
|
1825
|
-
lines.push("Send messages — I'll respond via a side channel while the task runs.");
|
|
1826
|
-
} else {
|
|
1827
|
-
lines.push("Ready.");
|
|
1828
|
-
}
|
|
1829
|
-
send(lines.join("\n"));
|
|
1830
|
-
});
|
|
1831
|
-
|
|
1832
|
-
bot.onText(/\/end/, (msg) => {
|
|
1833
|
-
if (!isAuthorized(msg)) return;
|
|
1834
|
-
if (currentSession) {
|
|
1835
|
-
const n = currentSession.name; currentSession = null; lastSessionId = null; messageQueue = []; resetSettings();
|
|
1836
|
-
saveState();
|
|
1837
|
-
send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
|
|
1838
|
-
} else send("No session.");
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
bot.onText(/\/soul$/, async (msg) => {
|
|
1842
|
-
if (!isAuthorized(msg)) return;
|
|
1843
|
-
const soul = loadSoul();
|
|
1844
|
-
const preview = soul.length > 3000 ? soul.slice(0, 3000) + "\n..." : soul;
|
|
1845
|
-
await send(preview);
|
|
1846
|
-
await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
|
|
1847
|
-
});
|
|
1848
|
-
|
|
1849
|
-
// ── Claude Auth Commands ────────────────────────────────────────────
|
|
1850
|
-
|
|
1851
|
-
bot.onText(/\/(?:auth_status|auth status)$/, async (msg) => {
|
|
1852
|
-
if (!isAuthorized(msg)) return;
|
|
1853
|
-
const tokenInfo = getClaudeOAuthToken();
|
|
1854
|
-
const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
|
|
1855
|
-
cwd: process.env.HOME || require("os").homedir(),
|
|
1856
|
-
env: claudeSubprocessEnv(),
|
|
1857
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1858
|
-
});
|
|
1859
|
-
let output = "";
|
|
1860
|
-
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
1861
|
-
proc.stderr.on("data", (d) => { output += d.toString(); });
|
|
1862
|
-
proc.on("close", async (code) => {
|
|
1863
|
-
const clean = redactSensitive(output.trim()) || "(no output)";
|
|
1864
|
-
await send([
|
|
1865
|
-
`Claude auth status: exit ${code}`,
|
|
1866
|
-
...summarizeClaudeAuthStatus(output, code, tokenInfo),
|
|
1867
|
-
`Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
|
|
1868
|
-
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
|
|
1869
|
-
"",
|
|
1870
|
-
clean.slice(-2500),
|
|
1871
|
-
].join("\n"));
|
|
1872
|
-
});
|
|
1873
|
-
proc.on("error", async (err) => send(`Claude auth status failed: ${redactSensitive(err.message)}`));
|
|
1874
|
-
});
|
|
1875
|
-
|
|
1876
|
-
bot.onText(/\/cancel_auth$/, async (msg) => {
|
|
1877
|
-
if (!isAuthorized(msg)) return;
|
|
1878
|
-
if (!pendingClaudeAuthProcess) return send("No Claude auth flow is pending.");
|
|
1879
|
-
clearPendingClaudeAuth();
|
|
1880
|
-
await send("Claude auth flow cancelled. Normal messages will go to the assistant again.");
|
|
1881
|
-
});
|
|
1882
|
-
|
|
1883
|
-
bot.onText(/\/auth_code(?:\s+(.+))?$/, async (msg, match) => {
|
|
1884
|
-
if (!isAuthorized(msg)) return;
|
|
1885
|
-
const code = (match[1] || "").trim();
|
|
1886
|
-
await deleteMessage(msg.message_id);
|
|
1887
|
-
if (!pendingClaudeAuthProcess || pendingClaudeAuthLabel === "manual OAuth token save") {
|
|
1888
|
-
return send("No Claude login flow is waiting for an auth code. Start with /login, or use /use_oauth_token for tokens.");
|
|
1889
|
-
}
|
|
1890
|
-
if (!code || !looksLikeClaudeAuthReply(code)) {
|
|
1891
|
-
return send("That does not look like a Claude auth code/callback. Use /cancel_auth to cancel the login flow.");
|
|
1892
|
-
}
|
|
1893
|
-
try {
|
|
1894
|
-
pendingClaudeAuthProcess.stdin.write(code + "\n");
|
|
1895
|
-
await send("Auth code sent to Claude. I’ll confirm with auth status when Claude finishes.");
|
|
1896
|
-
} catch (e) {
|
|
1897
|
-
clearPendingClaudeAuth();
|
|
1898
|
-
await send(`Could not send auth code to Claude: ${redactSensitive(e.message)}`);
|
|
1899
|
-
}
|
|
1900
|
-
});
|
|
1901
|
-
|
|
1902
|
-
bot.onText(/\/login$/, async (msg) => {
|
|
1903
|
-
if (!isAuthorized(msg)) return;
|
|
1904
|
-
await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
|
|
1905
|
-
});
|
|
1906
|
-
|
|
1907
|
-
bot.onText(/\/setup_token$/, async (msg) => {
|
|
1908
|
-
if (!isAuthorized(msg)) return;
|
|
1909
|
-
await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
|
|
1910
|
-
});
|
|
1911
|
-
|
|
1912
|
-
bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, async (msg, match) => {
|
|
1913
|
-
if (!isAuthorized(msg)) return;
|
|
1914
|
-
const token = (match[1] || "").trim();
|
|
1915
|
-
await deleteMessage(msg.message_id);
|
|
1916
|
-
if (!token) {
|
|
1917
|
-
pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
|
|
1918
|
-
pendingClaudeAuthLabel = "manual OAuth token save";
|
|
1919
|
-
await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
|
|
1920
|
-
return;
|
|
1921
|
-
}
|
|
1922
|
-
if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
|
|
1923
|
-
saveClaudeOAuthToken(token);
|
|
1924
|
-
await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
|
|
1925
|
-
await sendClaudeAuthStatusSummary("Stored token. Current Claude auth status:");
|
|
1926
|
-
});
|
|
1927
|
-
|
|
1928
|
-
bot.onText(/\/clear_oauth_token$/, async (msg) => {
|
|
1929
|
-
if (!isAuthorized(msg)) return;
|
|
1930
|
-
clearClaudeOAuthToken();
|
|
1931
|
-
await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
|
|
1932
|
-
});
|
|
1933
|
-
|
|
1934
|
-
// ── /vault with password protection ─────────────────────────────────
|
|
1935
|
-
|
|
1936
|
-
bot.onText(/\/vault$/, async (msg) => {
|
|
1937
|
-
if (!isAuthorized(msg)) return;
|
|
1938
|
-
if (!vault.exists()) {
|
|
1939
|
-
await send("No vault found. Run setup first: node setup.js");
|
|
1940
|
-
return;
|
|
1941
|
-
}
|
|
1942
|
-
if (vault.isUnlocked()) {
|
|
1943
|
-
const entries = vault.list();
|
|
1944
|
-
const keys = Object.keys(entries);
|
|
1945
|
-
if (keys.length === 0) await send("Vault is unlocked but empty.\n\nUse /vault set <name> <value>");
|
|
1946
|
-
else await send("Vault (unlocked):\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nLocks automatically in 5 min.");
|
|
1947
|
-
} else {
|
|
1948
|
-
pendingVaultUnlock = true;
|
|
1949
|
-
pendingVaultAction = { type: "list" };
|
|
1950
|
-
await send("Vault is locked. Send your vault password.\n(Message will be deleted after reading)");
|
|
1951
|
-
}
|
|
1952
|
-
});
|
|
1953
|
-
|
|
1954
|
-
bot.onText(/\/vault set (\S+) (.+)/, async (msg, match) => {
|
|
1955
|
-
if (!isAuthorized(msg)) return;
|
|
1956
|
-
// Delete the message containing the value immediately
|
|
1957
|
-
await deleteMessage(msg.message_id);
|
|
1958
|
-
if (vault.isUnlocked()) {
|
|
1959
|
-
vault.set(match[1], match[2].trim());
|
|
1960
|
-
await send(`Saved: ${match[1]}`);
|
|
1961
|
-
} else {
|
|
1962
|
-
pendingVaultUnlock = true;
|
|
1963
|
-
pendingVaultAction = { type: "set", key: match[1], value: match[2].trim() };
|
|
1964
|
-
await send("Vault locked. Send password to unlock.");
|
|
1965
|
-
}
|
|
1966
|
-
});
|
|
1967
|
-
|
|
1968
|
-
bot.onText(/\/vault remove (\S+)/, async (msg, match) => {
|
|
1969
|
-
if (!isAuthorized(msg)) return;
|
|
1970
|
-
if (vault.isUnlocked()) {
|
|
1971
|
-
vault.remove(match[1]);
|
|
1972
|
-
await send(`Removed: ${match[1]}`);
|
|
1973
|
-
} else {
|
|
1974
|
-
pendingVaultUnlock = true;
|
|
1975
|
-
pendingVaultAction = { type: "remove", key: match[1] };
|
|
1976
|
-
await send("Vault locked. Send password to unlock.");
|
|
1977
|
-
}
|
|
1978
|
-
});
|
|
1979
|
-
|
|
1980
|
-
bot.onText(/\/vault lock/, async (msg) => {
|
|
1981
|
-
if (!isAuthorized(msg)) return;
|
|
1982
|
-
vault.lock();
|
|
1983
|
-
await send("Vault locked.");
|
|
1984
|
-
});
|
|
1985
|
-
|
|
1986
|
-
// ── /cron ───────────────────────────────────────────────────────────
|
|
1987
|
-
|
|
1988
|
-
bot.onText(/\/cron$/, (msg) => {
|
|
1989
|
-
if (!isAuthorized(msg)) return;
|
|
1990
|
-
const list = loadCrons();
|
|
1991
|
-
if (list.length === 0) {
|
|
1992
|
-
send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
|
|
1993
|
-
keyboard: { inline_keyboard: [
|
|
1994
|
-
[{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
|
|
1995
|
-
[{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
|
|
1996
|
-
] },
|
|
1997
|
-
});
|
|
1998
|
-
} else {
|
|
1999
|
-
send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project}`).join("\n") + "\n\nRemove: /cron remove <#>");
|
|
2000
|
-
}
|
|
2001
|
-
});
|
|
2002
|
-
|
|
2003
|
-
bot.onText(/\/cron add "(.+)" (\S+) "(.+)"/, (msg, match) => {
|
|
2004
|
-
if (!isAuthorized(msg)) return;
|
|
2005
|
-
if (!cron.validate(match[1])) return send("Invalid cron schedule.");
|
|
2006
|
-
const proj = findProject(match[2]);
|
|
2007
|
-
if (!proj || Array.isArray(proj)) return send("Project not found.");
|
|
2008
|
-
const c = { id: `cron_${Date.now()}`, schedule: match[1], project: proj, prompt: match[3], label: match[3].slice(0, 50) };
|
|
2009
|
-
const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
|
|
2010
|
-
send(`Added: ${c.label} (${c.schedule}) for ${proj}`);
|
|
2011
|
-
});
|
|
2012
|
-
|
|
2013
|
-
bot.onText(/\/cron remove (\d+)/, (msg, match) => {
|
|
2014
|
-
if (!isAuthorized(msg)) return;
|
|
2015
|
-
const list = loadCrons(); const idx = parseInt(match[1]) - 1;
|
|
2016
|
-
if (idx < 0 || idx >= list.length) return send("Invalid number.");
|
|
2017
|
-
const removed = list.splice(idx, 1)[0]; saveCrons(list);
|
|
2018
|
-
if (activeCrons.has(removed.id)) { activeCrons.get(removed.id).task.stop(); activeCrons.delete(removed.id); }
|
|
2019
|
-
send(`Removed: ${removed.label}`);
|
|
2020
|
-
});
|
|
2021
|
-
|
|
2022
|
-
// ── Callback Queries ────────────────────────────────────────────────
|
|
2023
|
-
|
|
2024
|
-
bot.on("callback_query", async (q) => {
|
|
2025
|
-
const d = q.data;
|
|
2026
|
-
await bot.answerCallbackQuery(q.id);
|
|
2027
|
-
|
|
2028
|
-
if (d.startsWith("auth:")) {
|
|
2029
|
-
const callbackFromOwner = String(q.from?.id) === CHAT_ID || String(q.message?.chat?.id) === CHAT_ID;
|
|
2030
|
-
if (!callbackFromOwner) {
|
|
2031
|
-
await send("Owner only — auth approvals are restricted.");
|
|
2032
|
-
return;
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
const [, action, chatId] = d.split(":");
|
|
2036
|
-
if (!chatId || !["approve", "deny"].includes(action)) return;
|
|
2037
|
-
|
|
2038
|
-
const result = action === "approve"
|
|
2039
|
-
? await approveAuthRequest(chatId)
|
|
2040
|
-
: await denyAuthRequest(chatId);
|
|
2041
|
-
|
|
2042
|
-
if (result.status === "not_found") {
|
|
2043
|
-
await send(`No pending auth request found for ${chatId}.`);
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
if (result.status === "already_authorized") {
|
|
2048
|
-
await send(`Chat ${chatId} is already authorized.`);
|
|
2049
|
-
return;
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
const label = authRequestLabel(result.request);
|
|
2053
|
-
if (result.status === "approved") {
|
|
2054
|
-
await bot.sendMessage(chatId, "Your access has been approved! You can now use the bot. Send /start to begin.").catch(() => {});
|
|
2055
|
-
let edited = false;
|
|
2056
|
-
if (q.message) {
|
|
2057
|
-
edited = await bot.editMessageText(`Approved auth request from ${label} (${chatId}).`, {
|
|
2058
|
-
chat_id: q.message.chat.id,
|
|
2059
|
-
message_id: q.message.message_id,
|
|
2060
|
-
reply_markup: { inline_keyboard: [] },
|
|
2061
|
-
}).then(() => true).catch(() => false);
|
|
2062
|
-
}
|
|
2063
|
-
if (!edited) await send(`Approved ${label} (${chatId}).`);
|
|
2064
|
-
return;
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
await bot.sendMessage(chatId, "Your access request was denied.").catch(() => {});
|
|
2068
|
-
let edited = false;
|
|
2069
|
-
if (q.message) {
|
|
2070
|
-
edited = await bot.editMessageText(`Denied auth request from ${label} (${chatId}).`, {
|
|
2071
|
-
chat_id: q.message.chat.id,
|
|
2072
|
-
message_id: q.message.message_id,
|
|
2073
|
-
reply_markup: { inline_keyboard: [] },
|
|
2074
|
-
}).then(() => true).catch(() => false);
|
|
2075
|
-
}
|
|
2076
|
-
if (!edited) await send(`Denied ${label} (${chatId}).`);
|
|
2077
|
-
return;
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
// Onboarding style selection
|
|
2081
|
-
if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
|
|
2082
|
-
|
|
2083
|
-
if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
|
|
2084
|
-
if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
|
|
2085
|
-
if (d.startsWith("ss:")) { if (currentSession) startSession(currentSession.name, d.slice(3)); return; }
|
|
2086
|
-
if (d.startsWith("new:")) {
|
|
2087
|
-
const proj = d.slice(4);
|
|
2088
|
-
// Clear session history so startSession doesn't auto-resume
|
|
2089
|
-
const sessions = loadSessions();
|
|
2090
|
-
// Don't delete history, just start fresh
|
|
2091
|
-
currentSession = { name: proj === "__workspace__" ? "Workspace" : proj, dir: proj === "__workspace__" ? WORKSPACE : path.join(WORKSPACE, proj) };
|
|
2092
|
-
lastSessionId = null; isFirstMessage = true; messageQueue = []; resetSettings();
|
|
2093
|
-
saveState();
|
|
2094
|
-
await send(`Session: ${currentSession.name}\nNew conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
2095
|
-
return;
|
|
2096
|
-
}
|
|
2097
|
-
if (d === "a:continue") { if (currentSession) await runClaude("continue", currentSession.dir); else send("No session."); return; }
|
|
2098
|
-
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; }
|
|
2099
|
-
if (d.startsWith("m:")) { settings.model = d.slice(2) === "default" ? null : d.slice(2); await send(`Model: ${settings.model || "default"}`); return; }
|
|
2100
|
-
if (d.startsWith("e:")) { const e = d.slice(2); settings.effort = e === "default" ? null : e; await send(`Effort: ${settings.effort || "default"}`); return; }
|
|
2101
|
-
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; }
|
|
2102
|
-
if (d.startsWith("be:")) {
|
|
2103
|
-
const be = d.slice(3);
|
|
2104
|
-
if (be === "cursor" && !resolvedCursorPath) { await send("Cursor Agent CLI not found."); return; }
|
|
2105
|
-
settings.backend = be;
|
|
2106
|
-
settings.model = null;
|
|
2107
|
-
saveState();
|
|
2108
|
-
const label = be === "cursor" ? "Cursor Agent" : "Claude Code";
|
|
2109
|
-
await send(`Switched to ${label}.`);
|
|
2110
|
-
return;
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
// Mode switching
|
|
2114
|
-
if (d.startsWith("mode:")) {
|
|
2115
|
-
const newMode = d.slice(5);
|
|
2116
|
-
const modeFile = path.join(CONFIG_DIR, ".bot-mode");
|
|
2117
|
-
fs.writeFileSync(modeFile, newMode);
|
|
2118
|
-
await send(`Switching to ${newMode} mode... restarting.`);
|
|
2119
|
-
setTimeout(() => process.exit(0), 500);
|
|
2120
|
-
return;
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
// Cron presets
|
|
2124
|
-
if (d.startsWith("cp:") && d !== "cp:clear") {
|
|
2125
|
-
if (!currentSession) return send("Start a session first.");
|
|
2126
|
-
const presets = {
|
|
2127
|
-
standup: { schedule: "0 9 * * 1-5", prompt: "Morning standup: recent commits, failing tests, open TODOs, what to focus on today. Brief.", label: "Morning standup" },
|
|
2128
|
-
git: { schedule: "0 18 * * 1-5", prompt: "Git digest: today's commits, changed files, uncommitted changes. Flag concerns.", label: "Git digest" },
|
|
2129
|
-
deps: { schedule: "0 10 * * 1", prompt: "Check outdated/vulnerable dependencies. Brief — just what needs attention.", label: "Dep check" },
|
|
2130
|
-
health: { schedule: "*/30 * * * *", prompt: "Quick health: can the project build? Run build/lint, report pass/fail.", label: "Health check" },
|
|
2131
|
-
};
|
|
2132
|
-
const p = presets[d.slice(3)];
|
|
2133
|
-
if (!p) return;
|
|
2134
|
-
const c = { id: `cron_${Date.now()}`, ...p, project: currentSession.name };
|
|
2135
|
-
const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
|
|
2136
|
-
await send(`Added: ${c.label} for ${currentSession.name}`);
|
|
2137
|
-
return;
|
|
2138
|
-
}
|
|
2139
|
-
if (d === "cp:clear") { for (const [,v] of activeCrons) v.task.stop(); activeCrons.clear(); saveCrons([]); await send("All crons cleared."); return; }
|
|
2140
|
-
});
|
|
2141
|
-
|
|
2142
|
-
// ── Media Handlers ──────────────────────────────────────────────────
|
|
2143
|
-
|
|
2144
|
-
bot.on("voice", async (msg) => {
|
|
2145
|
-
if (isDuplicate(msg.message_id)) return;
|
|
2146
|
-
if (!isAuthorized(msg)) return;
|
|
2147
|
-
if (!requireSession(msg)) return;
|
|
2148
|
-
try {
|
|
2149
|
-
bot.sendChatAction(CHAT_ID, "typing");
|
|
2150
|
-
const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
|
|
2151
|
-
const transcript = transcribeAudio(oggPath);
|
|
2152
|
-
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
2153
|
-
if (!transcript) return send("Couldn't transcribe. Try typing it.");
|
|
2154
|
-
await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
|
|
2155
|
-
lastInputWasVoice = true;
|
|
2156
|
-
await runClaude(transcript, currentSession.dir, msg.message_id);
|
|
2157
|
-
} catch (err) { await send(`Voice failed: ${err.message}`); }
|
|
2158
|
-
});
|
|
2159
|
-
|
|
2160
|
-
bot.on("audio", async (msg) => {
|
|
2161
|
-
if (isDuplicate(msg.message_id)) return;
|
|
2162
|
-
if (!isAuthorized(msg)) return;
|
|
2163
|
-
if (!requireSession(msg)) return;
|
|
2164
|
-
try {
|
|
2165
|
-
bot.sendChatAction(CHAT_ID, "typing");
|
|
2166
|
-
const p = await downloadFile(msg.audio.file_id, path.extname(msg.audio.file_name || ".ogg"));
|
|
2167
|
-
const t = transcribeAudio(p);
|
|
2168
|
-
try { fs.unlinkSync(p); } catch (e) {}
|
|
2169
|
-
if (!t) return send("Couldn't transcribe.");
|
|
2170
|
-
await send(`Heard: "${t}"`, { replyTo: msg.message_id });
|
|
2171
|
-
await runClaude(t, currentSession.dir, msg.message_id);
|
|
2172
|
-
} catch (err) { await send(`Audio failed: ${err.message}`); }
|
|
2173
|
-
});
|
|
2174
|
-
|
|
2175
|
-
bot.on("photo", async (msg) => {
|
|
2176
|
-
if (isDuplicate(msg.message_id)) return;
|
|
2177
|
-
if (!isAuthorized(msg)) return;
|
|
2178
|
-
if (!requireSession(msg)) return;
|
|
2179
|
-
try {
|
|
2180
|
-
const p = await downloadFile(msg.photo[msg.photo.length - 1].file_id, ".jpg");
|
|
2181
|
-
const caption = msg.caption || "Describe this image. If code/UI/error — explain and fix.";
|
|
2182
|
-
await runClaude(`Image at ${p}\n\nView it, then: ${caption}`, currentSession.dir, msg.message_id);
|
|
2183
|
-
} catch (err) { await send(`Image failed: ${err.message}`); }
|
|
2184
|
-
});
|
|
2185
|
-
|
|
2186
|
-
bot.on("document", async (msg) => {
|
|
2187
|
-
if (isDuplicate(msg.message_id)) return;
|
|
2188
|
-
if (!isAuthorized(msg)) return;
|
|
2189
|
-
if (!requireSession(msg)) return;
|
|
2190
|
-
try {
|
|
2191
|
-
const fileName = msg.document.file_name || `file-${Date.now()}`;
|
|
2192
|
-
const mime = msg.document.mime_type || "";
|
|
2193
|
-
// Save with original name to files dir
|
|
2194
|
-
const savePath = path.join(FILES_DIR, fileName);
|
|
2195
|
-
const file = await bot.getFile(msg.document.file_id);
|
|
2196
|
-
const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
|
|
2197
|
-
await new Promise((resolve, reject) => {
|
|
2198
|
-
const out = fs.createWriteStream(savePath);
|
|
2199
|
-
https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
|
|
2200
|
-
});
|
|
2201
|
-
const caption = msg.caption || "";
|
|
2202
|
-
const isImage = mime.startsWith("image/");
|
|
2203
|
-
let prompt;
|
|
2204
|
-
if (isImage) {
|
|
2205
|
-
prompt = `Image file saved at ${savePath}\n\nView it, then: ${caption || "Describe this image. If code/UI/error — explain and fix."}`;
|
|
2206
|
-
} else {
|
|
2207
|
-
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."}`;
|
|
2208
|
-
}
|
|
2209
|
-
await send(`File saved: ${fileName}`, { replyTo: msg.message_id });
|
|
2210
|
-
await runClaude(prompt, currentSession.dir, msg.message_id);
|
|
2211
|
-
} catch (err) { await send(`Failed: ${err.message}`); }
|
|
2212
|
-
});
|
|
2213
|
-
|
|
2214
|
-
// ── Text Message Handler (handles onboarding, vault password, normal messages) ──
|
|
2215
|
-
|
|
2216
|
-
bot.on("message", async (msg) => {
|
|
2217
|
-
if (!isAuthorized(msg)) return;
|
|
2218
|
-
if (!msg.text || msg.text.startsWith("/")) return;
|
|
2219
|
-
if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
|
|
2220
|
-
if (isDuplicate(msg.message_id)) return;
|
|
2221
|
-
|
|
2222
|
-
// Handle pending manual OAuth token paste mode. Login codes must be sent explicitly
|
|
2223
|
-
// with /auth_code so normal chat can never be deleted/consumed accidentally.
|
|
2224
|
-
if (pendingClaudeAuthProcess && pendingClaudeAuthLabel === "manual OAuth token save") {
|
|
2225
|
-
const text = msg.text.trim();
|
|
2226
|
-
if (looksLikeClaudeToken(text)) {
|
|
2227
|
-
await deleteMessage(msg.message_id);
|
|
2228
|
-
clearPendingClaudeAuth();
|
|
2229
|
-
saveClaudeOAuthToken(text);
|
|
2230
|
-
await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
|
|
2231
|
-
await sendClaudeAuthStatusSummary("Stored token. Current Claude auth status:");
|
|
2232
|
-
return;
|
|
2233
|
-
}
|
|
2234
|
-
await send("Token paste mode is active, but that does not look like a Claude OAuth token. I left your message visible and will handle it normally. Send /cancel_auth to stop token paste mode.");
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
if (pendingClaudeAuthProcess && pendingClaudeAuthLabel !== "manual OAuth token save") {
|
|
2238
|
-
const text = msg.text.trim();
|
|
2239
|
-
if (looksLikeClaudeAuthReply(text)) {
|
|
2240
|
-
await send("That looks like a Claude login code/callback. I did not delete it or send it to Claude as a prompt. Please resend it as `/auth_code YOUR_CODE`, or use /cancel_auth to stop the login flow.");
|
|
2241
|
-
return;
|
|
2242
|
-
}
|
|
2243
|
-
await send("Claude login is still waiting. I will not delete normal messages. If Claude gave you a code, send it as `/auth_code YOUR_CODE`, or use /cancel_auth.");
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
// Handle onboarding
|
|
2247
|
-
if (!isOnboarded() && onboardingStep) {
|
|
2248
|
-
await handleOnboarding(msg);
|
|
2249
|
-
return;
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
// Handle vault password
|
|
2253
|
-
if (pendingVaultUnlock) {
|
|
2254
|
-
const password = msg.text;
|
|
2255
|
-
// Delete the password message immediately
|
|
2256
|
-
await deleteMessage(msg.message_id);
|
|
2257
|
-
|
|
2258
|
-
const ok = vault.unlock(password);
|
|
2259
|
-
if (!ok) {
|
|
2260
|
-
pendingVaultUnlock = false;
|
|
2261
|
-
pendingVaultAction = null;
|
|
2262
|
-
await send("Wrong password.");
|
|
2263
|
-
return;
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
// Execute pending action
|
|
2267
|
-
const action = pendingVaultAction;
|
|
2268
|
-
pendingVaultUnlock = false;
|
|
2269
|
-
pendingVaultAction = null;
|
|
2270
|
-
|
|
2271
|
-
if (action.type === "list") {
|
|
2272
|
-
const entries = vault.list();
|
|
2273
|
-
const keys = Object.keys(entries);
|
|
2274
|
-
if (keys.length === 0) await send("Vault unlocked (empty).\n\nUse /vault set <name> <value>");
|
|
2275
|
-
else await send("Vault unlocked:\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nAuto-locks in 5 min.");
|
|
2276
|
-
} else if (action.type === "set") {
|
|
2277
|
-
vault.set(action.key, action.value);
|
|
2278
|
-
await send(`Saved: ${action.key}`);
|
|
2279
|
-
} else if (action.type === "remove") {
|
|
2280
|
-
vault.remove(action.key);
|
|
2281
|
-
await send(`Removed: ${action.key}`);
|
|
2282
|
-
}
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
// Normal message
|
|
2287
|
-
if (!requireSession(msg)) return;
|
|
2288
|
-
|
|
2289
|
-
// Detect credential-like messages and delete them from chat
|
|
2290
|
-
const text = msg.text;
|
|
2291
|
-
const credPatterns = [
|
|
2292
|
-
/^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/, // API keys
|
|
2293
|
-
/^[A-Za-z0-9_-]{20,}$/, // Long token-like strings with no spaces
|
|
2294
|
-
/^(Bearer |token:|key:|secret:|password:)/i, // Prefixed credentials
|
|
2295
|
-
];
|
|
2296
|
-
const looksLikeCredential = credPatterns.some((p) => p.test(text.trim()));
|
|
2297
|
-
if (looksLikeCredential) {
|
|
2298
|
-
await deleteMessage(msg.message_id);
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
|
-
let prompt = msg.text;
|
|
2302
|
-
const reply = msg.reply_to_message;
|
|
2303
|
-
if (reply) {
|
|
2304
|
-
let ctx = "";
|
|
2305
|
-
if (reply.text) {
|
|
2306
|
-
ctx = reply.text;
|
|
2307
|
-
}
|
|
2308
|
-
if (reply.caption) {
|
|
2309
|
-
ctx += (ctx ? "\n" : "") + reply.caption;
|
|
2310
|
-
}
|
|
2311
|
-
if (reply.document) {
|
|
2312
|
-
const fileName = reply.document.file_name || "unknown";
|
|
2313
|
-
const filePath = path.join(FILES_DIR, fileName);
|
|
2314
|
-
if (fs.existsSync(filePath)) {
|
|
2315
|
-
ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
|
|
2316
|
-
} else {
|
|
2317
|
-
// Download the file from the replied message
|
|
2318
|
-
try {
|
|
2319
|
-
const file = await bot.getFile(reply.document.file_id);
|
|
2320
|
-
const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
|
|
2321
|
-
await new Promise((resolve, reject) => {
|
|
2322
|
-
const out = fs.createWriteStream(filePath);
|
|
2323
|
-
https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
|
|
2324
|
-
});
|
|
2325
|
-
ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
|
|
2326
|
-
} catch (e) {
|
|
2327
|
-
ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} — could not download]`;
|
|
2328
|
-
}
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2331
|
-
if (reply.photo) {
|
|
2332
|
-
ctx += (ctx ? "\n" : "") + "[Attached photo]";
|
|
2333
|
-
}
|
|
2334
|
-
if (ctx) {
|
|
2335
|
-
prompt = `Replying to message:\n---\n${ctx.length > 1000 ? ctx.slice(0, 1000) + "..." : ctx}\n---\n\n${msg.text}`;
|
|
2336
|
-
}
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
await runClaude(prompt, currentSession.dir, msg.message_id);
|
|
2340
|
-
});
|
|
2341
|
-
|
|
2342
|
-
// ── Startup ─────────────────────────────────────────────────────────
|
|
2343
|
-
initCrons();
|
|
2344
|
-
console.log("Claude Code Telegram bot running");
|
|
2345
|
-
console.log(`Workspace: ${WORKSPACE}`);
|
|
2346
|
-
console.log(`Soul: ${SOUL_FILE}`);
|
|
2347
|
-
console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
|
|
2348
|
-
console.log(`Onboarded: ${isOnboarded()}`);
|
|
2349
|
-
|
|
2350
|
-
// Notify owner that bot is back online
|
|
2351
|
-
bot.sendMessage(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`).catch(() => {});
|
|
4
|
+
// Compatibility entrypoint for older services. Runtime behavior, including
|
|
5
|
+
// agent-mode side responses, lives exclusively in the modular entrypoint.
|
|
6
|
+
module.exports = require("./bot");
|