@inetafrica/open-claudia 1.20.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/router.js ADDED
@@ -0,0 +1,309 @@
1
+ // Inbound router. Takes a normalised envelope from any adapter, runs
2
+ // the right scope (chat context, dedup, auth gates), and dispatches to
3
+ // commands / media / runner.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { runInChat } = require("./context");
8
+ const { dispatch } = require("./commands");
9
+ const { handleAction } = require("./actions");
10
+ const { isChatAuthorized } = require("./access");
11
+ const { send, deleteMessage } = require("./io");
12
+ const { currentState } = require("./state");
13
+ const { runClaude } = require("./runner");
14
+ const { transcribeAudio } = require("./media");
15
+ const { vault } = require("./vault-store");
16
+ const { isOnboarded, handleOnboarding } = require("./onboarding");
17
+ const {
18
+ looksLikeOpenAIKey, looksLikeClaudeToken, looksLikeClaudeAuthReply,
19
+ saveClaudeOAuthToken, saveCodexApiKeyWithCli,
20
+ clearPendingClaudeAuth, clearPendingCodexAuth,
21
+ sendClaudeAuthStatusSummary, sendCodexAuthStatusSummary,
22
+ } = require("./auth-flow");
23
+ const { redactSensitive } = require("./redact");
24
+ const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
25
+ const channelWizard = require("./channel-wizard");
26
+ require("./handlers"); // side-effect: register slash commands
27
+
28
+ // Per-adapter dedup. Telegram message_ids are unique per chat, not
29
+ // globally; same goes for Kazee. Namespace by adapter:channel:msg.
30
+ const processedMessages = new Set();
31
+ function isDuplicate(envelope) {
32
+ const key = `${envelope.adapter?.id || envelope.adapter?.type}:${envelope.channelId}:${envelope.messageId}`;
33
+ if (processedMessages.has(key)) return true;
34
+ processedMessages.add(key);
35
+ if (processedMessages.size > 400) {
36
+ const arr = [...processedMessages];
37
+ processedMessages.clear();
38
+ arr.slice(-200).forEach((k) => processedMessages.add(k));
39
+ }
40
+ return false;
41
+ }
42
+
43
+ function scope(envelope, fn) {
44
+ return runInChat({
45
+ adapter: envelope.adapter,
46
+ channelId: envelope.channelId,
47
+ canonicalUserId: envelope.canonicalUserId,
48
+ raw: envelope.raw,
49
+ }, fn);
50
+ }
51
+
52
+ async function onMessage(envelope) {
53
+ return scope(envelope, async () => {
54
+ try {
55
+ // While a channel wizard is open, capture text input before slash
56
+ // dispatch — except for /cancel, which always bails out.
57
+ if (channelWizard.isAwaiting(envelope.canonicalUserId)) {
58
+ const raw = (envelope.text || "").trim();
59
+ if (raw.toLowerCase() === "/cancel") {
60
+ await channelWizard.cancel(envelope.canonicalUserId);
61
+ return;
62
+ }
63
+ if (envelope.type === "text" || envelope.type === "command") {
64
+ if (isDuplicate(envelope)) return;
65
+ await channelWizard.handleText(envelope);
66
+ return;
67
+ }
68
+ }
69
+
70
+ // Slash commands route through the registry first so the
71
+ // /auth handler can run for unauthorized users.
72
+ if (envelope.type === "command" || (envelope.text && envelope.text.startsWith("/"))) {
73
+ const result = await dispatch(envelope.text, envelope);
74
+ if (result.matched) return;
75
+ // Unknown slash: keep silent rather than spamming.
76
+ return;
77
+ }
78
+
79
+ if (!isChatAuthorized(envelope.channelId)) return;
80
+
81
+ if (envelope.type === "voice") return handleVoice(envelope);
82
+ if (envelope.type === "audio") return handleAudio(envelope);
83
+ if (envelope.type === "photo") return handlePhoto(envelope);
84
+ if (envelope.type === "document") return handleDocument(envelope);
85
+
86
+ if (envelope.type === "text") return handleText(envelope);
87
+ } catch (e) {
88
+ console.error("router:onMessage:", e.message);
89
+ }
90
+ });
91
+ }
92
+
93
+ async function onAction(envelope) {
94
+ return scope(envelope, async () => {
95
+ try {
96
+ if (await channelWizard.handleAction(envelope)) return;
97
+ await handleAction(envelope);
98
+ }
99
+ catch (e) { console.error("router:onAction:", e.message); }
100
+ });
101
+ }
102
+
103
+ async function handleVoice(envelope) {
104
+ if (isDuplicate(envelope)) return;
105
+ const state = currentState();
106
+ if (!state.currentSession) return send("Pick a project first.");
107
+ try {
108
+ const media = envelope.media?.[0];
109
+ if (!media) return;
110
+ if (media.size && media.size > MAX_VOICE_SIZE) {
111
+ return send(`Voice note too large (${Math.round(media.size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
112
+ }
113
+ envelope.adapter.typing(envelope.channelId).catch(() => {});
114
+ const oggPath = await envelope.adapter.downloadMedia(media);
115
+ const transcript = transcribeAudio(oggPath);
116
+ try { fs.unlinkSync(oggPath); } catch (e) {}
117
+ if (!transcript) return send("Couldn't transcribe. Try typing it.");
118
+ await send(`Heard: "${transcript}"`, { replyTo: envelope.messageId });
119
+ state.lastInputWasVoice = true;
120
+ await runClaude(transcript, state.currentSession.dir, envelope.messageId);
121
+ } catch (err) { await send(`Voice failed: ${err.message}`); }
122
+ }
123
+
124
+ async function handleAudio(envelope) {
125
+ if (isDuplicate(envelope)) return;
126
+ const state = currentState();
127
+ if (!state.currentSession) return send("Pick a project first.");
128
+ try {
129
+ const media = envelope.media?.[0];
130
+ if (!media) return;
131
+ envelope.adapter.typing(envelope.channelId).catch(() => {});
132
+ const p = await envelope.adapter.downloadMedia(media);
133
+ const t = transcribeAudio(p);
134
+ try { fs.unlinkSync(p); } catch (e) {}
135
+ if (!t) return send("Couldn't transcribe.");
136
+ await send(`Heard: "${t}"`, { replyTo: envelope.messageId });
137
+ await runClaude(t, state.currentSession.dir, envelope.messageId);
138
+ } catch (err) { await send(`Audio failed: ${err.message}`); }
139
+ }
140
+
141
+ async function handlePhoto(envelope) {
142
+ if (isDuplicate(envelope)) return;
143
+ const state = currentState();
144
+ if (!state.currentSession) return send("Pick a project first.");
145
+ try {
146
+ const media = envelope.media?.[0];
147
+ if (!media) return;
148
+ const p = await envelope.adapter.downloadMedia(media);
149
+ const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
150
+ await runClaude(`Image at ${p}\n\nView it, then: ${caption}`, state.currentSession.dir, envelope.messageId);
151
+ } catch (err) { await send(`Image failed: ${err.message}`); }
152
+ }
153
+
154
+ async function handleDocument(envelope) {
155
+ if (isDuplicate(envelope)) return;
156
+ const state = currentState();
157
+ if (!state.currentSession) return send("Pick a project first.");
158
+ try {
159
+ const media = envelope.media?.[0];
160
+ if (!media) return;
161
+ if (media.size && media.size > MAX_FILE_SIZE) {
162
+ return send(`File too large (${Math.round(media.size / 1024 / 1024)}MB). Max: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
163
+ }
164
+ const savePath = await envelope.adapter.downloadMedia(media);
165
+ const fileName = media.fileName || path.basename(savePath);
166
+ const mime = media.mimeType || "";
167
+ const caption = envelope.caption || "";
168
+ const isImage = mime.startsWith("image/");
169
+ const prompt = isImage
170
+ ? `Image file saved at ${savePath}\n\nView it, then: ${caption || "Describe this image. If code/UI/error — explain and fix."}`
171
+ : `File received: ${fileName} (${mime})\nSaved at: ${savePath}\n\nRead this file and ${caption || "summarize its contents. If it's code, explain what it does. If it's a document, give key points."}`;
172
+ await send(`File saved: ${fileName}`, { replyTo: envelope.messageId });
173
+ await runClaude(prompt, state.currentSession.dir, envelope.messageId);
174
+ } catch (err) { await send(`Failed: ${err.message}`); }
175
+ }
176
+
177
+ async function handleText(envelope) {
178
+ if (isDuplicate(envelope)) return;
179
+ const state = currentState();
180
+
181
+ // Pending Codex API-key paste mode.
182
+ if (state.pendingCodexAuthProcess && state.pendingCodexAuthLabel === "manual OpenAI API key save") {
183
+ const text = (envelope.text || "").trim();
184
+ await deleteMessage(envelope.messageId);
185
+ if (!looksLikeOpenAIKey(text)) {
186
+ clearPendingCodexAuth(state);
187
+ await send("That did not look like an OpenAI API key. Not saved.");
188
+ return;
189
+ }
190
+ clearPendingCodexAuth(state);
191
+ const result = await saveCodexApiKeyWithCli(text);
192
+ 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)}`);
193
+ await sendCodexAuthStatusSummary("Current Codex auth status:");
194
+ return;
195
+ }
196
+
197
+ if (state.pendingCodexAuthProcess) {
198
+ await send("Codex login is still running. Complete the device flow in your browser, or send /cancel_codex_auth.");
199
+ return;
200
+ }
201
+
202
+ if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel === "manual OAuth token save") {
203
+ const text = (envelope.text || "").trim();
204
+ if (looksLikeClaudeToken(text)) {
205
+ await deleteMessage(envelope.messageId);
206
+ clearPendingClaudeAuth(state);
207
+ saveClaudeOAuthToken(text);
208
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
209
+ await sendClaudeAuthStatusSummary("Stored token. Current Claude auth status:");
210
+ return;
211
+ }
212
+ await send("Token paste mode is active, but that does not look like a Claude OAuth token. I left your message visible and will handle it normally. Send /cancel_auth to stop token paste mode.");
213
+ }
214
+
215
+ if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel !== "manual OAuth token save") {
216
+ const text = (envelope.text || "").trim();
217
+ if (looksLikeClaudeAuthReply(text)) {
218
+ await send("That looks like a Claude login code/callback. I did not delete it or send it to Claude as a prompt. Please resend it as `/auth_code YOUR_CODE`, or use /cancel_auth to stop the login flow.");
219
+ return;
220
+ }
221
+ await send("Claude login is still waiting. I will not delete normal messages. If Claude gave you a code, send it as `/auth_code YOUR_CODE`, or use /cancel_auth.");
222
+ }
223
+
224
+ if (!isOnboarded() && state.onboardingStep) {
225
+ await handleOnboarding({ text: envelope.text });
226
+ return;
227
+ }
228
+
229
+ if (state.pendingVaultUnlock) {
230
+ const password = envelope.text;
231
+ await deleteMessage(envelope.messageId);
232
+ const ok = vault.unlock(password);
233
+ if (!ok) {
234
+ state.pendingVaultUnlock = false;
235
+ state.pendingVaultAction = null;
236
+ await send("Wrong password.");
237
+ return;
238
+ }
239
+ const action = state.pendingVaultAction;
240
+ state.pendingVaultUnlock = false;
241
+ state.pendingVaultAction = null;
242
+ if (action.type === "list") {
243
+ const entries = vault.list();
244
+ const keys = Object.keys(entries);
245
+ if (keys.length === 0) await send("Vault unlocked (empty).\n\nUse /vault set <name> <value>");
246
+ else await send("Vault unlocked:\n\n" + keys.map((k) => `${k}: ${entries[k]}`).join("\n") + "\n\nAuto-locks in 5 min.");
247
+ } else if (action.type === "set") {
248
+ vault.set(action.key, action.value);
249
+ await send(`Saved: ${action.key}`);
250
+ } else if (action.type === "remove") {
251
+ vault.remove(action.key);
252
+ await send(`Removed: ${action.key}`);
253
+ }
254
+ return;
255
+ }
256
+
257
+ if (!state.currentSession) {
258
+ const { projectKeyboard } = require("./projects");
259
+ return send("Pick a project first:", { keyboard: projectKeyboard() });
260
+ }
261
+
262
+ const text = envelope.text || "";
263
+ const credPatterns = [
264
+ /^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/,
265
+ /^[A-Za-z0-9_-]{20,}$/,
266
+ /^(Bearer |token:|key:|secret:|password:)/i,
267
+ ];
268
+ if (credPatterns.some((p) => p.test(text.trim()))) {
269
+ await deleteMessage(envelope.messageId);
270
+ }
271
+
272
+ let prompt = text;
273
+ const reply = envelope.reply;
274
+ const replyFromBot = reply?.from?.is_bot;
275
+ const skipReplyContext = replyFromBot && !reply.document && !reply.photo;
276
+ if (reply && !skipReplyContext) {
277
+ let ctx = "";
278
+ if (reply.text) ctx = reply.text;
279
+ if (reply.caption) ctx += (ctx ? "\n" : "") + reply.caption;
280
+ if (reply.document) {
281
+ const fileName = reply.document.file_name || reply.document.fileName || "unknown";
282
+ const filePath = path.join(FILES_DIR, fileName);
283
+ if (fs.existsSync(filePath)) {
284
+ ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
285
+ } else if (reply.document.file_id) {
286
+ // Attempt to fetch via adapter so this works on every channel.
287
+ try {
288
+ const localPath = await envelope.adapter.downloadMedia({
289
+ type: "document",
290
+ fileId: reply.document.file_id,
291
+ fileName,
292
+ mimeType: reply.document.mime_type,
293
+ });
294
+ ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${localPath}]`;
295
+ } catch (e) {
296
+ ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} — could not download]`;
297
+ }
298
+ } else {
299
+ ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName}]`;
300
+ }
301
+ }
302
+ if (reply.photo) ctx += (ctx ? "\n" : "") + "[Attached photo]";
303
+ if (ctx) prompt = `Replying to message:\n---\n${ctx.length > 1000 ? ctx.slice(0, 1000) + "..." : ctx}\n---\n\n${text}`;
304
+ }
305
+
306
+ await runClaude(prompt, state.currentSession.dir, envelope.messageId);
307
+ }
308
+
309
+ module.exports = { onMessage, onAction };