@inetafrica/open-claudia 1.18.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +39 -0
  3. package/bot.js +530 -40
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## v1.18.0
4
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
5
14
  - `/compact` now creates a fresh compacted continuation session instead of only adding another summary turn to the existing session
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
@@ -204,6 +204,7 @@ const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
204
204
  const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
205
205
  const VAULT_FILE = config.VAULT_FILE || path.join(CONFIG_DIR, "vault.enc");
206
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");
207
208
  const BOT_DIR = __dirname;
208
209
 
209
210
  // Detect PATH for subprocess
@@ -303,7 +304,13 @@ bot.setMyCommands([
303
304
  { command: "claude", description: "Switch to Claude Code backend" },
304
305
  { command: "codex", description: "Switch to OpenAI Codex backend" },
305
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" },
306
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" },
307
314
  { command: "login", description: "Start Claude Code login" },
308
315
  { command: "setup_token", description: "Create Claude OAuth token" },
309
316
  { command: "stop", description: "Cancel running task" },
@@ -331,19 +338,85 @@ const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
331
338
  const STATE_FILE = path.join(CONFIG_DIR, "state.json");
332
339
  const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
333
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
+
334
390
  function loadStateFile() {
335
391
  try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")); } catch (e) { return {}; }
336
392
  }
337
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
+
338
403
  // Multi-user state. v1.16 and earlier stored a single user's state at the
339
- // top level of state.json; v1.17+ stores `{ users: { "<chatId>": {...} } }`.
340
- // On first load, an old-format file is migrated to belong to CHAT_ID
341
- // (the bot owner) direct-install users see no behavior change.
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.
342
408
  const savedState = (() => {
343
409
  const raw = loadStateFile();
344
- if (raw && raw.users && typeof raw.users === "object") return raw;
345
- // Legacy single-user shape: hoist it under the owner's chat id.
346
- return { users: { [CHAT_ID]: raw || {} } };
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 || {} } };
347
420
  })();
348
421
 
349
422
  let activeCrons = new Map();
@@ -361,12 +434,13 @@ function freshUsage() {
361
434
  return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
362
435
  }
363
436
 
