@inetafrica/open-claudia 1.18.0 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +41 -2
- package/bot-agent.js +129 -11
- package/bot.js +659 -51
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.19.1
|
|
4
|
+
- `/auth` requests now send the owner Telegram Approve/Deny buttons, while preserving the existing `auth.json` and CLI approval flow.
|
|
5
|
+
|
|
6
|
+
## v1.19.0
|
|
7
|
+
- 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
|
|
8
|
+
- `/upgrade` now includes a post-upgrade requirements/auth summary before restarting, so missing CLIs or auth breakage is visible immediately
|
|
9
|
+
- 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`
|
|
10
|
+
- Codex/OpenAI keys are redacted from Telegram output/logs; API keys are passed to `codex login --with-api-key` without being echoed
|
|
11
|
+
- 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.
|
|
12
|
+
- Added `/link`, `/links`, and `/whoami` for managing and inspecting Telegram-to-user mappings.
|
|
13
|
+
- Existing `state.json` and `sessions.json` chat-id keys are migrated through the identity resolver on load.
|
|
14
|
+
|
|
3
15
|
## v1.18.0
|
|
4
16
|
- 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
17
|
- `/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 |
|
|
@@ -220,14 +249,23 @@ Voice notes are transcribed locally — nothing sent to external services.
|
|
|
220
249
|
The setup owner is automatically authorized. To add more users:
|
|
221
250
|
|
|
222
251
|
**From Telegram** (unauthorized users):
|
|
223
|
-
- Send `/auth` to the bot — the owner gets
|
|
252
|
+
- Send `/auth` to the bot — the owner gets an Approve/Deny button prompt in Telegram
|
|
224
253
|
|
|
225
254
|
**From the terminal** (owner):
|
|
226
255
|
```bash
|
|
227
256
|
open-claudia auth
|
|
228
257
|
```
|
|
229
258
|
|
|
230
|
-
This shows authorized chats, pending requests, and lets you approve/deny or add new users with code verification.
|
|
259
|
+
This shows authorized chats, pending requests, and lets you approve/deny or add new users with code verification. Telegram button approvals use the same `auth.json` authorization list.
|
|
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`.
|
|
231
269
|
|
|
232
270
|
## How It Works
|
|
233
271
|
|
|
@@ -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-agent.js
CHANGED
|
@@ -190,6 +190,7 @@ bot.setMyCommands([
|
|
|
190
190
|
{ command: "cursor", description: "Switch to Cursor Agent backend" },
|
|
191
191
|
{ command: "claude", description: "Switch to Claude Code backend" },
|
|
192
192
|
{ command: "backend", description: "Show/switch active backend" },
|
|
193
|
+
{ command: "auth", description: "Request access to this bot" },
|
|
193
194
|
{ command: "auth_status", description: "Check Claude Code auth" },
|
|
194
195
|
{ command: "login", description: "Start Claude Code login" },
|
|
195
196
|
{ command: "setup_token", description: "Create Claude OAuth token" },
|
|
@@ -321,7 +322,7 @@ function isAuthorized(msg) {
|
|
|
321
322
|
// Also check auth.json for dynamically added chats
|
|
322
323
|
try {
|
|
323
324
|
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
324
|
-
return auth.authorized.some((a) => a.chatId === chatId);
|
|
325
|
+
return Array.isArray(auth.authorized) && auth.authorized.some((a) => String(a.chatId) === chatId);
|
|
325
326
|
} catch (e) {}
|
|
326
327
|
return false;
|
|
327
328
|
}
|
|
@@ -330,6 +331,69 @@ function isOwner(msg) {
|
|
|
330
331
|
return String(msg.chat.id) === CHAT_ID;
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
function loadAuth() {
|
|
335
|
+
try {
|
|
336
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
337
|
+
return {
|
|
338
|
+
authorized: Array.isArray(auth.authorized) ? auth.authorized : [],
|
|
339
|
+
pending: Array.isArray(auth.pending) ? auth.pending : [],
|
|
340
|
+
};
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return { authorized: [], pending: [] };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function saveAuth(auth) {
|
|
347
|
+
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function authRequestLabel(request) {
|
|
351
|
+
return request.username ? `@${request.username}` : request.name || request.chatId;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function updateAuthorizedChatEnv(auth) {
|
|
355
|
+
const ids = new Set(CHAT_IDS);
|
|
356
|
+
for (const user of auth.authorized) {
|
|
357
|
+
if (user.chatId) ids.add(String(user.chatId));
|
|
358
|
+
}
|
|
359
|
+
saveEnvKey("TELEGRAM_CHAT_ID", [...ids].join(","));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function approveAuthRequest(chatId) {
|
|
363
|
+
const auth = loadAuth();
|
|
364
|
+
if (auth.authorized.some((a) => String(a.chatId) === chatId)) {
|
|
365
|
+
auth.pending = auth.pending.filter((p) => String(p.chatId) !== chatId);
|
|
366
|
+
saveAuth(auth);
|
|
367
|
+
updateAuthorizedChatEnv(auth);
|
|
368
|
+
return { status: "already_authorized" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
|
|
372
|
+
if (idx < 0) return { status: "not_found" };
|
|
373
|
+
|
|
374
|
+
const approved = auth.pending.splice(idx, 1)[0];
|
|
375
|
+
auth.authorized.push({
|
|
376
|
+
chatId: approved.chatId,
|
|
377
|
+
name: approved.name,
|
|
378
|
+
username: approved.username,
|
|
379
|
+
isOwner: false,
|
|
380
|
+
authorizedAt: new Date().toISOString(),
|
|
381
|
+
});
|
|
382
|
+
saveAuth(auth);
|
|
383
|
+
updateAuthorizedChatEnv(auth);
|
|
384
|
+
return { status: "approved", request: approved };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function denyAuthRequest(chatId) {
|
|
388
|
+
const auth = loadAuth();
|
|
389
|
+
const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
|
|
390
|
+
if (idx < 0) return { status: "not_found" };
|
|
391
|
+
|
|
392
|
+
const denied = auth.pending.splice(idx, 1)[0];
|
|
393
|
+
saveAuth(auth);
|
|
394
|
+
return { status: "denied", request: denied };
|
|
395
|
+
}
|
|
396
|
+
|
|
333
397
|
// ── Auth request handler (for unauthorized users) ──────────────────
|
|
334
398
|
bot.onText(/\/auth$/, async (msg) => {
|
|
335
399
|
if (isAuthorized(msg)) {
|
|
@@ -341,15 +405,10 @@ bot.onText(/\/auth$/, async (msg) => {
|
|
|
341
405
|
const username = msg.from?.username || "";
|
|
342
406
|
|
|
343
407
|
// Add to pending in auth.json
|
|
344
|
-
|
|
345
|
-
try {
|
|
346
|
-
auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
347
|
-
} catch (e) {
|
|
348
|
-
auth = { authorized: [], pending: [] };
|
|
349
|
-
}
|
|
408
|
+
const auth = loadAuth();
|
|
350
409
|
|
|
351
410
|
// Check if already pending
|
|
352
|
-
if (auth.pending.some((p) => p.chatId === chatId)) {
|
|
411
|
+
if (auth.pending.some((p) => String(p.chatId) === chatId)) {
|
|
353
412
|
bot.sendMessage(msg.chat.id, "Your request is already pending. The bot owner will review it.");
|
|
354
413
|
return;
|
|
355
414
|
}
|
|
@@ -360,13 +419,20 @@ bot.onText(/\/auth$/, async (msg) => {
|
|
|
360
419
|
username,
|
|
361
420
|
requestedAt: new Date().toISOString(),
|
|
362
421
|
});
|
|
363
|
-
|
|
422
|
+
saveAuth(auth);
|
|
364
423
|
|
|
365
424
|
bot.sendMessage(msg.chat.id, "Access requested! The bot owner will review your request.");
|
|
366
425
|
|
|
367
426
|
// Notify owner
|
|
368
|
-
const label =
|
|
369
|
-
bot.sendMessage(CHAT_ID, `New auth request from ${label} (${chatId})
|
|
427
|
+
const label = authRequestLabel({ chatId, name, username });
|
|
428
|
+
bot.sendMessage(CHAT_ID, `New auth request from ${label} (${chatId}).`, {
|
|
429
|
+
reply_markup: {
|
|
430
|
+
inline_keyboard: [[
|
|
431
|
+
{ text: "Approve", callback_data: `auth:approve:${chatId}` },
|
|
432
|
+
{ text: "Deny", callback_data: `auth:deny:${chatId}` },
|
|
433
|
+
]],
|
|
434
|
+
},
|
|
435
|
+
});
|
|
370
436
|
});
|
|
371
437
|
|
|
372
438
|
// ── Onboarding ──────────────────────────────────────────────────────
|
|
@@ -1893,6 +1959,58 @@ bot.on("callback_query", async (q) => {
|
|
|
1893
1959
|
const d = q.data;
|
|
1894
1960
|
await bot.answerCallbackQuery(q.id);
|
|
1895
1961
|
|
|
1962
|
+
if (d.startsWith("auth:")) {
|
|
1963
|
+
const callbackFromOwner = String(q.from?.id) === CHAT_ID || String(q.message?.chat?.id) === CHAT_ID;
|
|
1964
|
+
if (!callbackFromOwner) {
|
|
1965
|
+
await send("Owner only — auth approvals are restricted.");
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
const [, action, chatId] = d.split(":");
|
|
1970
|
+
if (!chatId || !["approve", "deny"].includes(action)) return;
|
|
1971
|
+
|
|
1972
|
+
const result = action === "approve"
|
|
1973
|
+
? await approveAuthRequest(chatId)
|
|
1974
|
+
: await denyAuthRequest(chatId);
|
|
1975
|
+
|
|
1976
|
+
if (result.status === "not_found") {
|
|
1977
|
+
await send(`No pending auth request found for ${chatId}.`);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
if (result.status === "already_authorized") {
|
|
1982
|
+
await send(`Chat ${chatId} is already authorized.`);
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const label = authRequestLabel(result.request);
|
|
1987
|
+
if (result.status === "approved") {
|
|
1988
|
+
await bot.sendMessage(chatId, "Your access has been approved! You can now use the bot. Send /start to begin.").catch(() => {});
|
|
1989
|
+
let edited = false;
|
|
1990
|
+
if (q.message) {
|
|
1991
|
+
edited = await bot.editMessageText(`Approved auth request from ${label} (${chatId}).`, {
|
|
1992
|
+
chat_id: q.message.chat.id,
|
|
1993
|
+
message_id: q.message.message_id,
|
|
1994
|
+
reply_markup: { inline_keyboard: [] },
|
|
1995
|
+
}).then(() => true).catch(() => false);
|
|
1996
|
+
}
|
|
1997
|
+
if (!edited) await send(`Approved ${label} (${chatId}).`);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
await bot.sendMessage(chatId, "Your access request was denied.").catch(() => {});
|
|
2002
|
+
let edited = false;
|
|
2003
|
+
if (q.message) {
|
|
2004
|
+
edited = await bot.editMessageText(`Denied auth request from ${label} (${chatId}).`, {
|
|
2005
|
+
chat_id: q.message.chat.id,
|
|
2006
|
+
message_id: q.message.message_id,
|
|
2007
|
+
reply_markup: { inline_keyboard: [] },
|
|
2008
|
+
}).then(() => true).catch(() => false);
|
|
2009
|
+
}
|
|
2010
|
+
if (!edited) await send(`Denied ${label} (${chatId}).`);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1896
2014
|
// Onboarding style selection
|
|
1897
2015
|
if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
|
|
1898
2016
|
|
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,14 @@ 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: "auth", description: "Request access to this bot" },
|
|
310
|
+
{ command: "link", description: "Link this chat to a canonical user id" },
|
|
311
|
+
{ command: "whoami", description: "Show your canonical user id" },
|
|
306
312
|
{ command: "auth_status", description: "Check Claude Code auth" },
|
|
313
|
+
{ command: "codex_auth_status", description: "Check Codex auth" },
|
|
314
|
+
{ command: "codex_login", description: "Start Codex device login" },
|
|
307
315
|
{ command: "login", description: "Start Claude Code login" },
|
|
308
316
|
{ command: "setup_token", description: "Create Claude OAuth token" },
|
|
309
317
|
{ command: "stop", description: "Cancel running task" },
|
|
@@ -331,19 +339,85 @@ const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
|
|
|
331
339
|
const STATE_FILE = path.join(CONFIG_DIR, "state.json");
|
|
332
340
|
const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
|
|
333
341
|
|
|
342
|
+
function normalizeCanonicalUserId(value) {
|
|
343
|
+
return String(value || "").trim().toLowerCase();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function channelKey(transport, channelId) {
|
|
347
|
+
return `${String(transport || "").trim().toLowerCase()}:${String(channelId || "").trim()}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function defaultCanonicalForChannel(transport, channelId) {
|
|
351
|
+
return channelKey(transport, channelId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function loadIdentities() {
|
|
355
|
+
try {
|
|
356
|
+
const raw = JSON.parse(fs.readFileSync(IDENTITIES_FILE, "utf-8"));
|
|
357
|
+
return {
|
|
358
|
+
channels: raw && typeof raw.channels === "object" ? raw.channels : {},
|
|
359
|
+
preferred: raw && typeof raw.preferred === "object" ? raw.preferred : {},
|
|
360
|
+
};
|
|
361
|
+
} catch (e) {
|
|
362
|
+
return { channels: {}, preferred: {} };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const identities = loadIdentities();
|
|
367
|
+
|
|
368
|
+
function saveIdentities() {
|
|
369
|
+
try { fs.writeFileSync(IDENTITIES_FILE, JSON.stringify(identities, null, 2)); } catch (e) {}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function canonicalForChannel(transport, channelId) {
|
|
373
|
+
const key = channelKey(transport, channelId);
|
|
374
|
+
return normalizeCanonicalUserId(identities.channels[key]) || defaultCanonicalForChannel(transport, channelId);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function canonicalForTelegram(chatId) {
|
|
378
|
+
return canonicalForChannel("telegram", chatId);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function canonicalForStoredUserKey(key) {
|
|
382
|
+
const id = String(key);
|
|
383
|
+
if (id.includes(":") || id.includes("@")) return normalizeCanonicalUserId(id);
|
|
384
|
+
return canonicalForTelegram(id);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function currentCanonicalUserId() {
|
|
388
|
+
return canonicalForTelegram(currentChatId());
|
|
389
|
+
}
|
|
390
|
+
|
|
334
391
|
function loadStateFile() {
|
|
335
392
|
try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")); } catch (e) { return {}; }
|
|
336
393
|
}
|
|
337
394
|
|
|
395
|
+
function mergeSavedState(existing, next) {
|
|
396
|
+
return {
|
|
397
|
+
...existing,
|
|
398
|
+
...next,
|
|
399
|
+
settings: { ...(existing.settings || {}), ...(next.settings || {}) },
|
|
400
|
+
sessionUsage: { ...(existing.sessionUsage || {}), ...(next.sessionUsage || {}) },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
338
404
|
// Multi-user state. v1.16 and earlier stored a single user's state at the
|
|
339
|
-
// top level of state.json; v1.17
|
|
340
|
-
//
|
|
341
|
-
//
|
|
405
|
+
// top level of state.json; v1.17/v1.18 stored `{ users: { "<chatId>": {...} } }`.
|
|
406
|
+
// v1.19+ keys state by canonical user id (`telegram:<chatId>` by default,
|
|
407
|
+
// or an explicit mapping like `sumeet@inet.africa`) so future transports can
|
|
408
|
+
// share the same session state.
|
|
342
409
|
const savedState = (() => {
|
|
343
410
|
const raw = loadStateFile();
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
411
|
+
const users = {};
|
|
412
|
+
if (raw && raw.users && typeof raw.users === "object") {
|
|
413
|
+
for (const [key, value] of Object.entries(raw.users)) {
|
|
414
|
+
const userId = canonicalForStoredUserKey(key);
|
|
415
|
+
users[userId] = mergeSavedState(users[userId] || {}, value || {});
|
|
416
|
+
}
|
|
417
|
+
return { users };
|
|
418
|
+
}
|
|
419
|
+
// Legacy single-user shape: hoist it under the owner's canonical id.
|
|
420
|
+
return { users: { [canonicalForTelegram(CHAT_ID)]: raw || {} } };
|
|
347
421
|
})();
|
|
348
422
|
|
|
349
423
|
let activeCrons = new Map();
|
|
@@ -361,12 +435,13 @@ function freshUsage() {
|
|
|
361
435
|
return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
|
|
362
436
|
}
|
|
363
437
|
|
|
364
|
-
function createUserState(
|
|
365
|
-
const saved = (savedState.users && savedState.users[
|
|
438
|
+
function createUserState(userId) {
|
|
439
|
+
const saved = (savedState.users && savedState.users[userId]) || {};
|
|
366
440
|
const settings = saved.settings || freshSettings();
|
|
367
441
|
if (!settings.backend) settings.backend = "claude";
|
|
368
442
|
return {
|
|
369
|
-
|
|
443
|
+
userId: String(userId),
|
|
444
|
+
chatId: currentChatId(),
|
|
370
445
|
currentSession: saved.currentSession || null,
|
|
371
446
|
runningProcess: null,
|
|
372
447
|
statusMessageId: null,
|
|
@@ -386,15 +461,19 @@ function createUserState(chatId) {
|
|
|
386
461
|
pendingVaultAction: null,
|
|
387
462
|
pendingClaudeAuthProcess: null,
|
|
388
463
|
pendingClaudeAuthLabel: null,
|
|
464
|
+
pendingCodexAuthProcess: null,
|
|
465
|
+
pendingCodexAuthLabel: null,
|
|
389
466
|
isCompacting: false,
|
|
390
467
|
lastCompactedAt: saved.lastCompactedAt || 0,
|
|
391
468
|
};
|
|
392
469
|
}
|
|
393
470
|
|
|
394
|
-
function getUserState(
|
|
395
|
-
const id =
|
|
471
|
+
function getUserState(userId) {
|
|
472
|
+
const id = normalizeCanonicalUserId(userId);
|
|
396
473
|
if (!userStates.has(id)) userStates.set(id, createUserState(id));
|
|
397
|
-
|
|
474
|
+
const state = userStates.get(id);
|
|
475
|
+
state.chatId = currentChatId();
|
|
476
|
+
return state;
|
|
398
477
|
}
|
|
399
478
|
|
|
400
479
|
// AsyncLocalStorage carries the active chat id through the async call
|
|
@@ -408,7 +487,7 @@ function currentChatId() {
|
|
|
408
487
|
}
|
|
409
488
|
|
|
410
489
|
function currentState() {
|
|
411
|
-
return getUserState(
|
|
490
|
+
return getUserState(currentCanonicalUserId());
|
|
412
491
|
}
|
|
413
492
|
|
|
414
493
|
function resetSessionUsage(state = currentState()) {
|
|
@@ -420,7 +499,7 @@ function resetSettings(state = currentState()) {
|
|
|
420
499
|
}
|
|
421
500
|
|
|
422
501
|
function saveState() {
|
|
423
|
-
const data = { users: {} };
|
|
502
|
+
const data = { users: { ...(savedState.users || {}) } };
|
|
424
503
|
for (const [id, s] of userStates) {
|
|
425
504
|
data.users[id] = {
|
|
426
505
|
currentSession: s.currentSession,
|
|
@@ -432,9 +511,64 @@ function saveState() {
|
|
|
432
511
|
lastCompactedAt: s.lastCompactedAt || 0,
|
|
433
512
|
};
|
|
434
513
|
}
|
|
514
|
+
savedState.users = data.users;
|
|
435
515
|
try { fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch (e) {}
|
|
436
516
|
}
|
|
437
517
|
|
|
518
|
+
function migrateUserData(fromUserId, toUserId) {
|
|
519
|
+
const from = normalizeCanonicalUserId(fromUserId);
|
|
520
|
+
const to = normalizeCanonicalUserId(toUserId);
|
|
521
|
+
if (!from || !to || from === to) return;
|
|
522
|
+
|
|
523
|
+
if (savedState.users && savedState.users[from]) {
|
|
524
|
+
savedState.users[to] = mergeSavedState(savedState.users[to] || {}, savedState.users[from]);
|
|
525
|
+
delete savedState.users[from];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (userStates.has(from)) {
|
|
529
|
+
const fromState = userStates.get(from);
|
|
530
|
+
if (userStates.has(to)) {
|
|
531
|
+
const toState = userStates.get(to);
|
|
532
|
+
Object.assign(toState, mergeSavedState(toState, fromState));
|
|
533
|
+
toState.userId = to;
|
|
534
|
+
} else {
|
|
535
|
+
fromState.userId = to;
|
|
536
|
+
userStates.set(to, fromState);
|
|
537
|
+
}
|
|
538
|
+
userStates.delete(from);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const sessions = loadSessions();
|
|
542
|
+
if (sessions[from]) {
|
|
543
|
+
sessions[to] = { ...(sessions[to] || {}) };
|
|
544
|
+
for (const [project, list] of Object.entries(sessions[from])) {
|
|
545
|
+
sessions[to][project] = [
|
|
546
|
+
...(sessions[to][project] || []),
|
|
547
|
+
...(Array.isArray(list) ? list : []),
|
|
548
|
+
].slice(-20);
|
|
549
|
+
}
|
|
550
|
+
delete sessions[from];
|
|
551
|
+
saveSessions(sessions);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
saveState();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function setIdentityMapping(transport, channelId, canonicalUserId) {
|
|
558
|
+
const key = channelKey(transport, channelId);
|
|
559
|
+
const userId = normalizeCanonicalUserId(canonicalUserId);
|
|
560
|
+
if (!userId) throw new Error("Canonical user id is required.");
|
|
561
|
+
const hadExplicitMapping = Object.prototype.hasOwnProperty.call(identities.channels, key);
|
|
562
|
+
const previousUserId = canonicalForChannel(transport, channelId);
|
|
563
|
+
const defaultUserId = defaultCanonicalForChannel(transport, channelId);
|
|
564
|
+
identities.channels[key] = userId;
|
|
565
|
+
identities.preferred[userId] = { transport: String(transport).toLowerCase(), channelId: String(channelId) };
|
|
566
|
+
saveIdentities();
|
|
567
|
+
const shouldMigrate = !hadExplicitMapping || previousUserId === defaultUserId;
|
|
568
|
+
if (shouldMigrate) migrateUserData(previousUserId, userId);
|
|
569
|
+
return { key, previousUserId, userId, migrated: shouldMigrate && previousUserId !== userId };
|
|
570
|
+
}
|
|
571
|
+
|
|
438
572
|
// ── Message deduplication ──────────────────────────────────────────
|
|
439
573
|
// Telegram message_ids are unique per chat, not globally — namespace by
|
|
440
574
|
// chat id so two users' messages can't collide.
|
|
@@ -452,9 +586,9 @@ function isDuplicate(msg) {
|
|
|
452
586
|
}
|
|
453
587
|
|
|
454
588
|
// ── Per-project session history ────────────────────────────────────
|
|
455
|
-
// Sessions are stored per
|
|
456
|
-
// history per project. Legacy
|
|
457
|
-
//
|
|
589
|
+
// Sessions are stored per canonical user so linked channels share the same
|
|
590
|
+
// conversation history per project. Legacy files keyed by chat id are migrated
|
|
591
|
+
// through the same identity resolver on first read.
|
|
458
592
|
|
|
459
593
|
function loadSessions() {
|
|
460
594
|
let raw;
|
|
@@ -462,17 +596,28 @@ function loadSessions() {
|
|
|
462
596
|
if (!raw || typeof raw !== "object") return {};
|
|
463
597
|
// Legacy detection: top-level keys map directly to arrays of session objects.
|
|
464
598
|
const looksLegacy = Object.values(raw).some((v) => Array.isArray(v));
|
|
465
|
-
if (looksLegacy) return { [CHAT_ID]: raw };
|
|
466
|
-
|
|
599
|
+
if (looksLegacy) return { [canonicalForTelegram(CHAT_ID)]: raw };
|
|
600
|
+
const migrated = {};
|
|
601
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
602
|
+
const userId = canonicalForStoredUserKey(key);
|
|
603
|
+
migrated[userId] = { ...(migrated[userId] || {}) };
|
|
604
|
+
for (const [project, list] of Object.entries(value || {})) {
|
|
605
|
+
migrated[userId][project] = [
|
|
606
|
+
...(migrated[userId][project] || []),
|
|
607
|
+
...(Array.isArray(list) ? list : []),
|
|
608
|
+
].slice(-20);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return migrated;
|
|
467
612
|
}
|
|
468
613
|
|
|
469
614
|
function saveSessions(sessions) {
|
|
470
615
|
try { fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); } catch (e) {}
|
|
471
616
|
}
|
|
472
617
|
|
|
473
|
-
function recordSession(
|
|
618
|
+
function recordSession(userId, projectName, sessionId, title) {
|
|
474
619
|
const all = loadSessions();
|
|
475
|
-
const id =
|
|
620
|
+
const id = normalizeCanonicalUserId(userId);
|
|
476
621
|
if (!all[id]) all[id] = {};
|
|
477
622
|
if (!all[id][projectName]) all[id][projectName] = [];
|
|
478
623
|
const arr = all[id][projectName];
|
|
@@ -492,13 +637,13 @@ function recordSession(chatId, projectName, sessionId, title) {
|
|
|
492
637
|
saveSessions(all);
|
|
493
638
|
}
|
|
494
639
|
|
|
495
|
-
function getProjectSessions(
|
|
640
|
+
function getProjectSessions(userId, projectName) {
|
|
496
641
|
const all = loadSessions();
|
|
497
|
-
return ((all[
|
|
642
|
+
return ((all[normalizeCanonicalUserId(userId)] || {})[projectName] || []).slice().reverse();
|
|
498
643
|
}
|
|
499
644
|
|
|
500
|
-
function getLastProjectSession(
|
|
501
|
-
const sessions = getProjectSessions(
|
|
645
|
+
function getLastProjectSession(userId, projectName) {
|
|
646
|
+
const sessions = getProjectSessions(userId, projectName);
|
|
502
647
|
return sessions.length > 0 ? sessions[0] : null;
|
|
503
648
|
}
|
|
504
649
|
|
|
@@ -508,7 +653,7 @@ function isAuthorized(msg) {
|
|
|
508
653
|
// Also check auth.json for dynamically added chats
|
|
509
654
|
try {
|
|
510
655
|
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
511
|
-
return auth.authorized.some((a) => a.chatId === chatId);
|
|
656
|
+
return Array.isArray(auth.authorized) && auth.authorized.some((a) => String(a.chatId) === chatId);
|
|
512
657
|
} catch (e) {}
|
|
513
658
|
return false;
|
|
514
659
|
}
|
|
@@ -517,6 +662,69 @@ function isOwner(msg) {
|
|
|
517
662
|
return String(msg.chat.id) === CHAT_ID;
|
|
518
663
|
}
|
|
519
664
|
|
|
665
|
+
function loadAuth() {
|
|
666
|
+
try {
|
|
667
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
668
|
+
return {
|
|
669
|
+
authorized: Array.isArray(auth.authorized) ? auth.authorized : [],
|
|
670
|
+
pending: Array.isArray(auth.pending) ? auth.pending : [],
|
|
671
|
+
};
|
|
672
|
+
} catch (e) {
|
|
673
|
+
return { authorized: [], pending: [] };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function saveAuth(auth) {
|
|
678
|
+
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function authRequestLabel(request) {
|
|
682
|
+
return request.username ? `@${request.username}` : request.name || request.chatId;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function updateAuthorizedChatEnv(auth) {
|
|
686
|
+
const ids = new Set(CHAT_IDS);
|
|
687
|
+
for (const user of auth.authorized) {
|
|
688
|
+
if (user.chatId) ids.add(String(user.chatId));
|
|
689
|
+
}
|
|
690
|
+
saveEnvKey("TELEGRAM_CHAT_ID", [...ids].join(","));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function approveAuthRequest(chatId) {
|
|
694
|
+
const auth = loadAuth();
|
|
695
|
+
if (auth.authorized.some((a) => String(a.chatId) === chatId)) {
|
|
696
|
+
auth.pending = auth.pending.filter((p) => String(p.chatId) !== chatId);
|
|
697
|
+
saveAuth(auth);
|
|
698
|
+
updateAuthorizedChatEnv(auth);
|
|
699
|
+
return { status: "already_authorized" };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
|
|
703
|
+
if (idx < 0) return { status: "not_found" };
|
|
704
|
+
|
|
705
|
+
const approved = auth.pending.splice(idx, 1)[0];
|
|
706
|
+
auth.authorized.push({
|
|
707
|
+
chatId: approved.chatId,
|
|
708
|
+
name: approved.name,
|
|
709
|
+
username: approved.username,
|
|
710
|
+
isOwner: false,
|
|
711
|
+
authorizedAt: new Date().toISOString(),
|
|
712
|
+
});
|
|
713
|
+
saveAuth(auth);
|
|
714
|
+
updateAuthorizedChatEnv(auth);
|
|
715
|
+
return { status: "approved", request: approved };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function denyAuthRequest(chatId) {
|
|
719
|
+
const auth = loadAuth();
|
|
720
|
+
const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
|
|
721
|
+
if (idx < 0) return { status: "not_found" };
|
|
722
|
+
|
|
723
|
+
const denied = auth.pending.splice(idx, 1)[0];
|
|
724
|
+
saveAuth(auth);
|
|
725
|
+
return { status: "denied", request: denied };
|
|
726
|
+
}
|
|
727
|
+
|
|
520
728
|
// ── Auth request handler (for unauthorized users) ──────────────────
|
|
521
729
|
bot.onText(/\/auth$/, async (msg) => {
|
|
522
730
|
if (isAuthorized(msg)) {
|
|
@@ -528,15 +736,10 @@ bot.onText(/\/auth$/, async (msg) => {
|
|
|
528
736
|
const username = msg.from?.username || "";
|
|
529
737
|
|
|
530
738
|
// Add to pending in auth.json
|
|
531
|
-
|
|
532
|
-
try {
|
|
533
|
-
auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
|
|
534
|
-
} catch (e) {
|
|
535
|
-
auth = { authorized: [], pending: [] };
|
|
536
|
-
}
|
|
739
|
+
const auth = loadAuth();
|
|
537
740
|
|
|
538
741
|
// Check if already pending
|
|
539
|
-
if (auth.pending.some((p) => p.chatId === chatId)) {
|
|
742
|
+
if (auth.pending.some((p) => String(p.chatId) === chatId)) {
|
|
540
743
|
bot.sendMessage(msg.chat.id, "Your request is already pending. The bot owner will review it.");
|
|
541
744
|
return;
|
|
542
745
|
}
|
|
@@ -547,13 +750,20 @@ bot.onText(/\/auth$/, async (msg) => {
|
|
|
547
750
|
username,
|
|
548
751
|
requestedAt: new Date().toISOString(),
|
|
549
752
|
});
|
|
550
|
-
|
|
753
|
+
saveAuth(auth);
|
|
551
754
|
|
|
552
755
|
bot.sendMessage(msg.chat.id, "Access requested! The bot owner will review your request.");
|
|
553
756
|
|
|
554
757
|
// Notify owner
|
|
555
|
-
const label =
|
|
556
|
-
bot.sendMessage(CHAT_ID, `New auth request from ${label} (${chatId})
|
|
758
|
+
const label = authRequestLabel({ chatId, name, username });
|
|
759
|
+
bot.sendMessage(CHAT_ID, `New auth request from ${label} (${chatId}).`, {
|
|
760
|
+
reply_markup: {
|
|
761
|
+
inline_keyboard: [[
|
|
762
|
+
{ text: "Approve", callback_data: `auth:approve:${chatId}` },
|
|
763
|
+
{ text: "Deny", callback_data: `auth:deny:${chatId}` },
|
|
764
|
+
]],
|
|
765
|
+
},
|
|
766
|
+
});
|
|
557
767
|
});
|
|
558
768
|
|
|
559
769
|
// ── Onboarding ──────────────────────────────────────────────────────
|
|
@@ -875,6 +1085,226 @@ async function deleteMessage(msgId) {
|
|
|
875
1085
|
}
|
|
876
1086
|
|
|
877
1087
|
|
|
1088
|
+
// ── Requirements / Doctor Helpers ──────────────────────────────────
|
|
1089
|
+
|
|
1090
|
+
function shellQuote(value) {
|
|
1091
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function runCommandForDoctor(command, args = [], opts = {}) {
|
|
1095
|
+
try {
|
|
1096
|
+
const out = execSync([command, ...args].map(shellQuote).join(" "), {
|
|
1097
|
+
cwd: opts.cwd || process.env.HOME || require("os").homedir(),
|
|
1098
|
+
env: opts.env || botSubprocessEnv(),
|
|
1099
|
+
encoding: "utf-8",
|
|
1100
|
+
timeout: opts.timeout || 10000,
|
|
1101
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1102
|
+
});
|
|
1103
|
+
return { ok: true, output: out.trim(), code: 0 };
|
|
1104
|
+
} catch (e) {
|
|
1105
|
+
return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim(), code: e.status ?? 1 };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function binaryCheck(binPath, name) {
|
|
1110
|
+
if (!binPath) return { ok: false, label: name, detail: "not configured/found" };
|
|
1111
|
+
try {
|
|
1112
|
+
if (fs.existsSync(binPath)) fs.accessSync(binPath, fs.constants.X_OK);
|
|
1113
|
+
else execSync(process.platform === "win32" ? `where ${shellQuote(binPath)}` : `which ${shellQuote(binPath)}`, { stdio: "ignore", env: botSubprocessEnv() });
|
|
1114
|
+
return { ok: true, label: name, detail: binPath };
|
|
1115
|
+
} catch (e) {
|
|
1116
|
+
return { ok: false, label: name, detail: `not executable: ${binPath}` };
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function checkWritableDir(dirPath, label) {
|
|
1121
|
+
if (!dirPath) return { ok: false, label, detail: "not configured" };
|
|
1122
|
+
try {
|
|
1123
|
+
if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
|
|
1124
|
+
const test = path.join(dirPath, `.open-claudia-write-test-${Date.now()}`);
|
|
1125
|
+
fs.writeFileSync(test, "ok");
|
|
1126
|
+
fs.unlinkSync(test);
|
|
1127
|
+
return { ok: true, label, detail: dirPath };
|
|
1128
|
+
} catch (e) {
|
|
1129
|
+
return { ok: false, label, detail: redactSensitive(e.message) };
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function summarizeAuthOutput(output, ok) {
|
|
1134
|
+
const clean = redactSensitive(stripTerminalControls(output || "")).replace(/\s+/g, " ").trim();
|
|
1135
|
+
if (!clean) return ok ? "ok" : "no output";
|
|
1136
|
+
return clean.length > 160 ? clean.slice(0, 157) + "..." : clean;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function isCodexAuthErrorText(text) {
|
|
1140
|
+
return /not (?:logged in|authenticated)|unauthenticated|login required|please (?:log|sign) in|401 unauthorized|invalid api key|no credentials/i.test(String(text || ""));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function runDoctorChecks() {
|
|
1144
|
+
const checks = [];
|
|
1145
|
+
const nodeMajor = parseInt(process.version.slice(1).split(".")[0], 10);
|
|
1146
|
+
checks.push({ ok: nodeMajor >= 18, label: "Node.js", detail: process.version, action: nodeMajor >= 18 ? "" : "Install Node.js 18+." });
|
|
1147
|
+
|
|
1148
|
+
const claudeBin = binaryCheck(CLAUDE_PATH, "Claude CLI");
|
|
1149
|
+
if (claudeBin.ok) {
|
|
1150
|
+
const ver = runCommandForDoctor(CLAUDE_PATH, ["--version"]);
|
|
1151
|
+
const auth = runCommandForDoctor(CLAUDE_PATH, ["auth", "status"], { env: claudeSubprocessEnv(), timeout: 12000 });
|
|
1152
|
+
checks.push({ ok: ver.ok, label: "Claude version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CLAUDE_PATH." });
|
|
1153
|
+
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." });
|
|
1154
|
+
} else checks.push({ ...claudeBin, action: "Install @anthropic-ai/claude-code or fix CLAUDE_PATH." });
|
|
1155
|
+
|
|
1156
|
+
if (CURSOR_PATH || resolvedCursorPath) {
|
|
1157
|
+
const cursorPath = resolvedCursorPath || CURSOR_PATH;
|
|
1158
|
+
const cursorBin = binaryCheck(cursorPath, "Cursor Agent");
|
|
1159
|
+
if (cursorBin.ok) {
|
|
1160
|
+
const ver = runCommandForDoctor(cursorPath, ["--version"]);
|
|
1161
|
+
const status = runCommandForDoctor(cursorPath, ["status"], { timeout: 12000 });
|
|
1162
|
+
checks.push({ ok: ver.ok, label: "Cursor version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CURSOR_PATH." });
|
|
1163
|
+
checks.push({ ok: status.ok, label: "Cursor auth/status", detail: summarizeAuthOutput(status.output, status.ok), action: status.ok ? "" : "Run `agent login` on this host." });
|
|
1164
|
+
} else checks.push({ ...cursorBin, action: "Install Cursor Agent or fix CURSOR_PATH." });
|
|
1165
|
+
} else checks.push({ ok: true, warn: true, label: "Cursor Agent", detail: "not configured/found (optional)", action: "Install only if you want /cursor." });
|
|
1166
|
+
|
|
1167
|
+
if (CODEX_PATH || resolvedCodexPath) {
|
|
1168
|
+
const codexPath = resolvedCodexPath || CODEX_PATH;
|
|
1169
|
+
const codexBin = binaryCheck(codexPath, "Codex CLI");
|
|
1170
|
+
if (codexBin.ok) {
|
|
1171
|
+
const ver = runCommandForDoctor(codexPath, ["--version"]);
|
|
1172
|
+
const status = runCommandForDoctor(codexPath, ["login", "status"], { timeout: 12000 });
|
|
1173
|
+
checks.push({ ok: ver.ok, label: "Codex version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CODEX_PATH." });
|
|
1174
|
+
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." });
|
|
1175
|
+
} else checks.push({ ...codexBin, action: "Install @openai/codex or fix CODEX_PATH." });
|
|
1176
|
+
} else checks.push({ ok: true, warn: true, label: "Codex CLI", detail: "not configured/found (optional)", action: "Install only if you want /codex." });
|
|
1177
|
+
|
|
1178
|
+
if (FFMPEG || WHISPER_CLI || WHISPER_MODEL) {
|
|
1179
|
+
const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");
|
|
1180
|
+
const wh = binaryCheck(WHISPER_CLI, "Whisper CLI");
|
|
1181
|
+
checks.push({ ...ff, action: ff.ok ? "" : "Install ffmpeg or set FFMPEG." });
|
|
1182
|
+
checks.push({ ...wh, action: wh.ok ? "" : "Install whisper.cpp or set WHISPER_CLI." });
|
|
1183
|
+
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." });
|
|
1184
|
+
} else checks.push({ ok: true, warn: true, label: "Voice stack", detail: "not configured (optional)", action: "Set FFMPEG/WHISPER_CLI/WHISPER_MODEL for voice notes." });
|
|
1185
|
+
|
|
1186
|
+
checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
|
|
1187
|
+
checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
|
|
1188
|
+
return checks;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function formatDoctorReport(checks) {
|
|
1192
|
+
const hardProblems = checks.filter((c) => !c.ok && !c.warn);
|
|
1193
|
+
const warnings = checks.filter((c) => c.warn || (!c.ok && c.warn));
|
|
1194
|
+
const lines = [hardProblems.length ? "⚠️ Open Claudia doctor found issues" : "✅ Open Claudia doctor looks good", ""];
|
|
1195
|
+
for (const c of checks) {
|
|
1196
|
+
const icon = c.ok ? (c.warn ? "⚠️" : "✅") : "⚠️";
|
|
1197
|
+
lines.push(`${icon} ${c.label}: ${c.detail || (c.ok ? "ok" : "issue")}`);
|
|
1198
|
+
}
|
|
1199
|
+
const actions = checks.filter((c) => (!c.ok || c.warn) && c.action).map((c) => `• ${c.label}: ${c.action}`);
|
|
1200
|
+
if (actions.length) lines.push("", "Next actions:", ...actions.slice(0, 8));
|
|
1201
|
+
if (warnings.length && !hardProblems.length) lines[0] = "✅ Core requirements pass (optional items noted)";
|
|
1202
|
+
return lines.join("\n");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function looksLikeOpenAIKey(value) {
|
|
1206
|
+
return /^sk-(?:proj-)?[A-Za-z0-9._-]{20,}$/.test(String(value || "").trim());
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function clearPendingCodexAuth(state = currentState()) {
|
|
1210
|
+
if (state.pendingCodexAuthProcess && state.pendingCodexAuthProcess.kill) {
|
|
1211
|
+
try { state.pendingCodexAuthProcess.kill("SIGTERM"); } catch (e) {}
|
|
1212
|
+
}
|
|
1213
|
+
state.pendingCodexAuthProcess = null;
|
|
1214
|
+
state.pendingCodexAuthLabel = null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function runCodexLoginStatus() {
|
|
1218
|
+
if (!resolvedCodexPath) return { ok: false, code: 1, output: "Codex CLI not found" };
|
|
1219
|
+
const result = runCommandForDoctor(resolvedCodexPath, ["login", "status"], { timeout: 12000 });
|
|
1220
|
+
return { ...result, ok: result.ok && !isCodexAuthErrorText(result.output) };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
|
|
1224
|
+
const status = runCodexLoginStatus();
|
|
1225
|
+
const version = resolvedCodexPath ? runCommandForDoctor(resolvedCodexPath, ["--version"]) : { ok: false, output: "not found" };
|
|
1226
|
+
await send([
|
|
1227
|
+
prefix,
|
|
1228
|
+
"",
|
|
1229
|
+
`CLI: ${resolvedCodexPath ? "found" : "not found"}`,
|
|
1230
|
+
`Version: ${summarizeAuthOutput(version.output, version.ok)}`,
|
|
1231
|
+
`Logged in: ${status.ok ? "yes" : "no/unknown"}`,
|
|
1232
|
+
`Status: ${summarizeAuthOutput(status.output, status.ok)}`,
|
|
1233
|
+
status.ok ? "" : "Next: /codex_login for device auth, or /codex_setup_token to paste an OpenAI API key securely.",
|
|
1234
|
+
].filter(Boolean).join("\n"));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function saveCodexApiKeyWithCli(apiKey) {
|
|
1238
|
+
return new Promise((resolve) => {
|
|
1239
|
+
const proc = spawn(resolvedCodexPath, ["login", "--with-api-key"], {
|
|
1240
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
1241
|
+
env: botSubprocessEnv(),
|
|
1242
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1243
|
+
});
|
|
1244
|
+
let output = "";
|
|
1245
|
+
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
1246
|
+
proc.stderr.on("data", (d) => { output += d.toString(); });
|
|
1247
|
+
proc.on("close", (code) => resolve({ ok: code === 0, code, output }));
|
|
1248
|
+
proc.on("error", (err) => resolve({ ok: false, code: 1, output: err.message }));
|
|
1249
|
+
proc.stdin.write(apiKey.trim() + "\n");
|
|
1250
|
+
proc.stdin.end();
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function runCodexDeviceLogin() {
|
|
1255
|
+
const state = currentState();
|
|
1256
|
+
if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
|
|
1257
|
+
if (state.pendingCodexAuthProcess) return send(`Another Codex auth flow is already running (${state.pendingCodexAuthLabel}). Send /cancel_codex_auth to cancel.`);
|
|
1258
|
+
await send("Codex device login started. I’ll send the URL/code if the CLI prints one.");
|
|
1259
|
+
const proc = spawn(resolvedCodexPath, ["login", "--device-auth"], {
|
|
1260
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
1261
|
+
env: botSubprocessEnv(),
|
|
1262
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1263
|
+
});
|
|
1264
|
+
state.pendingCodexAuthProcess = proc;
|
|
1265
|
+
state.pendingCodexAuthLabel = "Codex device login";
|
|
1266
|
+
let output = "";
|
|
1267
|
+
let sent = new Set();
|
|
1268
|
+
let lastSnippetAt = 0;
|
|
1269
|
+
const handleChunk = async (chunk) => {
|
|
1270
|
+
output += chunk;
|
|
1271
|
+
const cleanChunk = redactSensitive(stripTerminalControls(chunk));
|
|
1272
|
+
for (const url of extractUrls(cleanChunk)) {
|
|
1273
|
+
if (!sent.has(url)) {
|
|
1274
|
+
sent.add(url);
|
|
1275
|
+
await send(`Codex login URL:\n${redactSensitive(url)}\n\nOpen it and enter the device code if shown.`);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
const codeMatch = cleanChunk.match(/(?:code|device code)[:\s]+([A-Z0-9-]{6,})/i);
|
|
1279
|
+
if (codeMatch && !sent.has(codeMatch[1])) {
|
|
1280
|
+
sent.add(codeMatch[1]);
|
|
1281
|
+
await send(`Codex device code: ${codeMatch[1]}`);
|
|
1282
|
+
}
|
|
1283
|
+
const now = Date.now();
|
|
1284
|
+
if (cleanChunk.trim() && /device|code|login|browser|open|auth|token|error|failed|api key/i.test(cleanChunk) && now - lastSnippetAt > 3000) {
|
|
1285
|
+
lastSnippetAt = now;
|
|
1286
|
+
await send(cleanChunk.trim().slice(-1200));
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth output error:", e.message)));
|
|
1290
|
+
proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth stderr error:", e.message)));
|
|
1291
|
+
proc.on("close", async (code) => {
|
|
1292
|
+
state.pendingCodexAuthProcess = null;
|
|
1293
|
+
state.pendingCodexAuthLabel = null;
|
|
1294
|
+
const clean = redactSensitive(stripTerminalControls(output)).trim();
|
|
1295
|
+
if (/raw mode is not supported|not a tty|inappropriate ioctl/i.test(clean)) {
|
|
1296
|
+
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.");
|
|
1297
|
+
} else if (clean) await send(`Codex login finished (exit ${code}).\n\n${clean.slice(-2000)}`);
|
|
1298
|
+
else await send(`Codex login finished (exit ${code}).`);
|
|
1299
|
+
await sendCodexAuthStatusSummary("Post-Codex-auth check:");
|
|
1300
|
+
});
|
|
1301
|
+
proc.on("error", async (err) => {
|
|
1302
|
+
state.pendingCodexAuthProcess = null;
|
|
1303
|
+
state.pendingCodexAuthLabel = null;
|
|
1304
|
+
await send(`Codex login failed: ${redactSensitive(err.message)}`);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
|
|
878
1308
|
// ── Claude Auth Helpers ─────────────────────────────────────────────
|
|
879
1309
|
|
|
880
1310
|
const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
@@ -883,9 +1313,12 @@ const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
|
|
|
883
1313
|
function redactSensitive(value) {
|
|
884
1314
|
return String(value || "")
|
|
885
1315
|
.replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
|
|
1316
|
+
.replace(/sk-proj-[A-Za-z0-9._-]+/g, "[REDACTED_OPENAI_KEY]")
|
|
1317
|
+
.replace(/sk-[A-Za-z0-9._-]{20,}/g, "[REDACTED_OPENAI_KEY]")
|
|
886
1318
|
.replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
|
|
887
1319
|
.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
|
|
888
|
-
.replace(/(
|
|
1320
|
+
.replace(/(OPENAI_API_KEY\s*=\s*)\S+/gi, "$1[REDACTED_OPENAI_KEY]")
|
|
1321
|
+
.replace(/([?&](?:token|access_token|refresh_token|api_key)=)[^\s&]+/gi, "$1[REDACTED]");
|
|
889
1322
|
}
|
|
890
1323
|
|
|
891
1324
|
|
|
@@ -945,8 +1378,12 @@ function getClaudeOAuthToken() {
|
|
|
945
1378
|
return { value: null, source: null };
|
|
946
1379
|
}
|
|
947
1380
|
|
|
1381
|
+
function botSubprocessEnv() {
|
|
1382
|
+
return { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
948
1385
|
function claudeSubprocessEnv() {
|
|
949
|
-
const env =
|
|
1386
|
+
const env = botSubprocessEnv();
|
|
950
1387
|
const token = getClaudeOAuthToken().value;
|
|
951
1388
|
if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
952
1389
|
return env;
|
|
@@ -1330,7 +1767,7 @@ function compactSeedPrompt(summary) {
|
|
|
1330
1767
|
|
|
1331
1768
|
async function runClaudeCapture(prompt, cwd, opts = {}) {
|
|
1332
1769
|
const state = currentState();
|
|
1333
|
-
const chatId =
|
|
1770
|
+
const chatId = currentChatId();
|
|
1334
1771
|
if (state.runningProcess) throw new Error("Another task is already running.");
|
|
1335
1772
|
const authPreflight = preflightClaudeAuthMessage();
|
|
1336
1773
|
if (authPreflight) throw new Error(authPreflight);
|
|
@@ -1441,7 +1878,7 @@ async function compactActiveSession(cwd, opts = {}) {
|
|
|
1441
1878
|
|
|
1442
1879
|
if (newSessionId && state.currentSession) {
|
|
1443
1880
|
const title = `Compacted ${new Date().toLocaleDateString()}`;
|
|
1444
|
-
recordSession(state.
|
|
1881
|
+
recordSession(state.userId, state.currentSession.name, newSessionId, title);
|
|
1445
1882
|
}
|
|
1446
1883
|
return { compacted: true, oldSessionId, newSessionId, summary };
|
|
1447
1884
|
} finally {
|
|
@@ -1456,7 +1893,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1456
1893
|
// propagation through child_process events isn't guaranteed across all
|
|
1457
1894
|
// Node versions.
|
|
1458
1895
|
const state = currentState();
|
|
1459
|
-
const chatId =
|
|
1896
|
+
const chatId = currentChatId();
|
|
1460
1897
|
const { settings } = state;
|
|
1461
1898
|
|
|
1462
1899
|
if (state.runningProcess) {
|
|
@@ -1694,7 +2131,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1694
2131
|
return;
|
|
1695
2132
|
}
|
|
1696
2133
|
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.
|
|
2134
|
+
await send("Codex authentication error. Try /codex_auth_status, then /codex_login or /codex_setup_token.", { replyTo: replyToMsgId });
|
|
1698
2135
|
return;
|
|
1699
2136
|
}
|
|
1700
2137
|
|
|
@@ -1742,7 +2179,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1742
2179
|
// Record session with auto-title from first message
|
|
1743
2180
|
if (state.lastSessionId && state.currentSession) {
|
|
1744
2181
|
const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
|
|
1745
|
-
recordSession(
|
|
2182
|
+
recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
|
|
1746
2183
|
state.isFirstMessage = false;
|
|
1747
2184
|
}
|
|
1748
2185
|
if (state.messageQueue.length > 0 && state.currentSession) {
|
|
@@ -1832,14 +2269,14 @@ function startSession(name, resumeSessionId) {
|
|
|
1832
2269
|
// Resume a specific session or the last one for this project
|
|
1833
2270
|
if (resumeSessionId) {
|
|
1834
2271
|
state.lastSessionId = resumeSessionId;
|
|
1835
|
-
const sessions = getProjectSessions(state.
|
|
2272
|
+
const sessions = getProjectSessions(state.userId, projectName);
|
|
1836
2273
|
const s = sessions.find((x) => x.id === resumeSessionId);
|
|
1837
2274
|
const title = s ? s.title : "";
|
|
1838
2275
|
state.isFirstMessage = false;
|
|
1839
2276
|
saveState();
|
|
1840
2277
|
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
2278
|
} else {
|
|
1842
|
-
const last = getLastProjectSession(state.
|
|
2279
|
+
const last = getLastProjectSession(state.userId, projectName);
|
|
1843
2280
|
if (last) {
|
|
1844
2281
|
state.lastSessionId = last.id;
|
|
1845
2282
|
state.isFirstMessage = false;
|
|
@@ -1893,15 +2330,71 @@ bot.onText(/\/help/, wrapHandler((msg) => {
|
|
|
1893
2330
|
send([
|
|
1894
2331
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
1895
2332
|
"Settings: /model /effort /budget /plan /compact /worktree /mode",
|
|
2333
|
+
"Identity: /whoami /link",
|
|
1896
2334
|
"Automation: /cron /vault /soul",
|
|
1897
2335
|
"Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
|
|
1898
|
-
"
|
|
2336
|
+
"Codex auth: /codex_auth_status /codex_login /codex_setup_token",
|
|
2337
|
+
"System: /doctor /requirements /restart /upgrade",
|
|
1899
2338
|
"",
|
|
1900
2339
|
"Send text, voice, photos, or files.",
|
|
1901
2340
|
"Reply to any message for context.",
|
|
1902
2341
|
].join("\n"));
|
|
1903
2342
|
}));
|
|
1904
2343
|
|
|
2344
|
+
bot.onText(/\/whoami$/, wrapHandler((msg) => {
|
|
2345
|
+
if (!isAuthorized(msg)) return;
|
|
2346
|
+
const chatId = String(msg.chat.id);
|
|
2347
|
+
const key = channelKey("telegram", chatId);
|
|
2348
|
+
const userId = canonicalForTelegram(chatId);
|
|
2349
|
+
const preferred = identities.preferred[userId];
|
|
2350
|
+
send([
|
|
2351
|
+
`Channel: ${key}`,
|
|
2352
|
+
`User: ${userId}`,
|
|
2353
|
+
preferred ? `Preferred: ${preferred.transport}:${preferred.channelId}` : "Preferred: this channel",
|
|
2354
|
+
].join("\n"));
|
|
2355
|
+
}));
|
|
2356
|
+
|
|
2357
|
+
bot.onText(/\/links$/, wrapHandler((msg) => {
|
|
2358
|
+
if (!isOwner(msg)) return;
|
|
2359
|
+
const rows = Object.entries(identities.channels)
|
|
2360
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2361
|
+
.map(([channel, userId]) => `${channel} -> ${userId}`);
|
|
2362
|
+
send(rows.length ? rows.join("\n") : "No explicit identity links. Unlinked Telegram chats use telegram:<chatId>.");
|
|
2363
|
+
}));
|
|
2364
|
+
|
|
2365
|
+
bot.onText(/\/link$/, wrapHandler((msg) => {
|
|
2366
|
+
if (!isAuthorized(msg)) return;
|
|
2367
|
+
send(isOwner(msg)
|
|
2368
|
+
? "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."
|
|
2369
|
+
: "Usage:\n/link <email-or-user-id>\n\nThis links your Telegram chat to a canonical user id.");
|
|
2370
|
+
}));
|
|
2371
|
+
|
|
2372
|
+
bot.onText(/\/link\s+(.+)$/, wrapHandler((msg, match) => {
|
|
2373
|
+
if (!isAuthorized(msg)) return;
|
|
2374
|
+
const parts = String(match[1] || "").trim().split(/\s+/).filter(Boolean);
|
|
2375
|
+
if (parts.length === 0 || parts.length > 2) return send("Usage: /link <email-or-user-id>");
|
|
2376
|
+
|
|
2377
|
+
let targetChatId = String(msg.chat.id);
|
|
2378
|
+
let userId = parts[0];
|
|
2379
|
+
if (parts.length === 2) {
|
|
2380
|
+
if (!isOwner(msg)) return send("Only the owner can link another chat.");
|
|
2381
|
+
const channel = parts[0];
|
|
2382
|
+
if (channel.startsWith("telegram:")) targetChatId = channel.slice("telegram:".length);
|
|
2383
|
+
else targetChatId = channel;
|
|
2384
|
+
userId = parts[1];
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
if (!/^-?\d+$/.test(targetChatId)) return send("Telegram chat id must be numeric.");
|
|
2388
|
+
const normalizedUserId = normalizeCanonicalUserId(userId);
|
|
2389
|
+
if (!normalizedUserId || /\s/.test(normalizedUserId)) return send("Canonical user id cannot be empty or contain spaces.");
|
|
2390
|
+
|
|
2391
|
+
const result = setIdentityMapping("telegram", targetChatId, normalizedUserId);
|
|
2392
|
+
send([
|
|
2393
|
+
`Linked ${result.key} -> ${result.userId}`,
|
|
2394
|
+
result.migrated ? `Migrated state from ${result.previousUserId}.` : "Existing canonical state was left in place.",
|
|
2395
|
+
].join("\n"));
|
|
2396
|
+
}));
|
|
2397
|
+
|
|
1905
2398
|
bot.onText(/\/version$/, wrapHandler((msg) => {
|
|
1906
2399
|
if (!isAuthorized(msg)) return;
|
|
1907
2400
|
send(`Open Claudia v${CURRENT_VERSION}`);
|
|
@@ -1964,8 +2457,9 @@ bot.onText(/\/upgrade$/, wrapHandler(async (msg) => {
|
|
|
1964
2457
|
whatsNew = section.trim();
|
|
1965
2458
|
}
|
|
1966
2459
|
} catch (e) { /* no changelog */ }
|
|
1967
|
-
const
|
|
1968
|
-
|
|
2460
|
+
const doctorReport = formatDoctorReport(runDoctorChecks());
|
|
2461
|
+
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...`;
|
|
2462
|
+
await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
1969
2463
|
} catch (e) {
|
|
1970
2464
|
const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
|
|
1971
2465
|
await send(`Upgrade failed:\n${errOutput}`);
|
|
@@ -1988,7 +2482,7 @@ bot.onText(/\/sessions$/, wrapHandler((msg) => {
|
|
|
1988
2482
|
if (!isAuthorized(msg)) return;
|
|
1989
2483
|
if (!requireSession(msg)) return;
|
|
1990
2484
|
const state = currentState();
|
|
1991
|
-
const sessions = getProjectSessions(state.
|
|
2485
|
+
const sessions = getProjectSessions(state.userId, state.currentSession.name);
|
|
1992
2486
|
if (sessions.length === 0) return send("No past conversations for this project.");
|
|
1993
2487
|
const rows = sessions.slice(0, 10).map((s) => {
|
|
1994
2488
|
const date = new Date(s.lastUsed).toLocaleDateString();
|
|
@@ -2202,6 +2696,11 @@ bot.onText(/\/stop/, wrapHandler(async (msg) => {
|
|
|
2202
2696
|
else await send("Nothing running.");
|
|
2203
2697
|
}));
|
|
2204
2698
|
|
|
2699
|
+
bot.onText(/\/(?:doctor|requirements)$/, wrapHandler(async (msg) => {
|
|
2700
|
+
if (!isAuthorized(msg)) return;
|
|
2701
|
+
await send(formatDoctorReport(runDoctorChecks()));
|
|
2702
|
+
}));
|
|
2703
|
+
|
|
2205
2704
|
bot.onText(/\/status/, wrapHandler((msg) => {
|
|
2206
2705
|
if (!isAuthorized(msg)) return;
|
|
2207
2706
|
const state = currentState();
|
|
@@ -2348,6 +2847,42 @@ bot.onText(/\/clear_oauth_token$/, wrapHandler(async (msg) => {
|
|
|
2348
2847
|
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
2848
|
}));
|
|
2350
2849
|
|
|
2850
|
+
bot.onText(/\/(?:codex_auth_status|codex auth status)$/, wrapHandler(async (msg) => {
|
|
2851
|
+
if (!isAuthorized(msg)) return;
|
|
2852
|
+
await sendCodexAuthStatusSummary();
|
|
2853
|
+
}));
|
|
2854
|
+
|
|
2855
|
+
bot.onText(/\/codex_login$/, wrapHandler(async (msg) => {
|
|
2856
|
+
if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
|
|
2857
|
+
await runCodexDeviceLogin();
|
|
2858
|
+
}));
|
|
2859
|
+
|
|
2860
|
+
bot.onText(/\/cancel_codex_auth$/, wrapHandler(async (msg) => {
|
|
2861
|
+
if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
|
|
2862
|
+
const state = currentState();
|
|
2863
|
+
if (!state.pendingCodexAuthProcess) return send("No Codex auth flow is pending.");
|
|
2864
|
+
clearPendingCodexAuth(state);
|
|
2865
|
+
await send("Codex auth flow cancelled. Normal messages will go to the assistant again.");
|
|
2866
|
+
}));
|
|
2867
|
+
|
|
2868
|
+
bot.onText(/\/(?:codex_setup_token|codex_use_api_key)(?:\s+(.+))?$/, wrapHandler(async (msg, match) => {
|
|
2869
|
+
if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
|
|
2870
|
+
if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
|
|
2871
|
+
const key = (match[1] || "").trim();
|
|
2872
|
+
await deleteMessage(msg.message_id);
|
|
2873
|
+
if (!key) {
|
|
2874
|
+
const state = currentState();
|
|
2875
|
+
state.pendingCodexAuthProcess = { kill: () => {} };
|
|
2876
|
+
state.pendingCodexAuthLabel = "manual OpenAI API key save";
|
|
2877
|
+
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.");
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
if (!looksLikeOpenAIKey(key)) return send("That does not look like an OpenAI API key. Not saved.");
|
|
2881
|
+
const result = await saveCodexApiKeyWithCli(key);
|
|
2882
|
+
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)}`);
|
|
2883
|
+
await sendCodexAuthStatusSummary("Current Codex auth status:");
|
|
2884
|
+
}));
|
|
2885
|
+
|
|
2351
2886
|
// ── /vault with password protection ─────────────────────────────────
|
|
2352
2887
|
// Vault is a single shared store. Lock state is global; the
|
|
2353
2888
|
// password-unlock flow is per-user (pendingVaultUnlock lives on the
|
|
@@ -2450,6 +2985,58 @@ bot.on("callback_query", wrapHandler(async (q) => {
|
|
|
2450
2985
|
await bot.answerCallbackQuery(q.id);
|
|
2451
2986
|
const state = currentState();
|
|
2452
2987
|
|
|
2988
|
+
if (d.startsWith("auth:")) {
|
|
2989
|
+
const callbackFromOwner = String(q.from?.id) === CHAT_ID || String(q.message?.chat?.id) === CHAT_ID;
|
|
2990
|
+
if (!callbackFromOwner) {
|
|
2991
|
+
await send("Owner only — auth approvals are restricted.");
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
const [, action, chatId] = d.split(":");
|
|
2996
|
+
if (!chatId || !["approve", "deny"].includes(action)) return;
|
|
2997
|
+
|
|
2998
|
+
const result = action === "approve"
|
|
2999
|
+
? await approveAuthRequest(chatId)
|
|
3000
|
+
: await denyAuthRequest(chatId);
|
|
3001
|
+
|
|
3002
|
+
if (result.status === "not_found") {
|
|
3003
|
+
await send(`No pending auth request found for ${chatId}.`);
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
if (result.status === "already_authorized") {
|
|
3008
|
+
await send(`Chat ${chatId} is already authorized.`);
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
const label = authRequestLabel(result.request);
|
|
3013
|
+
if (result.status === "approved") {
|
|
3014
|
+
await bot.sendMessage(chatId, "Your access has been approved! You can now use the bot. Send /start to begin.").catch(() => {});
|
|
3015
|
+
let edited = false;
|
|
3016
|
+
if (q.message) {
|
|
3017
|
+
edited = await bot.editMessageText(`Approved auth request from ${label} (${chatId}).`, {
|
|
3018
|
+
chat_id: q.message.chat.id,
|
|
3019
|
+
message_id: q.message.message_id,
|
|
3020
|
+
reply_markup: { inline_keyboard: [] },
|
|
3021
|
+
}).then(() => true).catch(() => false);
|
|
3022
|
+
}
|
|
3023
|
+
if (!edited) await send(`Approved ${label} (${chatId}).`);
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
await bot.sendMessage(chatId, "Your access request was denied.").catch(() => {});
|
|
3028
|
+
let edited = false;
|
|
3029
|
+
if (q.message) {
|
|
3030
|
+
edited = await bot.editMessageText(`Denied auth request from ${label} (${chatId}).`, {
|
|
3031
|
+
chat_id: q.message.chat.id,
|
|
3032
|
+
message_id: q.message.message_id,
|
|
3033
|
+
reply_markup: { inline_keyboard: [] },
|
|
3034
|
+
}).then(() => true).catch(() => false);
|
|
3035
|
+
}
|
|
3036
|
+
if (!edited) await send(`Denied ${label} (${chatId}).`);
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
2453
3040
|
// Onboarding style selection
|
|
2454
3041
|
if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
|
|
2455
3042
|
|
|
@@ -2560,7 +3147,7 @@ bot.on("voice", wrapHandler(async (msg) => {
|
|
|
2560
3147
|
if (msg.voice.file_size && msg.voice.file_size > MAX_VOICE_SIZE) {
|
|
2561
3148
|
return send(`Voice note too large (${Math.round(msg.voice.file_size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
|
|
2562
3149
|
}
|
|
2563
|
-
bot.sendChatAction(
|
|
3150
|
+
bot.sendChatAction(currentChatId(), "typing");
|
|
2564
3151
|
const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
|
|
2565
3152
|
const transcript = transcribeAudio(oggPath);
|
|
2566
3153
|
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
@@ -2577,7 +3164,7 @@ bot.on("audio", wrapHandler(async (msg) => {
|
|
|
2577
3164
|
if (!requireSession(msg)) return;
|
|
2578
3165
|
const state = currentState();
|
|
2579
3166
|
try {
|
|
2580
|
-
bot.sendChatAction(
|
|
3167
|
+
bot.sendChatAction(currentChatId(), "typing");
|
|
2581
3168
|
const p = await downloadFile(msg.audio.file_id, path.extname(msg.audio.file_name || ".ogg"));
|
|
2582
3169
|
const t = transcribeAudio(p);
|
|
2583
3170
|
try { fs.unlinkSync(p); } catch (e) {}
|
|
@@ -2641,6 +3228,27 @@ bot.on("message", wrapHandler(async (msg) => {
|
|
|
2641
3228
|
if (isDuplicate(msg)) return;
|
|
2642
3229
|
const state = currentState();
|
|
2643
3230
|
|
|
3231
|
+
// Handle pending manual Codex API key paste mode.
|
|
3232
|
+
if (state.pendingCodexAuthProcess && state.pendingCodexAuthLabel === "manual OpenAI API key save") {
|
|
3233
|
+
const text = msg.text.trim();
|
|
3234
|
+
await deleteMessage(msg.message_id);
|
|
3235
|
+
if (!looksLikeOpenAIKey(text)) {
|
|
3236
|
+
clearPendingCodexAuth(state);
|
|
3237
|
+
await send("That did not look like an OpenAI API key. Not saved.");
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3240
|
+
clearPendingCodexAuth(state);
|
|
3241
|
+
const result = await saveCodexApiKeyWithCli(text);
|
|
3242
|
+
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)}`);
|
|
3243
|
+
await sendCodexAuthStatusSummary("Current Codex auth status:");
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
if (state.pendingCodexAuthProcess) {
|
|
3248
|
+
await send("Codex login is still running. Complete the device flow in your browser, or send /cancel_codex_auth.");
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
|
|
2644
3252
|
// Handle pending manual OAuth token paste mode. Login codes must be sent explicitly
|
|
2645
3253
|
// with /auth_code so normal chat can never be deleted/consumed accidentally.
|
|
2646
3254
|
if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel === "manual OAuth token save") {
|