@inetafrica/open-claudia 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -0
- package/CHANGELOG.md +16 -0
- package/README.md +39 -0
- package/bot.js +751 -99
- package/package.json +1 -1
package/.env.example
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.19.0
|
|
4
|
+
- Added `/doctor` / `/requirements`: mobile-friendly checks for Node.js, Claude/Cursor/Codex CLI versions and auth, optional ffmpeg/Whisper voice stack, workspace writability, and config dir writability
|
|
5
|
+
- `/upgrade` now includes a post-upgrade requirements/auth summary before restarting, so missing CLIs or auth breakage is visible immediately
|
|
6
|
+
- Added Codex auth commands: `/codex_auth_status`, `/codex_login` device auth, `/codex_setup_token` / `/codex_use_api_key` secure API-key paste mode, and `/cancel_codex_auth`
|
|
7
|
+
- Codex/OpenAI keys are redacted from Telegram output/logs; API keys are passed to `codex login --with-api-key` without being echoed
|
|
8
|
+
- Added canonical user identity links for future multi-channel support. State and session history now key by canonical user id (`telegram:<chatId>` by default, or an explicit id like `sumeet@inet.africa`) instead of raw Telegram chat id.
|
|
9
|
+
- Added `/link`, `/links`, and `/whoami` for managing and inspecting Telegram-to-user mappings.
|
|
10
|
+
- Existing `state.json` and `sessions.json` chat-id keys are migrated through the identity resolver on load.
|
|
11
|
+
|
|
12
|
+
## v1.18.0
|
|
13
|
+
- Auto-compacts high-context sessions before the next turn (`AUTO_COMPACT_TOKENS`, default 140k): summarizes the old session, seeds a fresh session, then continues the user's request there
|
|
14
|
+
- `/compact` now creates a fresh compacted continuation session instead of only adding another summary turn to the existing session
|
|
15
|
+
- `/continue` resumes the selected stored session ID with `--resume` instead of using cwd-most-recent `--continue`
|
|
16
|
+
- Stabilized the mobile system prompt: no timestamps, dynamic file lists, vault key names, or raw Telegram token curl examples
|
|
17
|
+
- Reply context is no longer redundantly injected when replying to the bot's own prior text from the active session
|
|
18
|
+
|
|
3
19
|
## v1.17.0
|
|
4
20
|
- **Multi-user / team mode**: a single bot can now serve multiple authorized users in parallel. Each user has their own conversation thread, project session, settings, model, backend, runningProcess, queue, and usage counters
|
|
5
21
|
- Per-user state lives in `userStates: Map<chatId, UserState>`; an `AsyncLocalStorage` chat context routes `send()`/`editMessage()`/typing indicators back to whoever triggered the work
|
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Send text, voice notes, screenshots, and files from your phone. Your chosen AI a
|
|
|
22
22
|
- **Model switching** — toggle between models on either backend
|
|
23
23
|
- **Plan mode, effort levels, budgets** — full control from Telegram
|
|
24
24
|
- **Auto-updates** — checks for new versions every 5 minutes, upgrade with `/upgrade`
|
|
25
|
+
- **Requirements doctor** — `/doctor` / `/requirements` checks CLI installs, auth, voice tools, and writable paths after upgrades
|
|
25
26
|
- **Multi-user auth** — authorize additional users with code verification
|
|
26
27
|
- **Cross-platform** — works on macOS, Linux, and Windows
|
|
27
28
|
|
|
@@ -60,6 +61,8 @@ agent status # Verify: should show your email and plan
|
|
|
60
61
|
```bash
|
|
61
62
|
npm install -g @openai/codex
|
|
62
63
|
codex login # Opens browser to authenticate
|
|
64
|
+
# Or from Telegram after Open Claudia is running: /codex_login
|
|
65
|
+
# If browser/device login cannot complete remotely: /codex_setup_token
|
|
63
66
|
codex --version # Verify it works
|
|
64
67
|
```
|
|
65
68
|
|
|
@@ -141,6 +144,7 @@ When you select a project, the last conversation is automatically resumed. Tap "
|
|
|
141
144
|
| `/worktree` | Toggle isolated git branch — Claude only |
|
|
142
145
|
| `/mode` | Switch between direct and agent bot modes |
|
|
143
146
|
| `/status` | Show current session, backend, and settings |
|
|
147
|
+
| `/doctor` / `/requirements` | Check Node, CLI binaries/versions/auth, voice stack, and writable paths |
|
|
144
148
|
|
|
145
149
|
### Automation
|
|
146
150
|
|
|
@@ -163,12 +167,37 @@ When you select a project, the last conversation is automatically resumed. Tap "
|
|
|
163
167
|
|
|
164
168
|
Tokens are redacted from Telegram output and logs. If the encrypted vault is unlocked, `/use_oauth_token` also mirrors the token into the vault, but `.env` storage is what lets launchd pass `CLAUDE_CODE_OAUTH_TOKEN` to Claude without relying on macOS Keychain.
|
|
165
169
|
|
|
170
|
+
### Codex Auth
|
|
171
|
+
|
|
172
|
+
| Command | Description |
|
|
173
|
+
|---------|-------------|
|
|
174
|
+
| `/codex_auth_status` | Runs `codex login status` and reports redacted status/version |
|
|
175
|
+
| `/codex_login` | Starts `codex login --device-auth` and sends any login URL/device code printed by the CLI |
|
|
176
|
+
| `/codex_setup_token` | Secure paste mode for an OpenAI API key; the message is deleted and passed to `codex login --with-api-key` without echoing it |
|
|
177
|
+
| `/codex_setup_token <key>` | Same as above, but inline; the command message is deleted when possible |
|
|
178
|
+
| `/cancel_codex_auth` | Cancels a pending Codex auth flow |
|
|
179
|
+
|
|
180
|
+
Codex tokens/API keys are redacted from Telegram output and logs. Device login support depends on the installed Codex CLI; if it requires a real TTY, Open Claudia will say so and suggest `/codex_setup_token` or an SSH/terminal fallback.
|
|
181
|
+
|
|
182
|
+
### Requirements Doctor
|
|
183
|
+
|
|
184
|
+
`/doctor` (alias: `/requirements`) gives a mobile-friendly checklist after upgrades or whenever something feels broken:
|
|
185
|
+
|
|
186
|
+
- Node.js version
|
|
187
|
+
- Claude CLI version/auth
|
|
188
|
+
- Cursor Agent version/status when installed/configured
|
|
189
|
+
- Codex CLI version/auth when installed/configured
|
|
190
|
+
- ffmpeg/Whisper/model paths when voice is configured
|
|
191
|
+
- Workspace and config directory writability
|
|
192
|
+
|
|
193
|
+
`/upgrade` also includes a post-upgrade doctor summary before restarting.
|
|
166
194
|
|
|
167
195
|
### System
|
|
168
196
|
|
|
169
197
|
| Command | Description |
|
|
170
198
|
|---------|-------------|
|
|
171
199
|
| `/version` | Show current running version |
|
|
200
|
+
| `/doctor` / `/requirements` | Check installed CLI requirements/auth and writable paths |
|
|
172
201
|
| `/upgrade` | Upgrade to latest version and restart |
|
|
173
202
|
| `/restart` | Restart the bot |
|
|
174
203
|
| `/stop` | Cancel a running task |
|
|
@@ -229,6 +258,15 @@ open-claudia auth
|
|
|
229
258
|
|
|
230
259
|
This shows authorized chats, pending requests, and lets you approve/deny or add new users with code verification.
|
|
231
260
|
|
|
261
|
+
Authorized chats are separate from identity links. By default each Telegram chat uses `telegram:<chatId>` as its user id. To share one session/history across channels later, link the chat to a canonical id:
|
|
262
|
+
|
|
263
|
+
```text
|
|
264
|
+
/link sumeet@inet.africa
|
|
265
|
+
/whoami
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The owner can link another Telegram chat with `/link <chatId> <email-or-user-id>` and list explicit links with `/links`.
|
|
269
|
+
|
|
232
270
|
## How It Works
|
|
233
271
|
|
|
234
272
|
```
|
|
@@ -249,6 +287,7 @@ All stored in `~/.open-claudia/`:
|
|
|
249
287
|
|------|---------|
|
|
250
288
|
| `.env` | Telegram token, workspace path, binary paths (`CLAUDE_PATH`, `CURSOR_PATH`) |
|
|
251
289
|
| `auth.json` | Authorized users and pending requests |
|
|
290
|
+
| `identities.json` | Channel-to-canonical-user mappings such as `telegram:<chatId> -> user@example.com` |
|
|
252
291
|
| `vault.enc` | Encrypted credential store |
|
|
253
292
|
| `soul.md` | Assistant identity and personality (editable via `/soul`) |
|
|
254
293
|
| `crons.json` | Scheduled tasks |
|
package/bot.js
CHANGED
|
@@ -150,6 +150,7 @@ const WORKSPACE = config.WORKSPACE;
|
|
|
150
150
|
const CLAUDE_PATH = config.CLAUDE_PATH;
|
|
151
151
|
const CURSOR_PATH = config.CURSOR_PATH || null;
|
|
152
152
|
const CODEX_PATH = config.CODEX_PATH || null;
|
|
153
|
+
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "140000", 10);
|
|
153
154
|
|
|
154
155
|
// Validate critical config at startup
|
|
155
156
|
if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN not set"); process.exit(1); }
|
|
@@ -203,6 +204,7 @@ const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
|
|
|
203
204
|
const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
|
|
204
205
|
const VAULT_FILE = config.VAULT_FILE || path.join(CONFIG_DIR, "vault.enc");
|
|
205
206
|
const AUTH_FILE = config.AUTH_FILE || path.join(CONFIG_DIR, "auth.json");
|
|
207
|
+
const IDENTITIES_FILE = config.IDENTITIES_FILE || path.join(CONFIG_DIR, "identities.json");
|
|
206
208
|
const BOT_DIR = __dirname;
|
|
207
209
|
|
|
208
210
|
// Detect PATH for subprocess
|
|
@@ -302,7 +304,13 @@ bot.setMyCommands([
|
|
|
302
304
|
{ command: "claude", description: "Switch to Claude Code backend" },
|
|
303
305
|
{ command: "codex", description: "Switch to OpenAI Codex backend" },
|
|
304
306
|
{ command: "backend", description: "Show/switch active backend" },
|
|
307
|
+
{ command: "doctor", description: "Check CLI requirements" },
|
|
308
|
+
{ command: "requirements", description: "Check CLI requirements" },
|
|
309
|
+
{ command: "link", description: "Link this chat to a canonical user id" },
|
|
310
|
+
{ command: "whoami", description: "Show your canonical user id" },
|
|
305
311
|
{ command: "auth_status", description: "Check Claude Code auth" },
|
|
312
|
+
{ command: "codex_auth_status", description: "Check Codex auth" },
|
|
313
|
+
{ command: "codex_login", description: "Start Codex device login" },
|
|
306
314
|
{ command: "login", description: "Start Claude Code login" },
|
|
307
315
|
{ command: "setup_token", description: "Create Claude OAuth token" },
|
|
308
316
|
{ command: "stop", description: "Cancel running task" },
|
|
@@ -330,19 +338,85 @@ const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
|
|
|
330
338
|
const STATE_FILE = path.join(CONFIG_DIR, "state.json");
|
|
331
339
|
const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
|
|
332
340
|
|
|
341
|
+
function normalizeCanonicalUserId(value) {
|
|
342
|
+
return String(value || "").trim().toLowerCase();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function channelKey(transport, channelId) {
|
|
346
|
+
return `${String(transport || "").trim().toLowerCase()}:${String(channelId || "").trim()}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function defaultCanonicalForChannel(transport, channelId) {
|
|
350
|
+
return channelKey(transport, channelId);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function loadIdentities() {
|
|
354
|
+
try {
|
|
355
|
+
const raw = JSON.parse(fs.readFileSync(IDENTITIES_FILE, "utf-8"));
|
|
356
|
+
return {
|
|
357
|
+
channels: raw && typeof raw.channels === "object" ? raw.channels : {},
|
|
358
|
+
preferred: raw && typeof raw.preferred === "object" ? raw.preferred : {},
|
|
359
|
+
};
|
|
360
|
+
} catch (e) {
|
|
361
|
+
return { channels: {}, preferred: {} };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const identities = loadIdentities();
|
|
366
|
+
|
|
367
|
+
function saveIdentities() {
|
|
368
|
+
try { fs.writeFileSync(IDENTITIES_FILE, JSON.stringify(identities, null, 2)); } catch (e) {}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function canonicalForChannel(transport, channelId) {
|
|
372
|
+
const key = channelKey(transport, channelId);
|
|
373
|
+
return normalizeCanonicalUserId(identities.channels[key]) || defaultCanonicalForChannel(transport, channelId);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function canonicalForTelegram(chatId) {
|
|
377
|
+
return canonicalForChannel("telegram", chatId);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function canonicalForStoredUserKey(key) {
|
|
381
|
+
const id = String(key);
|
|
382
|
+
if (id.includes(":") || id.includes("@")) return normalizeCanonicalUserId(id);
|
|
383
|
+
return canonicalForTelegram(id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function currentCanonicalUserId() {
|
|
387
|
+
return canonicalForTelegram(currentChatId());
|
|
388
|
+
}
|
|
389
|
+
|
|
333
390
|
function loadStateFile() {
|
|
334
391
|
try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")); } catch (e) { return {}; }
|
|
335
392
|
}
|
|
336
393
|
|
|
394
|
+
function mergeSavedState(existing, next) {
|
|
395
|
+
return {
|
|
396
|
+
...existing,
|
|
397
|
+
...next,
|
|
398
|
+
settings: { ...(existing.settings || {}), ...(next.settings || {}) },
|
|
399
|
+
sessionUsage: { ...(existing.sessionUsage || {}), ...(next.sessionUsage || {}) },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
337
403
|
// Multi-user state. v1.16 and earlier stored a single user's state at the
|
|
338
|
-
// top level of state.json; v1.17
|
|
339
|
-
//
|
|
340
|
-
//
|
|
404
|
+
// top level of state.json; v1.17/v1.18 stored `{ users: { "<chatId>": {...} } }`.
|
|
405
|
+
// v1.19+ keys state by canonical user id (`telegram:<chatId>` by default,
|
|
406
|
+
// or an explicit mapping like `sumeet@inet.africa`) so future transports can
|
|
407
|
+
// share the same session state.
|
|
341
408
|
const savedState = (() => {
|
|
342
409
|
const raw = loadStateFile();
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
410
|
+
const users = {};
|
|
411
|
+
if (raw && raw.users && typeof raw.users === "object") {
|
|
412
|
+
for (const [key, value] of Object.entries(raw.users)) {
|
|
413
|
+
const userId = canonicalForStoredUserKey(key);
|
|
414
|
+
users[userId] = mergeSavedState(users[userId] || {}, value || {});
|
|
415
|
+
}
|
|
416
|
+
return { users };
|
|
417
|
+
}
|
|
418
|
+
// Legacy single-user shape: hoist it under the owner's canonical id.
|
|
419
|
+
return { users: { [canonicalForTelegram(CHAT_ID)]: raw || {} } };
|
|
346
420
|
})();
|
|
347
421
|
|
|
348
422
|
let activeCrons = new Map();
|
|
@@ -360,12 +434,13 @@ function freshUsage() {
|
|
|
360
434
|
return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
|
|
361
435
|
}
|
|
362
436
|
|
|
363
|
-
function createUserState(
|
|
364
|
-
const saved = (savedState.users && savedState.users[
|
|
437
|
+
function createUserState(userId) {
|
|
438
|
+
const saved = (savedState.users && savedState.users[userId]) || {};
|
|
365
439
|
const settings = saved.settings || freshSettings();
|
|
366
440
|
if (!settings.backend) settings.backend = "claude";
|
|
367
441
|
return {
|
|
368
|
-
|
|
442
|
+
userId: String(userId),
|
|
443
|
+
chatId: currentChatId(),
|
|
369
444
|
currentSession: saved.currentSession || null,
|
|
370
445
|
runningProcess: null,
|
|
371
446
|
statusMessageId: null,
|
|
@@ -385,13 +460,19 @@ function createUserState(chatId) {
|
|
|
385
460
|
pendingVaultAction: null,
|
|
386
461
|
pendingClaudeAuthProcess: null,
|
|
387
462
|
pendingClaudeAuthLabel: null,
|
|
463
|
+
pendingCodexAuthProcess: null,
|
|
464
|
+
pendingCodexAuthLabel: null,
|
|
465
|
+
isCompacting: false,
|
|
466
|
+
lastCompactedAt: saved.lastCompactedAt || 0,
|
|
388
467
|
};
|
|
389
468
|
}
|
|
390
469
|
|
|
391
|
-
function getUserState(
|
|
392
|
-
const id =
|
|
470
|
+
function getUserState(userId) {
|
|
471
|
+
const id = normalizeCanonicalUserId(userId);
|
|
393
472
|
if (!userStates.has(id)) userStates.set(id, createUserState(id));
|
|
394
|
-
|
|
473
|
+
const state = userStates.get(id);
|
|
474
|
+
state.chatId = currentChatId();
|
|
475
|
+
return state;
|
|
395
476
|
}
|
|
396
477
|
|
|
397
478
|
// AsyncLocalStorage carries the active chat id through the async call
|
|
@@ -405,7 +486,7 @@ function currentChatId() {
|
|
|
405
486
|
}
|
|
406
487
|
|
|
407
488
|
function currentState() {
|
|
408
|
-
return getUserState(
|
|
489
|
+
return getUserState(currentCanonicalUserId());
|
|
409
490
|
}
|
|
410
491
|
|
|
411
492
|
function resetSessionUsage(state = currentState()) {
|
|
@@ -417,7 +498,7 @@ function resetSettings(state = currentState()) {
|
|
|
417
498
|
}
|
|
418
499
|
|
|
419
500
|
function saveState() {
|
|
420
|
-
const data = { users: {} };
|
|
501
|
+
const data = { users: { ...(savedState.users || {}) } };
|
|
421
502
|
for (const [id, s] of userStates) {
|
|
422
503
|
data.users[id] = {
|
|
423
504
|
currentSession: s.currentSession,
|
|
@@ -426,11 +507,67 @@ function saveState() {
|
|
|
426
507
|
codexSessionId: s.codexSessionId,
|
|
427
508
|
settings: s.settings,
|
|
428
509
|
sessionUsage: s.sessionUsage,
|
|
510
|
+
lastCompactedAt: s.lastCompactedAt || 0,
|
|
429
511
|
};
|
|
430
512
|
}
|
|
513
|
+
savedState.users = data.users;
|
|
431
514
|
try { fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch (e) {}
|
|
432
515
|
}
|
|
433
516
|
|
|
517
|
+
function migrateUserData(fromUserId, toUserId) {
|
|
518
|
+
const from = normalizeCanonicalUserId(fromUserId);
|
|
519
|
+
const to = normalizeCanonicalUserId(toUserId);
|
|
520
|
+
if (!from || !to || from === to) return;
|
|
521
|
+
|
|
522
|
+
if (savedState.users && savedState.users[from]) {
|
|
523
|
+
savedState.users[to] = mergeSavedState(savedState.users[to] || {}, savedState.users[from]);
|
|
524
|
+
delete savedState.users[from];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (userStates.has(from)) {
|
|
528
|
+
const fromState = userStates.get(from);
|
|
529
|
+
if (userStates.has(to)) {
|
|
530
|
+
const toState = userStates.get(to);
|
|
531
|
+
Object.assign(toState, mergeSavedState(toState, fromState));
|
|
532
|
+
toState.userId = to;
|
|
533
|
+
} else {
|
|
534
|
+
fromState.userId = to;
|
|
535
|
+
userStates.set(to, fromState);
|
|
536
|
+
}
|
|
537
|
+
userStates.delete(from);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const sessions = loadSessions();
|
|
541
|
+
if (sessions[from]) {
|
|
542
|
+
sessions[to] = { ...(sessions[to] || {}) };
|
|
543
|
+
for (const [project, list] of Object.entries(sessions[from])) {
|
|
544
|
+
sessions[to][project] = [
|
|
545
|
+
...(sessions[to][project] || []),
|
|
546
|
+
...(Array.isArray(list) ? list : []),
|
|
547
|
+
].slice(-20);
|
|
548
|
+
}
|
|
549
|
+
delete sessions[from];
|
|
550
|
+
saveSessions(sessions);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
saveState();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function setIdentityMapping(transport, channelId, canonicalUserId) {
|
|
557
|
+
const key = channelKey(transport, channelId);
|
|
558
|
+
const userId = normalizeCanonicalUserId(canonicalUserId);
|
|
559
|
+
if (!userId) throw new Error("Canonical user id is required.");
|
|
560
|
+
const hadExplicitMapping = Object.prototype.hasOwnProperty.call(identities.channels, key);
|
|
561
|
+
const previousUserId = canonicalForChannel(transport, channelId);
|
|
562
|
+
const defaultUserId = defaultCanonicalForChannel(transport, channelId);
|
|
563
|
+
identities.channels[key] = userId;
|
|
564
|
+
identities.preferred[userId] = { transport: String(transport).toLowerCase(), channelId: String(channelId) };
|
|
565
|
+
saveIdentities();
|
|
566
|
+
const shouldMigrate = !hadExplicitMapping || previousUserId === defaultUserId;
|
|
567
|
+
if (shouldMigrate) migrateUserData(previousUserId, userId);
|
|
568
|
+
return { key, previousUserId, userId, migrated: shouldMigrate && previousUserId !== userId };
|
|
569
|
+
}
|
|
570
|
+
|
|
434
571
|
// ── Message deduplication ──────────────────────────────────────────
|
|
435
572
|
// Telegram message_ids are unique per chat, not globally — namespace by
|
|
436
573
|
// chat id so two users' messages can't collide.
|
|
@@ -448,9 +585,9 @@ function isDuplicate(msg) {
|
|
|
448
585
|
}
|
|
449
586
|
|
|
450
587
|
// ── Per-project session history ────────────────────────────────────
|
|
451
|
-
// Sessions are stored per
|
|
452
|
-
// history per project. Legacy
|
|
453
|
-
//
|
|
588
|
+
// Sessions are stored per canonical user so linked channels share the same
|
|
589
|
+
// conversation history per project. Legacy files keyed by chat id are migrated
|
|
590
|
+
// through the same identity resolver on first read.
|
|
454
591
|
|
|
455
592
|
function loadSessions() {
|
|
456
593
|
let raw;
|
|
@@ -458,17 +595,28 @@ function loadSessions() {
|
|
|
458
595
|
if (!raw || typeof raw !== "object") return {};
|
|
459
596
|
// Legacy detection: top-level keys map directly to arrays of session objects.
|
|
460
597
|
const looksLegacy = Object.values(raw).some((v) => Array.isArray(v));
|
|
461
|
-
if (looksLegacy) return { [CHAT_ID]: raw };
|
|
462
|
-
|
|
598
|
+
if (looksLegacy) return { [canonicalForTelegram(CHAT_ID)]: raw };
|
|
599
|
+
const migrated = {};
|
|
600
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
601
|
+
const userId = canonicalForStoredUserKey(key);
|
|
602
|
+
migrated[userId] = { ...(migrated[userId] || {}) };
|
|
603
|
+
for (const [project, list] of Object.entries(value || {})) {
|
|
604
|
+
migrated[userId][project] = [
|
|
605
|
+
...(migrated[userId][project] || []),
|
|
606
|
+
...(Array.isArray(list) ? list : []),
|
|
607
|
+
].slice(-20);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return migrated;
|
|
463
611
|
}
|
|
464
612
|
|
|
465
613
|
function saveSessions(sessions) {
|
|
466
614
|
try { fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); } catch (e) {}
|
|
467
615
|
}
|
|
468
616
|
|
|
469
|
-
function recordSession(
|
|
617
|
+
function recordSession(userId, projectName, sessionId, title) {
|
|
470
618
|
const all = loadSessions();
|
|
471
|
-
const id =
|
|
619
|
+
const id = normalizeCanonicalUserId(userId);
|
|
472
620
|
if (!all[id]) all[id] = {};
|
|
473
621
|
if (!all[id][projectName]) all[id][projectName] = [];
|
|
474
622
|
const arr = all[id][projectName];
|
|
@@ -488,13 +636,13 @@ function recordSession(chatId, projectName, sessionId, title) {
|
|
|
488
636
|
saveSessions(all);
|
|
489
637
|
}
|
|
490
638
|
|
|
491
|
-
function getProjectSessions(
|
|
639
|
+
function getProjectSessions(userId, projectName) {
|
|
492
640
|
const all = loadSessions();
|
|
493
|
-
return ((all[
|
|
641
|
+
return ((all[normalizeCanonicalUserId(userId)] || {})[projectName] || []).slice().reverse();
|
|
494
642
|
}
|
|
495
643
|
|
|
496
|
-
function getLastProjectSession(
|
|
497
|
-
const sessions = getProjectSessions(
|
|
644
|
+
function getLastProjectSession(userId, projectName) {
|
|
645
|
+
const sessions = getProjectSessions(userId, projectName);
|
|
498
646
|
return sessions.length > 0 ? sessions[0] : null;
|
|
499
647
|
}
|
|
500
648
|
|
|
@@ -667,65 +815,38 @@ function saveCrons(list) {
|
|
|
667
815
|
|
|
668
816
|
function buildSystemPrompt() {
|
|
669
817
|
const state = currentState();
|
|
670
|
-
const chatId = state.chatId;
|
|
671
818
|
const soul = loadSoul();
|
|
672
|
-
const cronList = loadCrons();
|
|
673
|
-
const vaultKeys = vault.isUnlocked() ? vault.keys() : [];
|
|
674
|
-
const now = new Date().toISOString();
|
|
675
819
|
const hasVoice = WHISPER_CLI && FFMPEG;
|
|
676
820
|
|
|
677
821
|
return `
|
|
678
822
|
${soul}
|
|
679
823
|
|
|
680
|
-
##
|
|
681
|
-
-
|
|
682
|
-
-
|
|
683
|
-
- Active project: ${state.currentSession ? state.currentSession.name + " (" + state.currentSession.dir + ")" : "none"}
|
|
824
|
+
## Runtime Context
|
|
825
|
+
- Interface: Telegram mobile chat through Open Claudia.
|
|
826
|
+
- Active project path: ${state.currentSession ? state.currentSession.dir : "none"}
|
|
684
827
|
- Voice notes: ${hasVoice ? "enabled" : "disabled"}
|
|
685
|
-
- Vault: ${vault.isUnlocked() ? "unlocked
|
|
828
|
+
- Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}
|
|
829
|
+
- Session: ${state.lastSessionId ? "resuming existing conversation" : "new conversation"}
|
|
686
830
|
|
|
687
|
-
##
|
|
688
|
-
|
|
831
|
+
## Stable Local Paths
|
|
832
|
+
- Bot code: ${path.join(BOT_DIR, "bot.js")}
|
|
833
|
+
- Personality file: ${SOUL_FILE}
|
|
834
|
+
- Cron config: ${CRONS_FILE}
|
|
835
|
+
- Vault file: ${VAULT_FILE}
|
|
836
|
+
- Bot environment: ${path.join(BOT_DIR, ".env")} (sensitive; never expose values)
|
|
837
|
+
- Received user files directory: ${FILES_DIR}
|
|
689
838
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
### ${CRONS_FILE}
|
|
694
|
-
Scheduled tasks. JSON array of { id, schedule, project, prompt, label }.
|
|
695
|
-
Active: ${cronList.length > 0 ? cronList.map(c => c.label).join(", ") : "none"}.
|
|
696
|
-
|
|
697
|
-
### ${VAULT_FILE}
|
|
698
|
-
Encrypted credential vault. ${vault.isUnlocked() ? "UNLOCKED. Keys: " + vaultKeys.join(", ") : "LOCKED — user must send /vault to unlock."}.
|
|
699
|
-
${vault.isUnlocked() ? "To read a credential: require('./vault') or read from the vault object." : ""}
|
|
700
|
-
|
|
701
|
-
### ${path.join(BOT_DIR, "bot.js")}
|
|
702
|
-
The bot code. Read to understand capabilities. Only modify if explicitly asked.
|
|
703
|
-
|
|
704
|
-
### ${path.join(BOT_DIR, ".env")}
|
|
705
|
-
Configuration (Telegram token, paths, etc). Sensitive — don't expose values.
|
|
706
|
-
|
|
707
|
-
## Received Files
|
|
708
|
-
Files sent by the user are saved in: ${FILES_DIR}
|
|
709
|
-
${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 ""; } })() : ""}
|
|
710
|
-
|
|
711
|
-
## Telegram API
|
|
712
|
-
Send things directly to the user who triggered this turn (chat_id ${chatId}):
|
|
713
|
-
|
|
714
|
-
Text: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" -d chat_id=${chatId} -d text="message"
|
|
715
|
-
File: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendDocument" -F chat_id=${chatId} -F document=@/path/to/file
|
|
716
|
-
Image: curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendPhoto" -F chat_id=${chatId} -F photo=@/path/to/image.png
|
|
717
|
-
|
|
718
|
-
## Session
|
|
719
|
-
${state.lastSessionId ? "Resuming conversation — you have prior context." : "New conversation."}
|
|
839
|
+
## Telegram Delivery
|
|
840
|
+
Reply normally in your final answer. If you must send a large file, image, or artifact directly, use the Telegram API with the configured bot token from the environment/config; never print or embed the token in prompts, commands, logs, or messages.
|
|
720
841
|
|
|
721
842
|
## Guidelines
|
|
722
843
|
- Keep responses concise — this is a mobile screen.
|
|
723
844
|
- Use Telegram-compatible markdown: *bold*, _italic_, \`code\`, \`\`\`code blocks\`\`\`. No headers (#), no links [text](url).
|
|
724
|
-
- For long output (logs, diffs, large code), save to a file and send
|
|
845
|
+
- For long output (logs, diffs, large code), save to a file and send it as an artifact instead of pasting walls of text.
|
|
725
846
|
- Act on screenshots (fix bugs, implement designs) — don't just describe what you see.
|
|
726
|
-
- When the user sends a file, it
|
|
727
|
-
- 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.
|
|
728
|
-
- When asked to change your personality, edit
|
|
847
|
+
- When the user sends a file, it is saved in the received files directory above. Read it with the Read tool.
|
|
848
|
+
- 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.
|
|
849
|
+
- When asked to change your personality, edit the personality file above.
|
|
729
850
|
- When asked about yourself, you are Open Claudia — an AI coding assistant running Claude Code via Telegram.
|
|
730
851
|
- If a task will take a while, let the user know upfront.
|
|
731
852
|
- Don't ask for confirmation on simple tasks — just do them.
|
|
@@ -898,6 +1019,226 @@ async function deleteMessage(msgId) {
|
|
|
898
1019
|
}
|
|
899
1020
|
|
|
900
1021
|
|
|
1022
|
+
// ── Requirements / Doctor Helpers ──────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
function shellQuote(value) {
|
|
1025
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function runCommandForDoctor(command, args = [], opts = {}) {
|
|
1029
|
+
try {
|
|
1030
|
+
const out = execSync([command, ...args].map(shellQuote).join(" "), {
|
|
1031
|
+
cwd: opts.cwd || process.env.HOME || require("os").homedir(),
|
|
1032
|
+
env: opts.env || botSubprocessEnv(),
|
|
1033
|
+
encoding: "utf-8",
|
|
1034
|
+
timeout: opts.timeout || 10000,
|
|
1035
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1036
|
+
});
|
|
1037
|
+
return { ok: true, output: out.trim(), code: 0 };
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim(), code: e.status ?? 1 };
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function binaryCheck(binPath, name) {
|
|
1044
|
+
if (!binPath) return { ok: false, label: name, detail: "not configured/found" };
|
|
1045
|
+
try {
|
|
1046
|
+
if (fs.existsSync(binPath)) fs.accessSync(binPath, fs.constants.X_OK);
|
|
1047
|
+
else execSync(process.platform === "win32" ? `where ${shellQuote(binPath)}` : `which ${shellQuote(binPath)}`, { stdio: "ignore", env: botSubprocessEnv() });
|
|
1048
|
+
return { ok: true, label: name, detail: binPath };
|
|
1049
|
+
} catch (e) {
|
|
1050
|
+
return { ok: false, label: name, detail: `not executable: ${binPath}` };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function checkWritableDir(dirPath, label) {
|
|
1055
|
+
if (!dirPath) return { ok: false, label, detail: "not configured" };
|
|
1056
|
+
try {
|
|
1057
|
+
if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
|
|
1058
|
+
const test = path.join(dirPath, `.open-claudia-write-test-${Date.now()}`);
|
|
1059
|
+
fs.writeFileSync(test, "ok");
|
|
1060
|
+
fs.unlinkSync(test);
|
|
1061
|
+
return { ok: true, label, detail: dirPath };
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
return { ok: false, label, detail: redactSensitive(e.message) };
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function summarizeAuthOutput(output, ok) {
|
|
1068
|
+
const clean = redactSensitive(stripTerminalControls(output || "")).replace(/\s+/g, " ").trim();
|
|
1069
|
+
if (!clean) return ok ? "ok" : "no output";
|
|
1070
|
+
return clean.length > 160 ? clean.slice(0, 157) + "..." : clean;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function isCodexAuthErrorText(text) {
|
|
1074
|
+
return /not (?:logged in|authenticated)|unauthenticated|login required|please (?:log|sign) in|401 unauthorized|invalid api key|no credentials/i.test(String(text || ""));
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function runDoctorChecks() {
|
|
1078
|
+
const checks = [];
|
|
1079
|
+
const nodeMajor = parseInt(process.version.slice(1).split(".")[0], 10);
|
|
1080
|
+
checks.push({ ok: nodeMajor >= 18, label: "Node.js", detail: process.version, action: nodeMajor >= 18 ? "" : "Install Node.js 18+." });
|
|
1081
|
+
|
|
1082
|
+
const claudeBin = binaryCheck(CLAUDE_PATH, "Claude CLI");
|
|
1083
|
+
if (claudeBin.ok) {
|
|
1084
|
+
const ver = runCommandForDoctor(CLAUDE_PATH, ["--version"]);
|
|
1085
|
+
const auth = runCommandForDoctor(CLAUDE_PATH, ["auth", "status"], { env: claudeSubprocessEnv(), timeout: 12000 });
|
|
1086
|
+
checks.push({ ok: ver.ok, label: "Claude version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CLAUDE_PATH." });
|
|
1087
|
+
checks.push({ ok: auth.ok && !isClaudeAuthErrorText(auth.output), label: "Claude auth", detail: summarizeAuthOutput(auth.output, auth.ok), action: auth.ok && !isClaudeAuthErrorText(auth.output) ? "" : "Run /auth_status then /setup_token or /login." });
|
|
1088
|
+
} else checks.push({ ...claudeBin, action: "Install @anthropic-ai/claude-code or fix CLAUDE_PATH." });
|
|
1089
|
+
|
|
1090
|
+
if (CURSOR_PATH || resolvedCursorPath) {
|
|
1091
|
+
const cursorPath = resolvedCursorPath || CURSOR_PATH;
|
|
1092
|
+
const cursorBin = binaryCheck(cursorPath, "Cursor Agent");
|
|
1093
|
+
if (cursorBin.ok) {
|
|
1094
|
+
const ver = runCommandForDoctor(cursorPath, ["--version"]);
|
|
1095
|
+
const status = runCommandForDoctor(cursorPath, ["status"], { timeout: 12000 });
|
|
1096
|
+
checks.push({ ok: ver.ok, label: "Cursor version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CURSOR_PATH." });
|
|
1097
|
+
checks.push({ ok: status.ok, label: "Cursor auth/status", detail: summarizeAuthOutput(status.output, status.ok), action: status.ok ? "" : "Run `agent login` on this host." });
|
|
1098
|
+
} else checks.push({ ...cursorBin, action: "Install Cursor Agent or fix CURSOR_PATH." });
|
|
1099
|
+
} else checks.push({ ok: true, warn: true, label: "Cursor Agent", detail: "not configured/found (optional)", action: "Install only if you want /cursor." });
|
|
1100
|
+
|
|
1101
|
+
if (CODEX_PATH || resolvedCodexPath) {
|
|
1102
|
+
const codexPath = resolvedCodexPath || CODEX_PATH;
|
|
1103
|
+
const codexBin = binaryCheck(codexPath, "Codex CLI");
|
|
1104
|
+
if (codexBin.ok) {
|
|
1105
|
+
const ver = runCommandForDoctor(codexPath, ["--version"]);
|
|
1106
|
+
const status = runCommandForDoctor(codexPath, ["login", "status"], { timeout: 12000 });
|
|
1107
|
+
checks.push({ ok: ver.ok, label: "Codex version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CODEX_PATH." });
|
|
1108
|
+
checks.push({ ok: status.ok && !isCodexAuthErrorText(status.output), label: "Codex auth", detail: summarizeAuthOutput(status.output, status.ok), action: status.ok && !isCodexAuthErrorText(status.output) ? "" : "Run /codex_login or /codex_setup_token." });
|
|
1109
|
+
} else checks.push({ ...codexBin, action: "Install @openai/codex or fix CODEX_PATH." });
|
|
1110
|
+
} else checks.push({ ok: true, warn: true, label: "Codex CLI", detail: "not configured/found (optional)", action: "Install only if you want /codex." });
|
|
1111
|
+
|
|
1112
|
+
if (FFMPEG || WHISPER_CLI || WHISPER_MODEL) {
|
|
1113
|
+
const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");
|
|
1114
|
+
const wh = binaryCheck(WHISPER_CLI, "Whisper CLI");
|
|
1115
|
+
checks.push({ ...ff, action: ff.ok ? "" : "Install ffmpeg or set FFMPEG." });
|
|
1116
|
+
checks.push({ ...wh, action: wh.ok ? "" : "Install whisper.cpp or set WHISPER_CLI." });
|
|
1117
|
+
checks.push({ ok: !!WHISPER_MODEL && fs.existsSync(WHISPER_MODEL), label: "Whisper model", detail: WHISPER_MODEL || "not configured", action: WHISPER_MODEL ? "Check model path." : "Set WHISPER_MODEL for voice notes." });
|
|
1118
|
+
} else checks.push({ ok: true, warn: true, label: "Voice stack", detail: "not configured (optional)", action: "Set FFMPEG/WHISPER_CLI/WHISPER_MODEL for voice notes." });
|
|
1119
|
+
|
|
1120
|
+
checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
|
|
1121
|
+
checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
|
|
1122
|
+
return checks;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function formatDoctorReport(checks) {
|
|
1126
|
+
const hardProblems = checks.filter((c) => !c.ok && !c.warn);
|
|
1127
|
+
const warnings = checks.filter((c) => c.warn || (!c.ok && c.warn));
|
|
1128
|
+
const lines = [hardProblems.length ? "⚠️ Open Claudia doctor found issues" : "✅ Open Claudia doctor looks good", ""];
|
|
1129
|
+
for (const c of checks) {
|
|
1130
|
+
const icon = c.ok ? (c.warn ? "⚠️" : "✅") : "⚠️";
|
|
1131
|
+
lines.push(`${icon} ${c.label}: ${c.detail || (c.ok ? "ok" : "issue")}`);
|
|
1132
|
+
}
|
|
1133
|
+
const actions = checks.filter((c) => (!c.ok || c.warn) && c.action).map((c) => `• ${c.label}: ${c.action}`);
|
|
1134
|
+
if (actions.length) lines.push("", "Next actions:", ...actions.slice(0, 8));
|
|
1135
|
+
if (warnings.length && !hardProblems.length) lines[0] = "✅ Core requirements pass (optional items noted)";
|
|
1136
|
+
return lines.join("\n");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function looksLikeOpenAIKey(value) {
|
|
1140
|
+
return /^sk-(?:proj-)?[A-Za-z0-9._-]{20,}$/.test(String(value || "").trim());
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function clearPendingCodexAuth(state = currentState()) {
|
|
1144
|
+
if (state.pendingCodexAuthProcess && state.pendingCodexAuthProcess.kill) {
|
|
1145
|
+
try { state.pendingCodexAuthProcess.kill("SIGTERM"); } catch (e) {}
|
|
1146
|
+
}
|
|
1147
|
+
state.pendingCodexAuthProcess = null;
|
|
1148
|
+
state.pendingCodexAuthLabel = null;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function runCodexLoginStatus() {
|
|
1152
|
+
if (!resolvedCodexPath) return { ok: false, code: 1, output: "Codex CLI not found" };
|
|
1153
|
+
const result = runCommandForDoctor(resolvedCodexPath, ["login", "status"], { timeout: 12000 });
|
|
1154
|
+
return { ...result, ok: result.ok && !isCodexAuthErrorText(result.output) };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
|
|
1158
|
+
const status = runCodexLoginStatus();
|
|
1159
|
+
const version = resolvedCodexPath ? runCommandForDoctor(resolvedCodexPath, ["--version"]) : { ok: false, output: "not found" };
|
|
1160
|
+
await send([
|
|
1161
|
+
prefix,
|
|
1162
|
+
"",
|
|
1163
|
+
`CLI: ${resolvedCodexPath ? "found" : "not found"}`,
|
|
1164
|
+
`Version: ${summarizeAuthOutput(version.output, version.ok)}`,
|
|
1165
|
+
`Logged in: ${status.ok ? "yes" : "no/unknown"}`,
|
|
1166
|
+
`Status: ${summarizeAuthOutput(status.output, status.ok)}`,
|
|
1167
|
+
status.ok ? "" : "Next: /codex_login for device auth, or /codex_setup_token to paste an OpenAI API key securely.",
|
|
1168
|
+
].filter(Boolean).join("\n"));
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function saveCodexApiKeyWithCli(apiKey) {
|
|
1172
|
+
return new Promise((resolve) => {
|
|
1173
|
+
const proc = spawn(resolvedCodexPath, ["login", "--with-api-key"], {
|
|
1174
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
1175
|
+
env: botSubprocessEnv(),
|
|
1176
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1177
|
+
});
|
|
1178
|
+
let output = "";
|
|
1179
|
+
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
1180
|
+
proc.stderr.on("data", (d) => { output += d.toString(); });
|
|
1181
|
+
proc.on("close", (code) => resolve({ ok: code === 0, code, output }));
|
|
1182
|
+
proc.on("error", (err) => resolve({ ok: false, code: 1, output: err.message }));
|
|
1183
|
+
proc.stdin.write(apiKey.trim() + "\n");
|
|
1184
|
+
proc.stdin.end();
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function runCodexDeviceLogin() {
|
|
1189
|
+
const state = currentState();
|
|
1190
|
+
if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
|
|
1191
|
+
if (state.pendingCodexAuthProcess) return send(`Another Codex auth flow is already running (${state.pendingCodexAuthLabel}). Send /cancel_codex_auth to cancel.`);
|
|
1192
|
+
await send("Codex device login started. I’ll send the URL/code if the CLI prints one.");
|
|
1193
|
+
const proc = spawn(resolvedCodexPath, ["login", "--device-auth"], {
|
|
1194
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
1195
|
+
env: botSubprocessEnv(),
|
|
1196
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1197
|
+
});
|
|
1198
|
+
state.pendingCodexAuthProcess = proc;
|
|
1199
|
+
state.pendingCodexAuthLabel = "Codex device login";
|
|
1200
|
+
let output = "";
|
|
1201
|
+
let sent = new Set();
|
|
1202
|
+
let lastSnippetAt = 0;
|
|
1203
|
+
const handleChunk = async (chunk) => {
|
|
1204
|
+
output += chunk;
|
|
1205
|
+
const cleanChunk = redactSensitive(stripTerminalControls(chunk));
|
|
1206
|
+
for (const url of extractUrls(cleanChunk)) {
|
|
1207
|
+
if (!sent.has(url)) {
|
|
1208
|
+
sent.add(url);
|
|
1209
|
+
await send(`Codex login URL:\n${redactSensitive(url)}\n\nOpen it and enter the device code if shown.`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const codeMatch = cleanChunk.match(/(?:code|device code)[:\s]+([A-Z0-9-]{6,})/i);
|
|
1213
|
+
if (codeMatch && !sent.has(codeMatch[1])) {
|
|
1214
|
+
sent.add(codeMatch[1]);
|
|
1215
|
+
await send(`Codex device code: ${codeMatch[1]}`);
|
|
1216
|
+
}
|
|
1217
|
+
const now = Date.now();
|
|
1218
|
+
if (cleanChunk.trim() && /device|code|login|browser|open|auth|token|error|failed|api key/i.test(cleanChunk) && now - lastSnippetAt > 3000) {
|
|
1219
|
+
lastSnippetAt = now;
|
|
1220
|
+
await send(cleanChunk.trim().slice(-1200));
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth output error:", e.message)));
|
|
1224
|
+
proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth stderr error:", e.message)));
|
|
1225
|
+
proc.on("close", async (code) => {
|
|
1226
|
+
state.pendingCodexAuthProcess = null;
|
|
1227
|
+
state.pendingCodexAuthLabel = null;
|
|
1228
|
+
const clean = redactSensitive(stripTerminalControls(output)).trim();
|
|
1229
|
+
if (/raw mode is not supported|not a tty|inappropriate ioctl/i.test(clean)) {
|
|
1230
|
+
await send("Codex device login could not complete inside Telegram on this host. Use /codex_setup_token to paste an OpenAI API key securely, or run `codex login --device-auth` in an SSH/terminal session.");
|
|
1231
|
+
} else if (clean) await send(`Codex login finished (exit ${code}).\n\n${clean.slice(-2000)}`);
|
|
1232
|
+
else await send(`Codex login finished (exit ${code}).`);
|
|
1233
|
+
await sendCodexAuthStatusSummary("Post-Codex-auth check:");
|
|
1234
|
+
});
|
|
1235
|
+
proc.on("error", async (err) => {
|
|
1236
|
+
state.pendingCodexAuthProcess = null;
|
|
1237
|
+
state.pendingCodexAuthLabel = null;
|
|
1238
|
+
await send(`Codex login failed: ${redactSensitive(err.message)}`);
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
901
1242
|
// ── Claude Auth Helpers ─────────────────────────────────────────────
|
|
902
1243
|
|
|
903
1244
|
const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
@@ -906,9 +1247,12 @@ const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
|
|
|
906
1247
|
function redactSensitive(value) {
|
|
907
1248
|
return String(value || "")
|
|
908
1249
|
.replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
|
|
1250
|
+
.replace(/sk-proj-[A-Za-z0-9._-]+/g, "[REDACTED_OPENAI_KEY]")
|
|
1251
|
+
.replace(/sk-[A-Za-z0-9._-]{20,}/g, "[REDACTED_OPENAI_KEY]")
|
|
909
1252
|
.replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
|
|
910
1253
|
.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
|
|
911
|
-
.replace(/(
|
|
1254
|
+
.replace(/(OPENAI_API_KEY\s*=\s*)\S+/gi, "$1[REDACTED_OPENAI_KEY]")
|
|
1255
|
+
.replace(/([?&](?:token|access_token|refresh_token|api_key)=)[^\s&]+/gi, "$1[REDACTED]");
|
|
912
1256
|
}
|
|
913
1257
|
|
|
914
1258
|
|
|
@@ -968,8 +1312,12 @@ function getClaudeOAuthToken() {
|
|
|
968
1312
|
return { value: null, source: null };
|
|
969
1313
|
}
|
|
970
1314
|
|
|
1315
|
+
function botSubprocessEnv() {
|
|
1316
|
+
return { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
971
1319
|
function claudeSubprocessEnv() {
|
|
972
|
-
const env =
|
|
1320
|
+
const env = botSubprocessEnv();
|
|
973
1321
|
const token = getClaudeOAuthToken().value;
|
|
974
1322
|
if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
975
1323
|
return env;
|
|
@@ -1246,7 +1594,8 @@ function buildClaudeArgs(prompt, opts = {}) {
|
|
|
1246
1594
|
if (settings.backend === "codex") return buildCodexArgs(prompt, opts);
|
|
1247
1595
|
const args = ["-p", "--verbose", "--output-format", "stream-json",
|
|
1248
1596
|
"--append-system-prompt", buildSystemPrompt()];
|
|
1249
|
-
if (opts.
|
|
1597
|
+
if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
|
|
1598
|
+
else if (opts.continueSession) args.push("--continue");
|
|
1250
1599
|
else if (state.lastSessionId && !opts.fresh) args.push("--resume", state.lastSessionId);
|
|
1251
1600
|
if (settings.model) args.push("--model", settings.model);
|
|
1252
1601
|
if (settings.effort) args.push("--effort", settings.effort);
|
|
@@ -1262,7 +1611,8 @@ function buildCursorArgs(prompt, opts = {}) {
|
|
|
1262
1611
|
const state = currentState();
|
|
1263
1612
|
const { settings } = state;
|
|
1264
1613
|
const args = ["--print", "--trust", "--output-format", "stream-json"];
|
|
1265
|
-
if (opts.
|
|
1614
|
+
if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
|
|
1615
|
+
else if (opts.continueSession) args.push("--continue");
|
|
1266
1616
|
else if (state.cursorSessionId && !opts.fresh) args.push("--resume", state.cursorSessionId);
|
|
1267
1617
|
if (settings.model) args.push("--model", settings.model);
|
|
1268
1618
|
if (settings.permissionMode === "plan") args.push("--mode", "plan");
|
|
@@ -1279,7 +1629,7 @@ function buildCodexArgs(prompt, opts = {}) {
|
|
|
1279
1629
|
const state = currentState();
|
|
1280
1630
|
const { settings, codexSessionId } = state;
|
|
1281
1631
|
const args = [];
|
|
1282
|
-
const resumeId = (!opts.fresh && !opts.continueSession) ? codexSessionId : null;
|
|
1632
|
+
const resumeId = opts.resumeSessionId || ((!opts.fresh && !opts.continueSession) ? codexSessionId : null);
|
|
1283
1633
|
if (opts.continueSession && codexSessionId) {
|
|
1284
1634
|
args.push("exec", "resume", codexSessionId);
|
|
1285
1635
|
} else if (resumeId) {
|
|
@@ -1312,13 +1662,172 @@ function getActiveSessionId() {
|
|
|
1312
1662
|
return state.lastSessionId;
|
|
1313
1663
|
}
|
|
1314
1664
|
|
|
1665
|
+
function getActiveSessionKey(state = currentState()) {
|
|
1666
|
+
if (state.settings.backend === "cursor") return "cursorSessionId";
|
|
1667
|
+
if (state.settings.backend === "codex") return "codexSessionId";
|
|
1668
|
+
return "lastSessionId";
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function shouldAutoCompact(state = currentState(), opts = {}) {
|
|
1672
|
+
if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
|
|
1673
|
+
if (!state[getActiveSessionKey(state)]) return false;
|
|
1674
|
+
const threshold = Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 140000;
|
|
1675
|
+
return (state.sessionUsage?.lastInputTokens || 0) >= threshold;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function compactSummaryPrompt() {
|
|
1679
|
+
return [
|
|
1680
|
+
"Summarize this conversation for a fresh compacted continuation.",
|
|
1681
|
+
"Include only durable context needed to continue effectively:",
|
|
1682
|
+
"- current user goal and constraints",
|
|
1683
|
+
"- important decisions and preferences",
|
|
1684
|
+
"- files/repos touched and current code state",
|
|
1685
|
+
"- commands/tests already run and results",
|
|
1686
|
+
"- open TODOs, blockers, and exact next step",
|
|
1687
|
+
"Do not include secrets, raw tokens, or irrelevant chat transcript."
|
|
1688
|
+
].join("\n");
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function compactSeedPrompt(summary) {
|
|
1692
|
+
return [
|
|
1693
|
+
"This is a compacted continuation of a previous Open Claudia session.",
|
|
1694
|
+
"Treat the following summary as the prior conversation context. Do not repeat it back unless asked.",
|
|
1695
|
+
"Continue from this state in future turns.",
|
|
1696
|
+
"",
|
|
1697
|
+
"Compacted summary:",
|
|
1698
|
+
summary
|
|
1699
|
+
].join("\n");
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async function runClaudeCapture(prompt, cwd, opts = {}) {
|
|
1703
|
+
const state = currentState();
|
|
1704
|
+
const chatId = currentChatId();
|
|
1705
|
+
if (state.runningProcess) throw new Error("Another task is already running.");
|
|
1706
|
+
const authPreflight = preflightClaudeAuthMessage();
|
|
1707
|
+
if (authPreflight) throw new Error(authPreflight);
|
|
1708
|
+
|
|
1709
|
+
return new Promise((resolve, reject) => {
|
|
1710
|
+
let assistantText = "";
|
|
1711
|
+
let stderrBuffer = "";
|
|
1712
|
+
let streamBuffer = "";
|
|
1713
|
+
let sessionId = null;
|
|
1714
|
+
const args = buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
|
|
1715
|
+
const proc = spawn(getActiveBinary(), args, {
|
|
1716
|
+
cwd,
|
|
1717
|
+
env: claudeSubprocessEnv(),
|
|
1718
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1719
|
+
detached: process.platform !== "win32",
|
|
1720
|
+
});
|
|
1721
|
+
state.runningProcess = proc;
|
|
1722
|
+
const timeout = setTimeout(() => {
|
|
1723
|
+
if (state.runningProcess === proc) {
|
|
1724
|
+
killProcessTree(proc.pid, "SIGTERM");
|
|
1725
|
+
setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
|
|
1726
|
+
}
|
|
1727
|
+
}, MAX_PROCESS_TIMEOUT);
|
|
1728
|
+
|
|
1729
|
+
proc.stdout.on("data", (data) => {
|
|
1730
|
+
streamBuffer += data.toString();
|
|
1731
|
+
const events = parseStreamEvents(streamBuffer);
|
|
1732
|
+
const lastNewline = streamBuffer.lastIndexOf("\n");
|
|
1733
|
+
streamBuffer = lastNewline >= 0 ? streamBuffer.slice(lastNewline + 1) : streamBuffer;
|
|
1734
|
+
for (const evt of events) {
|
|
1735
|
+
if (evt.type === "assistant" && evt.message?.content) {
|
|
1736
|
+
for (const block of evt.message.content) {
|
|
1737
|
+
if (block.type === "text") assistantText += block.text;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
if (evt.type === "item.completed" && evt.item?.type === "agent_message" && typeof evt.item.text === "string") {
|
|
1741
|
+
assistantText += (assistantText ? "\n" : "") + evt.item.text;
|
|
1742
|
+
}
|
|
1743
|
+
if (evt.type === "thread.started" && evt.thread_id) {
|
|
1744
|
+
state.codexSessionId = evt.thread_id;
|
|
1745
|
+
sessionId = evt.thread_id;
|
|
1746
|
+
saveState();
|
|
1747
|
+
}
|
|
1748
|
+
if (evt.type === "result" && evt.session_id) {
|
|
1749
|
+
if (state.settings.backend === "cursor") state.cursorSessionId = evt.session_id;
|
|
1750
|
+
else state.lastSessionId = evt.session_id;
|
|
1751
|
+
sessionId = evt.session_id;
|
|
1752
|
+
if (evt.usage) {
|
|
1753
|
+
const u = state.sessionUsage;
|
|
1754
|
+
u.turns += 1;
|
|
1755
|
+
u.inputTokens += evt.usage.input_tokens || 0;
|
|
1756
|
+
u.outputTokens += evt.usage.output_tokens || 0;
|
|
1757
|
+
u.cacheReadTokens += evt.usage.cache_read_input_tokens || 0;
|
|
1758
|
+
u.cacheCreationTokens += evt.usage.cache_creation_input_tokens || 0;
|
|
1759
|
+
u.lastInputTokens = (evt.usage.input_tokens || 0) +
|
|
1760
|
+
(evt.usage.cache_read_input_tokens || 0) +
|
|
1761
|
+
(evt.usage.cache_creation_input_tokens || 0);
|
|
1762
|
+
}
|
|
1763
|
+
if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
|
|
1764
|
+
saveState();
|
|
1765
|
+
}
|
|
1766
|
+
if (evt.type === "result" && evt.result) assistantText = evt.result;
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
proc.stderr.on("data", (d) => { stderrBuffer += d.toString(); });
|
|
1770
|
+
proc.on("close", (code) => chatContext.run(chatId, () => {
|
|
1771
|
+
if (state.runningProcess === proc) state.runningProcess = null;
|
|
1772
|
+
clearTimeout(timeout);
|
|
1773
|
+
if (code !== 0 && code !== null) {
|
|
1774
|
+
reject(new Error(claudeEmptyFailureMessage(code, stderrBuffer)));
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
resolve({ text: redactSensitive(assistantText.trim()), sessionId });
|
|
1778
|
+
}));
|
|
1779
|
+
proc.on("error", (err) => chatContext.run(chatId, () => {
|
|
1780
|
+
if (state.runningProcess === proc) state.runningProcess = null;
|
|
1781
|
+
clearTimeout(timeout);
|
|
1782
|
+
reject(err);
|
|
1783
|
+
}));
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
async function compactActiveSession(cwd, opts = {}) {
|
|
1788
|
+
const state = currentState();
|
|
1789
|
+
const sessionKey = getActiveSessionKey(state);
|
|
1790
|
+
const oldSessionId = state[sessionKey];
|
|
1791
|
+
if (!oldSessionId) return { compacted: false, reason: "No conversation." };
|
|
1792
|
+
if (state.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
|
|
1793
|
+
|
|
1794
|
+
state.isCompacting = true;
|
|
1795
|
+
try {
|
|
1796
|
+
if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
|
|
1797
|
+
const summaryRun = await runClaudeCapture(compactSummaryPrompt(), cwd, { resumeSessionId: oldSessionId, skipAutoCompact: true });
|
|
1798
|
+
const summary = summaryRun.text || "No prior context was returned by the summarizer.";
|
|
1799
|
+
|
|
1800
|
+
state[sessionKey] = null;
|
|
1801
|
+
resetSessionUsage(state);
|
|
1802
|
+
state.isFirstMessage = true;
|
|
1803
|
+
saveState();
|
|
1804
|
+
|
|
1805
|
+
const seedRun = await runClaudeCapture(compactSeedPrompt(summary), cwd, { fresh: true, skipAutoCompact: true });
|
|
1806
|
+
const newSessionId = seedRun.sessionId || state[sessionKey];
|
|
1807
|
+
if (newSessionId) state[sessionKey] = newSessionId;
|
|
1808
|
+
state.isFirstMessage = false;
|
|
1809
|
+
state.lastCompactedAt = Date.now();
|
|
1810
|
+
resetSessionUsage(state);
|
|
1811
|
+
saveState();
|
|
1812
|
+
|
|
1813
|
+
if (newSessionId && state.currentSession) {
|
|
1814
|
+
const title = `Compacted ${new Date().toLocaleDateString()}`;
|
|
1815
|
+
recordSession(state.userId, state.currentSession.name, newSessionId, title);
|
|
1816
|
+
}
|
|
1817
|
+
return { compacted: true, oldSessionId, newSessionId, summary };
|
|
1818
|
+
} finally {
|
|
1819
|
+
state.isCompacting = false;
|
|
1820
|
+
saveState();
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1315
1824
|
async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
1316
1825
|
// Capture per-user state at entry so event callbacks (stdout/stderr/close)
|
|
1317
1826
|
// operate on the right user's thread even though AsyncLocalStorage
|
|
1318
1827
|
// propagation through child_process events isn't guaranteed across all
|
|
1319
1828
|
// Node versions.
|
|
1320
1829
|
const state = currentState();
|
|
1321
|
-
const chatId =
|
|
1830
|
+
const chatId = currentChatId();
|
|
1322
1831
|
const { settings } = state;
|
|
1323
1832
|
|
|
1324
1833
|
if (state.runningProcess) {
|
|
@@ -1333,6 +1842,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1333
1842
|
return;
|
|
1334
1843
|
}
|
|
1335
1844
|
|
|
1845
|
+
if (shouldAutoCompact(state, opts)) {
|
|
1846
|
+
try {
|
|
1847
|
+
await compactActiveSession(cwd, {
|
|
1848
|
+
notify: true,
|
|
1849
|
+
message: "Context is getting large, compacting first so this stays fast…",
|
|
1850
|
+
});
|
|
1851
|
+
} catch (e) {
|
|
1852
|
+
await send(`Compaction failed: ${redactSensitive(e.message)}\nContinuing in the existing session.`, { replyTo: replyToMsgId });
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1336
1856
|
bot.sendChatAction(chatId, "typing");
|
|
1337
1857
|
state.statusMessageId = null;
|
|
1338
1858
|
state.streamBuffer = "";
|
|
@@ -1545,7 +2065,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1545
2065
|
return;
|
|
1546
2066
|
}
|
|
1547
2067
|
if (settings.backend === "codex" && /not (?:logged in|authenticated)|please (?:log|sign) in|401 unauthorized|invalid api key/i.test(stderrBuffer)) {
|
|
1548
|
-
await send("Codex authentication error.
|
|
2068
|
+
await send("Codex authentication error. Try /codex_auth_status, then /codex_login or /codex_setup_token.", { replyTo: replyToMsgId });
|
|
1549
2069
|
return;
|
|
1550
2070
|
}
|
|
1551
2071
|
|
|
@@ -1582,14 +2102,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1582
2102
|
if (voicePath) await sendVoice(voicePath);
|
|
1583
2103
|
}
|
|
1584
2104
|
|
|
1585
|
-
//
|
|
1586
|
-
const ctx = state.sessionUsage.lastInputTokens;
|
|
1587
|
-
if (ctx > 150000 && !state.sessionUsage.warnedHighContext) {
|
|
1588
|
-
state.sessionUsage.warnedHighContext = true;
|
|
1589
|
-
await send(`Heads up: context is ${(ctx / 1000).toFixed(0)}k tokens. Use /compact to summarize or /end for a fresh start — keeps cost down.`);
|
|
1590
|
-
} else if (ctx < 80000 && state.sessionUsage.warnedHighContext) {
|
|
1591
|
-
state.sessionUsage.warnedHighContext = false;
|
|
1592
|
-
}
|
|
2105
|
+
// High-context sessions are compacted automatically before the next turn.
|
|
1593
2106
|
} catch (e) {
|
|
1594
2107
|
console.error("Final message delivery failed:", e.message);
|
|
1595
2108
|
await send("Task completed but failed to deliver the response. Send /continue to see the result.");
|
|
@@ -1600,7 +2113,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1600
2113
|
// Record session with auto-title from first message
|
|
1601
2114
|
if (state.lastSessionId && state.currentSession) {
|
|
1602
2115
|
const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
|
|
1603
|
-
recordSession(
|
|
2116
|
+
recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
|
|
1604
2117
|
state.isFirstMessage = false;
|
|
1605
2118
|
}
|
|
1606
2119
|
if (state.messageQueue.length > 0 && state.currentSession) {
|
|
@@ -1690,14 +2203,14 @@ function startSession(name, resumeSessionId) {
|
|
|
1690
2203
|
// Resume a specific session or the last one for this project
|
|
1691
2204
|
if (resumeSessionId) {
|
|
1692
2205
|
state.lastSessionId = resumeSessionId;
|
|
1693
|
-
const sessions = getProjectSessions(state.
|
|
2206
|
+
const sessions = getProjectSessions(state.userId, projectName);
|
|
1694
2207
|
const s = sessions.find((x) => x.id === resumeSessionId);
|
|
1695
2208
|
const title = s ? s.title : "";
|
|
1696
2209
|
state.isFirstMessage = false;
|
|
1697
2210
|
saveState();
|
|
1698
2211
|
send(`Session: ${projectName}\nResumed: ${title || resumeSessionId.slice(0, 8)}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
1699
2212
|
} else {
|
|
1700
|
-
const last = getLastProjectSession(state.
|
|
2213
|
+
const last = getLastProjectSession(state.userId, projectName);
|
|
1701
2214
|
if (last) {
|
|
1702
2215
|
state.lastSessionId = last.id;
|
|
1703
2216
|
state.isFirstMessage = false;
|
|
@@ -1751,15 +2264,71 @@ bot.onText(/\/help/, wrapHandler((msg) => {
|
|
|
1751
2264
|
send([
|
|
1752
2265
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
1753
2266
|
"Settings: /model /effort /budget /plan /compact /worktree /mode",
|
|
2267
|
+
"Identity: /whoami /link",
|
|
1754
2268
|
"Automation: /cron /vault /soul",
|
|
1755
2269
|
"Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
|
|
1756
|
-
"
|
|
2270
|
+
"Codex auth: /codex_auth_status /codex_login /codex_setup_token",
|
|
2271
|
+
"System: /doctor /requirements /restart /upgrade",
|
|
1757
2272
|
"",
|
|
1758
2273
|
"Send text, voice, photos, or files.",
|
|
1759
2274
|
"Reply to any message for context.",
|
|
1760
2275
|
].join("\n"));
|
|
1761
2276
|
}));
|
|
1762
2277
|
|
|
2278
|
+
bot.onText(/\/whoami$/, wrapHandler((msg) => {
|
|
2279
|
+
if (!isAuthorized(msg)) return;
|
|
2280
|
+
const chatId = String(msg.chat.id);
|
|
2281
|
+
const key = channelKey("telegram", chatId);
|
|
2282
|
+
const userId = canonicalForTelegram(chatId);
|
|
2283
|
+
const preferred = identities.preferred[userId];
|
|
2284
|
+
send([
|
|
2285
|
+
`Channel: ${key}`,
|
|
2286
|
+
`User: ${userId}`,
|
|
2287
|
+
preferred ? `Preferred: ${preferred.transport}:${preferred.channelId}` : "Preferred: this channel",
|
|
2288
|
+
].join("\n"));
|
|
2289
|
+
}));
|
|
2290
|
+
|
|
2291
|
+
bot.onText(/\/links$/, wrapHandler((msg) => {
|
|
2292
|
+
if (!isOwner(msg)) return;
|
|
2293
|
+
const rows = Object.entries(identities.channels)
|
|
2294
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2295
|
+
.map(([channel, userId]) => `${channel} -> ${userId}`);
|
|
2296
|
+
send(rows.length ? rows.join("\n") : "No explicit identity links. Unlinked Telegram chats use telegram:<chatId>.");
|
|
2297
|
+
}));
|
|
2298
|
+
|
|
2299
|
+
bot.onText(/\/link$/, wrapHandler((msg) => {
|
|
2300
|
+
if (!isAuthorized(msg)) return;
|
|
2301
|
+
send(isOwner(msg)
|
|
2302
|
+
? "Usage:\n/link <email-or-user-id>\n/link <telegram-chat-id> <email-or-user-id>\n/link telegram:<chat-id> <email-or-user-id>\n\nOwner can link any Telegram chat; other users can link only their current chat."
|
|
2303
|
+
: "Usage:\n/link <email-or-user-id>\n\nThis links your Telegram chat to a canonical user id.");
|
|
2304
|
+
}));
|
|
2305
|
+
|
|
2306
|
+
bot.onText(/\/link\s+(.+)$/, wrapHandler((msg, match) => {
|
|
2307
|
+
if (!isAuthorized(msg)) return;
|
|
2308
|
+
const parts = String(match[1] || "").trim().split(/\s+/).filter(Boolean);
|
|
2309
|
+
if (parts.length === 0 || parts.length > 2) return send("Usage: /link <email-or-user-id>");
|
|
2310
|
+
|
|
2311
|
+
let targetChatId = String(msg.chat.id);
|
|
2312
|
+
let userId = parts[0];
|
|
2313
|
+
if (parts.length === 2) {
|
|
2314
|
+
if (!isOwner(msg)) return send("Only the owner can link another chat.");
|
|
2315
|
+
const channel = parts[0];
|
|
2316
|
+
if (channel.startsWith("telegram:")) targetChatId = channel.slice("telegram:".length);
|
|
2317
|
+
else targetChatId = channel;
|
|
2318
|
+
userId = parts[1];
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
if (!/^-?\d+$/.test(targetChatId)) return send("Telegram chat id must be numeric.");
|
|
2322
|
+
const normalizedUserId = normalizeCanonicalUserId(userId);
|
|
2323
|
+
if (!normalizedUserId || /\s/.test(normalizedUserId)) return send("Canonical user id cannot be empty or contain spaces.");
|
|
2324
|
+
|
|
2325
|
+
const result = setIdentityMapping("telegram", targetChatId, normalizedUserId);
|
|
2326
|
+
send([
|
|
2327
|
+
`Linked ${result.key} -> ${result.userId}`,
|
|
2328
|
+
result.migrated ? `Migrated state from ${result.previousUserId}.` : "Existing canonical state was left in place.",
|
|
2329
|
+
].join("\n"));
|
|
2330
|
+
}));
|
|
2331
|
+
|
|
1763
2332
|
bot.onText(/\/version$/, wrapHandler((msg) => {
|
|
1764
2333
|
if (!isAuthorized(msg)) return;
|
|
1765
2334
|
send(`Open Claudia v${CURRENT_VERSION}`);
|
|
@@ -1822,8 +2391,9 @@ bot.onText(/\/upgrade$/, wrapHandler(async (msg) => {
|
|
|
1822
2391
|
whatsNew = section.trim();
|
|
1823
2392
|
}
|
|
1824
2393
|
} catch (e) { /* no changelog */ }
|
|
1825
|
-
const
|
|
1826
|
-
|
|
2394
|
+
const doctorReport = formatDoctorReport(runDoctorChecks());
|
|
2395
|
+
const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
|
|
2396
|
+
await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
1827
2397
|
} catch (e) {
|
|
1828
2398
|
const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
|
|
1829
2399
|
await send(`Upgrade failed:\n${errOutput}`);
|
|
@@ -1846,7 +2416,7 @@ bot.onText(/\/sessions$/, wrapHandler((msg) => {
|
|
|
1846
2416
|
if (!isAuthorized(msg)) return;
|
|
1847
2417
|
if (!requireSession(msg)) return;
|
|
1848
2418
|
const state = currentState();
|
|
1849
|
-
const sessions = getProjectSessions(state.
|
|
2419
|
+
const sessions = getProjectSessions(state.userId, state.currentSession.name);
|
|
1850
2420
|
if (sessions.length === 0) return send("No past conversations for this project.");
|
|
1851
2421
|
const rows = sessions.slice(0, 10).map((s) => {
|
|
1852
2422
|
const date = new Date(s.lastUsed).toLocaleDateString();
|
|
@@ -1966,8 +2536,27 @@ bot.onText(/\/ask$/, wrapHandler((msg) => {
|
|
|
1966
2536
|
settings.permissionMode = a ? null : "ask";
|
|
1967
2537
|
send(a ? "Ask mode off." : "Ask mode on (read-only Q&A, no edits).");
|
|
1968
2538
|
}));
|
|
1969
|
-
bot.onText(/\/compact/, wrapHandler(async (msg) => {
|
|
1970
|
-
|
|
2539
|
+
bot.onText(/\/compact/, wrapHandler(async (msg) => {
|
|
2540
|
+
if (!isAuthorized(msg)) return;
|
|
2541
|
+
if (!requireSession(msg)) return;
|
|
2542
|
+
if (!getActiveSessionId()) return send("No conversation.");
|
|
2543
|
+
try {
|
|
2544
|
+
const result = await compactActiveSession(currentState().currentSession.dir, {
|
|
2545
|
+
notify: true,
|
|
2546
|
+
message: "Compacting this conversation into a fresh session…",
|
|
2547
|
+
});
|
|
2548
|
+
if (result.compacted) await send(`Compacted into a fresh session${result.newSessionId ? ` (${result.newSessionId.slice(0, 8)}…)` : ""}. Continue normally.`, { replyTo: msg.message_id });
|
|
2549
|
+
else await send(result.reason || "Could not compact.", { replyTo: msg.message_id });
|
|
2550
|
+
} catch (e) {
|
|
2551
|
+
await send(`Compaction failed: ${redactSensitive(e.message)}`, { replyTo: msg.message_id });
|
|
2552
|
+
}
|
|
2553
|
+
}));
|
|
2554
|
+
bot.onText(/\/continue$/, wrapHandler(async (msg) => {
|
|
2555
|
+
if (!isAuthorized(msg)) return;
|
|
2556
|
+
if (!requireSession(msg)) return;
|
|
2557
|
+
if (!getActiveSessionId()) return send("No conversation to continue.");
|
|
2558
|
+
await runClaude("continue where we left off", currentState().currentSession.dir, msg.message_id);
|
|
2559
|
+
}));
|
|
1971
2560
|
bot.onText(/\/worktree$/, wrapHandler((msg) => { if (!isAuthorized(msg)) return; const { settings } = currentState(); settings.worktree = !settings.worktree; send(settings.worktree ? "Worktree on." : "Worktree off."); }));
|
|
1972
2561
|
|
|
1973
2562
|
bot.onText(/\/cursor$/, wrapHandler(async (msg) => {
|
|
@@ -2041,6 +2630,11 @@ bot.onText(/\/stop/, wrapHandler(async (msg) => {
|
|
|
2041
2630
|
else await send("Nothing running.");
|
|
2042
2631
|
}));
|
|
2043
2632
|
|
|
2633
|
+
bot.onText(/\/(?:doctor|requirements)$/, wrapHandler(async (msg) => {
|
|
2634
|
+
if (!isAuthorized(msg)) return;
|
|
2635
|
+
await send(formatDoctorReport(runDoctorChecks()));
|
|
2636
|
+
}));
|
|
2637
|
+
|
|
2044
2638
|
bot.onText(/\/status/, wrapHandler((msg) => {
|
|
2045
2639
|
if (!isAuthorized(msg)) return;
|
|
2046
2640
|
const state = currentState();
|
|
@@ -2083,7 +2677,7 @@ bot.onText(/\/usage$/, wrapHandler((msg) => {
|
|
|
2083
2677
|
`Cost: $${u.costUsd.toFixed(4)}`,
|
|
2084
2678
|
`Last turn context: ${fmt(u.lastInputTokens)}`,
|
|
2085
2679
|
];
|
|
2086
|
-
if (u.lastInputTokens > 100000) lines.push(
|
|
2680
|
+
if (u.lastInputTokens > 100000) lines.push(`\nTip: context is large. The bot auto-compacts before the next turn at ${fmt(AUTO_COMPACT_TOKENS)} tokens; /compact does it now.`);
|
|
2087
2681
|
send(lines.join("\n"), { parseMode: "Markdown" });
|
|
2088
2682
|
}));
|
|
2089
2683
|
|
|
@@ -2187,6 +2781,42 @@ bot.onText(/\/clear_oauth_token$/, wrapHandler(async (msg) => {
|
|
|
2187
2781
|
await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
|
|
2188
2782
|
}));
|
|
2189
2783
|
|
|
2784
|
+
bot.onText(/\/(?:codex_auth_status|codex auth status)$/, wrapHandler(async (msg) => {
|
|
2785
|
+
if (!isAuthorized(msg)) return;
|
|
2786
|
+
await sendCodexAuthStatusSummary();
|
|
2787
|
+
}));
|
|
2788
|
+
|
|
2789
|
+
bot.onText(/\/codex_login$/, wrapHandler(async (msg) => {
|
|
2790
|
+
if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
|
|
2791
|
+
await runCodexDeviceLogin();
|
|
2792
|
+
}));
|
|
2793
|
+
|
|
2794
|
+
bot.onText(/\/cancel_codex_auth$/, wrapHandler(async (msg) => {
|
|
2795
|
+
if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
|
|
2796
|
+
const state = currentState();
|
|
2797
|
+
if (!state.pendingCodexAuthProcess) return send("No Codex auth flow is pending.");
|
|
2798
|
+
clearPendingCodexAuth(state);
|
|
2799
|
+
await send("Codex auth flow cancelled. Normal messages will go to the assistant again.");
|
|
2800
|
+
}));
|
|
2801
|
+
|
|
2802
|
+
bot.onText(/\/(?:codex_setup_token|codex_use_api_key)(?:\s+(.+))?$/, wrapHandler(async (msg, match) => {
|
|
2803
|
+
if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
|
|
2804
|
+
if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
|
|
2805
|
+
const key = (match[1] || "").trim();
|
|
2806
|
+
await deleteMessage(msg.message_id);
|
|
2807
|
+
if (!key) {
|
|
2808
|
+
const state = currentState();
|
|
2809
|
+
state.pendingCodexAuthProcess = { kill: () => {} };
|
|
2810
|
+
state.pendingCodexAuthLabel = "manual OpenAI API key save";
|
|
2811
|
+
await send("Send the OpenAI API key in your next message. I’ll delete it and pass it to `codex login --with-api-key` without echoing it.");
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
if (!looksLikeOpenAIKey(key)) return send("That does not look like an OpenAI API key. Not saved.");
|
|
2815
|
+
const result = await saveCodexApiKeyWithCli(key);
|
|
2816
|
+
await send(result.ok ? "Codex API key stored by the Codex CLI. I did not print it." : `Codex CLI could not store the API key: ${redactSensitive(result.output).slice(-800)}`);
|
|
2817
|
+
await sendCodexAuthStatusSummary("Current Codex auth status:");
|
|
2818
|
+
}));
|
|
2819
|
+
|
|
2190
2820
|
// ── /vault with password protection ─────────────────────────────────
|
|
2191
2821
|
// Vault is a single shared store. Lock state is global; the
|
|
2192
2822
|
// password-unlock flow is per-user (pendingVaultUnlock lives on the
|
|
@@ -2308,7 +2938,7 @@ bot.on("callback_query", wrapHandler(async (q) => {
|
|
|
2308
2938
|
await send(`Session: ${state.currentSession.name}\nNew conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
2309
2939
|
return;
|
|
2310
2940
|
}
|
|
2311
|
-
if (d === "a:continue") { if (state.currentSession) await runClaude("continue", state.currentSession.dir); else send("No session."); return; }
|
|
2941
|
+
if (d === "a:continue") { if (state.currentSession && getActiveSessionId()) await runClaude("continue", state.currentSession.dir); else send("No session to continue."); return; }
|
|
2312
2942
|
if (d === "a:end") {
|
|
2313
2943
|
if (state.currentSession) {
|
|
2314
2944
|
const n = state.currentSession.name;
|
|
@@ -2399,7 +3029,7 @@ bot.on("voice", wrapHandler(async (msg) => {
|
|
|
2399
3029
|
if (msg.voice.file_size && msg.voice.file_size > MAX_VOICE_SIZE) {
|
|
2400
3030
|
return send(`Voice note too large (${Math.round(msg.voice.file_size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
|
|
2401
3031
|
}
|
|
2402
|
-
bot.sendChatAction(
|
|
3032
|
+
bot.sendChatAction(currentChatId(), "typing");
|
|
2403
3033
|
const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
|
|
2404
3034
|
const transcript = transcribeAudio(oggPath);
|
|
2405
3035
|
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
@@ -2416,7 +3046,7 @@ bot.on("audio", wrapHandler(async (msg) => {
|
|
|
2416
3046
|
if (!requireSession(msg)) return;
|
|
2417
3047
|
const state = currentState();
|
|
2418
3048
|
try {
|
|
2419
|
-
bot.sendChatAction(
|
|
3049
|
+
bot.sendChatAction(currentChatId(), "typing");
|
|
2420
3050
|
const p = await downloadFile(msg.audio.file_id, path.extname(msg.audio.file_name || ".ogg"));
|
|
2421
3051
|
const t = transcribeAudio(p);
|
|
2422
3052
|
try { fs.unlinkSync(p); } catch (e) {}
|
|
@@ -2480,6 +3110,27 @@ bot.on("message", wrapHandler(async (msg) => {
|
|
|
2480
3110
|
if (isDuplicate(msg)) return;
|
|
2481
3111
|
const state = currentState();
|
|
2482
3112
|
|
|
3113
|
+
// Handle pending manual Codex API key paste mode.
|
|
3114
|
+
if (state.pendingCodexAuthProcess && state.pendingCodexAuthLabel === "manual OpenAI API key save") {
|
|
3115
|
+
const text = msg.text.trim();
|
|
3116
|
+
await deleteMessage(msg.message_id);
|
|
3117
|
+
if (!looksLikeOpenAIKey(text)) {
|
|
3118
|
+
clearPendingCodexAuth(state);
|
|
3119
|
+
await send("That did not look like an OpenAI API key. Not saved.");
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
clearPendingCodexAuth(state);
|
|
3123
|
+
const result = await saveCodexApiKeyWithCli(text);
|
|
3124
|
+
await send(result.ok ? "Codex API key stored by the Codex CLI. I did not print it." : `Codex CLI could not store the API key: ${redactSensitive(result.output).slice(-800)}`);
|
|
3125
|
+
await sendCodexAuthStatusSummary("Current Codex auth status:");
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
if (state.pendingCodexAuthProcess) {
|
|
3130
|
+
await send("Codex login is still running. Complete the device flow in your browser, or send /cancel_codex_auth.");
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
2483
3134
|
// Handle pending manual OAuth token paste mode. Login codes must be sent explicitly
|
|
2484
3135
|
// with /auth_code so normal chat can never be deleted/consumed accidentally.
|
|
2485
3136
|
if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel === "manual OAuth token save") {
|
|
@@ -2563,7 +3214,8 @@ bot.on("message", wrapHandler(async (msg) => {
|
|
|
2563
3214
|
|
|
2564
3215
|
let prompt = msg.text;
|
|
2565
3216
|
const reply = msg.reply_to_message;
|
|
2566
|
-
|
|
3217
|
+
const skipReplyContext = reply?.from?.is_bot && !reply.document && !reply.photo;
|
|
3218
|
+
if (reply && !skipReplyContext) {
|
|
2567
3219
|
let ctx = "";
|
|
2568
3220
|
if (reply.text) {
|
|
2569
3221
|
ctx = reply.text;
|