364
- function createUserState(chatId) {
365
- const saved = (savedState.users && savedState.users[chatId]) || {};
437
+ function createUserState(userId) {
438
+ const saved = (savedState.users && savedState.users[userId]) || {};
366
439
  const settings = saved.settings || freshSettings();
367
440
  if (!settings.backend) settings.backend = "claude";
368
441
  return {
369
- chatId: String(chatId),
442
+ userId: String(userId),
443
+ chatId: currentChatId(),
370
444
  currentSession: saved.currentSession || null,
371
445
  runningProcess: null,
372
446
  statusMessageId: null,
@@ -386,15 +460,19 @@ function createUserState(chatId) {
386
460
  pendingVaultAction: null,
387
461
  pendingClaudeAuthProcess: null,
388
462
  pendingClaudeAuthLabel: null,
463
+ pendingCodexAuthProcess: null,
464
+ pendingCodexAuthLabel: null,
389
465
  isCompacting: false,
390
466
  lastCompactedAt: saved.lastCompactedAt || 0,
391
467
  };
392
468
  }
393
469
 
394
- function getUserState(chatId) {
395
- const id = String(chatId);
470
+ function getUserState(userId) {
471
+ const id = normalizeCanonicalUserId(userId);
396
472
  if (!userStates.has(id)) userStates.set(id, createUserState(id));
397
- return userStates.get(id);
473
+ const state = userStates.get(id);
474
+ state.chatId = currentChatId();
475
+ return state;
398
476
  }
399
477
 
400
478
  // AsyncLocalStorage carries the active chat id through the async call
@@ -408,7 +486,7 @@ function currentChatId() {
408
486
  }
409
487
 
410
488
  function currentState() {
411
- return getUserState(currentChatId());
489
+ return getUserState(currentCanonicalUserId());
412
490
  }
413
491
 
414
492
  function resetSessionUsage(state = currentState()) {
@@ -420,7 +498,7 @@ function resetSettings(state = currentState()) {
420
498
  }
421
499
 
422
500
  function saveState() {
423
- const data = { users: {} };
501
+ const data = { users: { ...(savedState.users || {}) } };
424
502
  for (const [id, s] of userStates) {
425
503
  data.users[id] = {
426
504
  currentSession: s.currentSession,
@@ -432,9 +510,64 @@ function saveState() {
432
510
  lastCompactedAt: s.lastCompactedAt || 0,
433
511
  };
434
512
  }
513
+ savedState.users = data.users;
435
514
  try { fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch (e) {}
436
515
  }
437
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
+
438
571
  // ── Message deduplication ──────────────────────────────────────────
439
572
  // Telegram message_ids are unique per chat, not globally — namespace by
440
573
  // chat id so two users' messages can't collide.
@@ -452,9 +585,9 @@ function isDuplicate(msg) {
452
585
  }
453
586
 
454
587
  // ── Per-project session history ────────────────────────────────────
455
- // Sessions are stored per-chat so each user has their own conversation
456
- // history per project. Legacy single-user files are migrated under
457
- // CHAT_ID on first read.
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.
458
591
 
459
592
  function loadSessions() {
460
593
  let raw;
@@ -462,17 +595,28 @@ function loadSessions() {
462
595
  if (!raw || typeof raw !== "object") return {};
463
596
  // Legacy detection: top-level keys map directly to arrays of session objects.
464
597
  const looksLegacy = Object.values(raw).some((v) => Array.isArray(v));
465
- if (looksLegacy) return { [CHAT_ID]: raw };
466
- return raw;
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;
467
611
  }
468
612
 
469
613
  function saveSessions(sessions) {
470
614
  try { fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); } catch (e) {}
471
615
  }
472
616
 
473
- function recordSession(chatId, projectName, sessionId, title) {
617
+ function recordSession(userId, projectName, sessionId, title) {
474
618
  const all = loadSessions();
475
- const id = String(chatId);
619
+ const id = normalizeCanonicalUserId(userId);
476
620
  if (!all[id]) all[id] = {};
477
621
  if (!all[id][projectName]) all[id][projectName] = [];
478
622
  const arr = all[id][projectName];
@@ -492,13 +636,13 @@ function recordSession(chatId, projectName, sessionId, title) {
492
636
  saveSessions(all);
493
637
  }
494
638
 
495
- function getProjectSessions(chatId, projectName) {
639
+ function getProjectSessions(userId, projectName) {
496
640
  const all = loadSessions();
497
- return ((all[String(chatId)] || {})[projectName] || []).slice().reverse();
641
+ return ((all[normalizeCanonicalUserId(userId)] || {})[projectName] || []).slice().reverse();
498
642
  }
499
643
 
500
- function getLastProjectSession(chatId, projectName) {
501
- const sessions = getProjectSessions(chatId, projectName);
644
+ function getLastProjectSession(userId, projectName) {
645
+ const sessions = getProjectSessions(userId, projectName);
502
646
  return sessions.length > 0 ? sessions[0] : null;
503
647
  }
504
648
 
@@ -875,6 +1019,226 @@ async function deleteMessage(msgId) {
875
1019
  }
876
1020
 
877
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
+
878
1242
  // ── Claude Auth Helpers ─────────────────────────────────────────────
879
1243
 
880
1244
  const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
@@ -883,9 +1247,12 @@ const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
883
1247
  function redactSensitive(value) {
884
1248
  return String(value || "")
885
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]")
886
1252
  .replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
887
1253
  .replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
888
- .replace(/([?&](?:token|access_token|refresh_token)=)[^\s&]+/gi, "$1[REDACTED]");
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]");
889
1256
  }
890
1257
 
891
1258
 
@@ -945,8 +1312,12 @@ function getClaudeOAuthToken() {
945
1312
  return { value: null, source: null };
946
1313
  }
947
1314
 
1315
+ function botSubprocessEnv() {
1316
+ return { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
1317
+ }
1318
+
948
1319
  function claudeSubprocessEnv() {
949
- const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME };
1320
+ const env = botSubprocessEnv();
950
1321
  const token = getClaudeOAuthToken().value;
951
1322
  if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
952
1323
  return env;
@@ -1330,7 +1701,7 @@ function compactSeedPrompt(summary) {
1330
1701
 
1331
1702
  async function runClaudeCapture(prompt, cwd, opts = {}) {
1332
1703
  const state = currentState();
1333
- const chatId = state.chatId;
1704
+ const chatId = currentChatId();
1334
1705
  if (state.runningProcess) throw new Error("Another task is already running.");
1335
1706
  const authPreflight = preflightClaudeAuthMessage();
1336
1707
  if (authPreflight) throw new Error(authPreflight);
@@ -1441,7 +1812,7 @@ async function compactActiveSession(cwd, opts = {}) {
1441
1812
 
1442
1813
  if (newSessionId && state.currentSession) {
1443
1814
  const title = `Compacted ${new Date().toLocaleDateString()}`;
1444
- recordSession(state.chatId, state.currentSession.name, newSessionId, title);
1815
+ recordSession(state.userId, state.currentSession.name, newSessionId, title);
1445
1816
  }
1446
1817
  return { compacted: true, oldSessionId, newSessionId, summary };
1447
1818
  } finally {
@@ -1456,7 +1827,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1456
1827
  // propagation through child_process events isn't guaranteed across all
1457
1828
  // Node versions.
1458
1829
  const state = currentState();
1459
- const chatId = state.chatId;
1830
+ const chatId = currentChatId();
1460
1831
  const { settings } = state;
1461
1832
 
1462
1833
  if (state.runningProcess) {
@@ -1694,7 +2065,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1694
2065
  return;
1695
2066
  }
1696
2067
  if (settings.backend === "codex" && /not (?:logged in|authenticated)|please (?:log|sign) in|401 unauthorized|invalid api key/i.test(stderrBuffer)) {
1697
- await send("Codex authentication error. Run `codex login` on this machine, then retry.", { replyTo: replyToMsgId });
2068
+ await send("Codex authentication error. Try /codex_auth_status, then /codex_login or /codex_setup_token.", { replyTo: replyToMsgId });
1698
2069
  return;
1699
2070
  }
1700
2071
 
@@ -1742,7 +2113,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1742
2113
  // Record session with auto-title from first message
1743
2114
  if (state.lastSessionId && state.currentSession) {
1744
2115
  const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
1745
- recordSession(chatId, state.currentSession.name, state.lastSessionId, title);
2116
+ recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
1746
2117
  state.isFirstMessage = false;
1747
2118
  }
1748
2119
  if (state.messageQueue.length > 0 && state.currentSession) {
@@ -1832,14 +2203,14 @@ function startSession(name, resumeSessionId) {
1832
2203
  // Resume a specific session or the last one for this project
1833
2204
  if (resumeSessionId) {
1834
2205
  state.lastSessionId = resumeSessionId;
1835
- const sessions = getProjectSessions(state.chatId, projectName);
2206
+ const sessions = getProjectSessions(state.userId, projectName);
1836
2207
  const s = sessions.find((x) => x.id === resumeSessionId);
1837
2208
  const title = s ? s.title : "";
1838
2209
  state.isFirstMessage = false;
1839
2210
  saveState();
1840
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`);
1841
2212
  } else {
1842
- const last = getLastProjectSession(state.chatId, projectName);
2213
+ const last = getLastProjectSession(state.userId, projectName);
1843
2214
  if (last) {
1844
2215
  state.lastSessionId = last.id;
1845
2216
  state.isFirstMessage = false;
@@ -1893,15 +2264,71 @@ bot.onText(/\/help/, wrapHandler((msg) => {
1893
2264
  send([
1894
2265
  "Session: /session /sessions /projects /continue /status /stop /end",
1895
2266
  "Settings: /model /effort /budget /plan /compact /worktree /mode",
2267
+ "Identity: /whoami /link",
1896
2268
  "Automation: /cron /vault /soul",
1897
2269
  "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
1898
- "System: /restart /upgrade",
2270
+ "Codex auth: /codex_auth_status /codex_login /codex_setup_token",
2271
+ "System: /doctor /requirements /restart /upgrade",
1899
2272
  "",
1900
2273
  "Send text, voice, photos, or files.",
1901
2274
  "Reply to any message for context.",
1902
2275
  ].join("\n"));
1903
2276
  }));
1904
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
+
1905
2332
  bot.onText(/\/version$/, wrapHandler((msg) => {
1906
2333
  if (!isAuthorized(msg)) return;
1907
2334
  send(`Open Claudia v${CURRENT_VERSION}`);
@@ -1964,8 +2391,9 @@ bot.onText(/\/upgrade$/, wrapHandler(async (msg) => {
1964
2391
  whatsNew = section.trim();
1965
2392
  }
1966
2393
  } catch (e) { /* no changelog */ }
1967
- const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nGoing offline to restart...`;
1968
- await send(msg);
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);
1969
2397
  } catch (e) {
1970
2398
  const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
1971
2399
  await send(`Upgrade failed:\n${errOutput}`);
@@ -1988,7 +2416,7 @@ bot.onText(/\/sessions$/, wrapHandler((msg) => {
1988
2416
  if (!isAuthorized(msg)) return;
1989
2417
  if (!requireSession(msg)) return;
1990
2418
  const state = currentState();
1991
- const sessions = getProjectSessions(state.chatId, state.currentSession.name);
2419
+ const sessions = getProjectSessions(state.userId, state.currentSession.name);
1992
2420
  if (sessions.length === 0) return send("No past conversations for this project.");
1993
2421
  const rows = sessions.slice(0, 10).map((s) => {
1994
2422
  const date = new Date(s.lastUsed).toLocaleDateString();
@@ -2202,6 +2630,11 @@ bot.onText(/\/stop/, wrapHandler(async (msg) => {
2202
2630
  else await send("Nothing running.");
2203
2631
  }));
2204
2632
 
2633
+ bot.onText(/\/(?:doctor|requirements)$/, wrapHandler(async (msg) => {
2634
+ if (!isAuthorized(msg)) return;
2635
+ await send(formatDoctorReport(runDoctorChecks()));
2636
+ }));
2637
+
2205
2638
  bot.onText(/\/status/, wrapHandler((msg) => {
2206
2639
  if (!isAuthorized(msg)) return;
2207
2640
  const state = currentState();
@@ -2348,6 +2781,42 @@ bot.onText(/\/clear_oauth_token$/, wrapHandler(async (msg) => {
2348
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."));
2349
2782
  }));
2350
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
+
2351
2820
  // ── /vault with password protection ─────────────────────────────────
2352
2821
  // Vault is a single shared store. Lock state is global; the
2353
2822
  // password-unlock flow is per-user (pendingVaultUnlock lives on the
@@ -2560,7 +3029,7 @@ bot.on("voice", wrapHandler(async (msg) => {
2560
3029
  if (msg.voice.file_size && msg.voice.file_size > MAX_VOICE_SIZE) {
2561
3030
  return send(`Voice note too large (${Math.round(msg.voice.file_size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
2562
3031
  }
2563
- bot.sendChatAction(state.chatId, "typing");
3032
+ bot.sendChatAction(currentChatId(), "typing");
2564
3033
  const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
2565
3034
  const transcript = transcribeAudio(oggPath);
2566
3035
  try { fs.unlinkSync(oggPath); } catch (e) {}
@@ -2577,7 +3046,7 @@ bot.on("audio", wrapHandler(async (msg) => {
2577
3046
  if (!requireSession(msg)) return;
2578
3047
  const state = currentState();
2579
3048
  try {
2580
- bot.sendChatAction(state.chatId, "typing");
3049
+ bot.sendChatAction(currentChatId(), "typing");
2581
3050
  const p = await downloadFile(msg.audio.file_id, path.extname(msg.audio.file_name || ".ogg"));
2582
3051
  const t = transcribeAudio(p);
2583
3052
  try { fs.unlinkSync(p); } catch (e) {}
@@ -2641,6 +3110,27 @@ bot.on("message", wrapHandler(async (msg) => {
2641
3110
  if (isDuplicate(msg)) return;
2642
3111
  const state = currentState();
2643
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
+
2644
3134
  // Handle pending manual OAuth token paste mode. Login codes must be sent explicitly
2645
3135
  // with /auth_code so normal chat can never be deleted/consumed accidentally.
2646
3136
  if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel === "manual OAuth token save") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram",
5
5
  "main": "bot.js",
6
6
  "bin": {