@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.
@@ -0,0 +1,180 @@
1
+ // Button-press router. Handles callback_data from Telegram and the same
2
+ // strings from Kazee's interactive buttons. The payload format is the
3
+ // same on both: short prefix-coded strings already in use today.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { CONFIG_DIR, WORKSPACE, CHAT_ID, resolvedCursorPath, resolvedCodexPath } = require("./config");
8
+ const { send } = require("./io");
9
+ const { currentState, resetSettings, resetSessionUsage, saveState } = require("./state");
10
+ const { runClaude, getActiveSessionId } = require("./runner");
11
+ const { listProjects, projectKeyboard, workspacePath } = require("./projects");
12
+ const { isChatOwner, approveAuthRequest, denyAuthRequest, authRequestLabel } = require("./access");
13
+ const { finishOnboarding } = require("./onboarding");
14
+ const { startSession } = require("./handlers");
15
+ const { activeCrons, loadCrons, saveCrons, scheduleCron } = require("./cron");
16
+
17
+ async function handleAction(envelope) {
18
+ const adapter = envelope.adapter;
19
+ const d = envelope.action?.payload || envelope.action?.buttonId || "";
20
+ // Telegram needs us to ack the callback so the client stops spinning.
21
+ if (adapter && adapter.type === "telegram" && envelope.action?.callbackId) {
22
+ try { await adapter.bot.answerCallbackQuery(envelope.action.callbackId); } catch (e) {}
23
+ }
24
+ const state = currentState();
25
+
26
+ if (d.startsWith("auth:")) {
27
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — auth approvals are restricted.");
28
+ const [, action, chatId] = d.split(":");
29
+ if (!chatId || !["approve", "deny"].includes(action)) return;
30
+ const result = action === "approve" ? approveAuthRequest(chatId) : denyAuthRequest(chatId);
31
+
32
+ if (result.status === "not_found") return send(`No pending auth request found for ${chatId}.`);
33
+ if (result.status === "already_authorized") return send(`Chat ${chatId} is already authorized.`);
34
+
35
+ const label = authRequestLabel(result.request);
36
+ if (result.status === "approved") {
37
+ try { await adapter.send(chatId, "Your access has been approved! You can now use the bot. Send /start to begin."); } catch (e) {}
38
+ if (envelope.action.sourceMessageId) {
39
+ await adapter.edit(envelope.channelId, envelope.action.sourceMessageId, `Approved auth request from ${label} (${chatId}).`, {});
40
+ } else {
41
+ await send(`Approved ${label} (${chatId}).`);
42
+ }
43
+ return;
44
+ }
45
+ try { await adapter.send(chatId, "Your access request was denied."); } catch (e) {}
46
+ if (envelope.action.sourceMessageId) {
47
+ await adapter.edit(envelope.channelId, envelope.action.sourceMessageId, `Denied auth request from ${label} (${chatId}).`, {});
48
+ } else {
49
+ await send(`Denied ${label} (${chatId}).`);
50
+ }
51
+ return;
52
+ }
53
+
54
+ if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
55
+
56
+ if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
57
+ if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
58
+ if (d.startsWith("ss:")) { if (state.currentSession) startSession(state.currentSession.name, d.slice(3)); return; }
59
+ if (d.startsWith("new:")) {
60
+ const proj = d.slice(4);
61
+ state.currentSession = { name: proj === "__workspace__" ? "Workspace" : proj, dir: workspacePath(proj) };
62
+ state.lastSessionId = null;
63
+ state.isFirstMessage = true;
64
+ state.messageQueue = [];
65
+ resetSettings(state);
66
+ resetSessionUsage(state);
67
+ saveState();
68
+ await send(`Session: ${state.currentSession.name}\nNew conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
69
+ return;
70
+ }
71
+ if (d === "a:continue") { if (state.currentSession && getActiveSessionId()) await runClaude("continue", state.currentSession.dir); else send("No session to continue."); return; }
72
+ if (d === "a:end") {
73
+ if (state.currentSession) {
74
+ const n = state.currentSession.name;
75
+ state.currentSession = null;
76
+ state.lastSessionId = null;
77
+ state.messageQueue = [];
78
+ resetSettings(state);
79
+ resetSessionUsage(state);
80
+ saveState();
81
+ await send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } });
82
+ }
83
+ return;
84
+ }
85
+ if (d === "noop") return;
86
+ if (d.startsWith("mb:")) {
87
+ const rest = d.slice(3);
88
+ const colon = rest.indexOf(":");
89
+ if (colon < 0) return;
90
+ const be = rest.slice(0, colon);
91
+ const model = rest.slice(colon + 1);
92
+ if (be === "cursor" && !resolvedCursorPath) { await send("Cursor Agent CLI not found."); return; }
93
+ if (be === "codex" && !resolvedCodexPath) { await send("Codex CLI not found. Install: npm install -g @openai/codex"); return; }
94
+ const switched = state.settings.backend !== be;
95
+ state.settings.backend = be;
96
+ state.settings.model = model;
97
+ saveState();
98
+ const beLabel = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
99
+ await send(switched ? `Switched to ${beLabel}.\nModel: ${model}` : `Model: ${model}`);
100
+ return;
101
+ }
102
+ if (d.startsWith("m:")) { state.settings.model = d.slice(2) === "default" ? null : d.slice(2); await send(`Model: ${state.settings.model || "default"}`); return; }
103
+ if (d.startsWith("e:")) { const e = d.slice(2); state.settings.effort = e === "default" ? null : e; await send(`Effort: ${state.settings.effort || "default"}`); return; }
104
+ if (d.startsWith("b:")) { const b = d.slice(2); state.settings.budget = b === "none" ? null : parseFloat(b); await send(`Budget: ${state.settings.budget ? "$" + state.settings.budget : "none"}`); return; }
105
+ if (d.startsWith("be:")) {
106
+ const be = d.slice(3);
107
+ if (be === "cursor" && !resolvedCursorPath) { await send("Cursor Agent CLI not found."); return; }
108
+ if (be === "codex" && !resolvedCodexPath) { await send("Codex CLI not found. Install: npm install -g @openai/codex"); return; }
109
+ state.settings.backend = be;
110
+ state.settings.model = null;
111
+ saveState();
112
+ const label = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
113
+ await send(`Switched to ${label}.`);
114
+ return;
115
+ }
116
+
117
+ if (d.startsWith("mode:")) {
118
+ if (!isChatOwner(envelope.channelId)) { await send("Owner only — mode switch restarts the bot."); return; }
119
+ const newMode = d.slice(5);
120
+ const modeFile = path.join(CONFIG_DIR, ".bot-mode");
121
+ fs.writeFileSync(modeFile, newMode);
122
+ await send(`Switching to ${newMode} mode... restarting.`);
123
+ setTimeout(() => process.exit(0), 500);
124
+ return;
125
+ }
126
+
127
+ if (d.startsWith("cp:") && d !== "cp:clear") {
128
+ if (!state.currentSession) return send("Start a session first.");
129
+ const presets = {
130
+ standup: { schedule: "0 9 * * 1-5", prompt: "Morning standup: recent commits, failing tests, open TODOs, what to focus on today. Brief.", label: "Morning standup" },
131
+ git: { schedule: "0 18 * * 1-5", prompt: "Git digest: today's commits, changed files, uncommitted changes. Flag concerns.", label: "Git digest" },
132
+ deps: { schedule: "0 10 * * 1", prompt: "Check outdated/vulnerable dependencies. Brief — just what needs attention.", label: "Dep check" },
133
+ health: { schedule: "*/30 * * * *", prompt: "Quick health: can the project build? Run build/lint, report pass/fail.", label: "Health check" },
134
+ };
135
+ const p = presets[d.slice(3)];
136
+ if (!p) return;
137
+ const c = { id: `cron_${Date.now()}`, ...p, project: state.currentSession.name };
138
+ const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
139
+ await send(`Added: ${c.label} for ${state.currentSession.name}`);
140
+ return;
141
+ }
142
+ if (d === "cp:clear") {
143
+ for (const [, v] of activeCrons) v.task.stop();
144
+ activeCrons.clear();
145
+ saveCrons([]);
146
+ await send("All crons cleared.");
147
+ return;
148
+ }
149
+
150
+ if (d.startsWith("chn:")) {
151
+ if (!isChatOwner(envelope.channelId)) { await send("Owner only."); return; }
152
+ const wizard = require("./channel-wizard");
153
+ const registry = require("./adapter-registry");
154
+ if (d === "chn:add:kazee") { await wizard.start(envelope.canonicalUserId, "kazee"); return; }
155
+ if (d.startsWith("chn:rm:")) {
156
+ const id = d.slice("chn:rm:".length);
157
+ if (id === "telegram") { await send("Refusing to remove the Telegram adapter."); return; }
158
+ const result = await registry.removeAdapter(id);
159
+ if (!result.ok) { await send(result.error); return; }
160
+ const { config, saveEnvKey } = require("./config");
161
+ const channels = (config.CHANNELS || "telegram").split(",").map((s) => s.trim()).filter(Boolean);
162
+ const kept = channels.filter((c) => c !== id && !c.startsWith(id + ":") && c !== id.split(":")[0]);
163
+ const next = kept.join(",") || "telegram";
164
+ saveEnvKey("CHANNELS", next);
165
+ config.CHANNELS = next;
166
+ if (id === "kazee") {
167
+ saveEnvKey("KAZEE_URL", "");
168
+ saveEnvKey("KAZEE_BOT_TOKEN", "");
169
+ saveEnvKey("KAZEE_OWNER_USER_ID", "");
170
+ config.KAZEE_URL = "";
171
+ config.KAZEE_BOT_TOKEN = "";
172
+ config.KAZEE_OWNER_USER_ID = "";
173
+ }
174
+ await send(`Removed channel: ${id}`);
175
+ return;
176
+ }
177
+ }
178
+ }
179
+
180
+ module.exports = { handleAction };
@@ -0,0 +1,113 @@
1
+ // Runtime registry of live ChannelAdapter instances.
2
+ //
3
+ // bot.js used to construct, boot, and tear down adapters in-line. That
4
+ // worked for a static CHANNELS=telegram,kazee list at startup, but the
5
+ // /channel command needs to add and remove adapters while the bot keeps
6
+ // running. This module owns those mechanics — including the cron
7
+ // dispatch hookup — so callers add/remove without each one having to
8
+ // remember to re-wire everything.
9
+
10
+ const { loadChannels } = require("./config");
11
+ const { setAdapters } = require("./cron");
12
+
13
+ const { TelegramAdapter } = require("../channels/telegram/adapter");
14
+ const { KazeeAdapter } = require("../channels/kazee/adapter");
15
+
16
+ const adapters = [];
17
+ let messageHandler = null;
18
+ let actionHandler = null;
19
+
20
+ function createAdapter(spec) {
21
+ if (spec.type === "telegram") return new TelegramAdapter({ id: spec.id, ...spec.opts });
22
+ if (spec.type === "kazee") return new KazeeAdapter({ id: spec.id, ...spec.opts });
23
+ console.error(`Unknown adapter type: ${spec.type}`);
24
+ return null;
25
+ }
26
+
27
+ function notifyCron() {
28
+ setAdapters(adapters);
29
+ }
30
+
31
+ function wireAdapter(adapter) {
32
+ if (messageHandler) adapter.on("message", messageHandler);
33
+ if (actionHandler) adapter.on("action", actionHandler);
34
+ }
35
+
36
+ function setHandlers({ onMessage, onAction }) {
37
+ messageHandler = onMessage;
38
+ actionHandler = onAction;
39
+ for (const a of adapters) wireAdapter(a);
40
+ }
41
+
42
+ // Construct (but do not start) every adapter declared in CHANNELS.
43
+ // Returns the array of constructed adapters so bot.js can keep its
44
+ // boot-failure messaging in one place.
45
+ function bootstrap() {
46
+ adapters.length = 0;
47
+ for (const spec of loadChannels()) {
48
+ try {
49
+ const a = createAdapter(spec);
50
+ if (a) {
51
+ adapters.push(a);
52
+ wireAdapter(a);
53
+ }
54
+ } catch (e) {
55
+ console.error(`Adapter ${spec.type} failed to construct:`, e.message);
56
+ }
57
+ }
58
+ notifyCron();
59
+ return adapters;
60
+ }
61
+
62
+ function getAdapters() {
63
+ return adapters.slice();
64
+ }
65
+
66
+ function findAdapter(id) {
67
+ return adapters.find((a) => a.id === id) || null;
68
+ }
69
+
70
+ // Add a freshly-validated channel and boot it. The caller is responsible
71
+ // for having already written its credentials to .env. On start failure
72
+ // we tear the adapter back down and return the error so the caller can
73
+ // roll the .env edit back.
74
+ async function addAdapter(spec, { publicCommands } = {}) {
75
+ if (findAdapter(spec.id)) {
76
+ return { ok: false, error: `Adapter ${spec.id} already exists` };
77
+ }
78
+ const adapter = createAdapter(spec);
79
+ if (!adapter) return { ok: false, error: `Unknown adapter type: ${spec.type}` };
80
+ adapters.push(adapter);
81
+ wireAdapter(adapter);
82
+ notifyCron();
83
+ try {
84
+ await adapter.start();
85
+ if (publicCommands) {
86
+ try { await adapter.registerCommands(publicCommands); } catch (e) { /* non-fatal */ }
87
+ }
88
+ return { ok: true, adapter };
89
+ } catch (e) {
90
+ const idx = adapters.indexOf(adapter);
91
+ if (idx >= 0) adapters.splice(idx, 1);
92
+ notifyCron();
93
+ return { ok: false, error: e.message || String(e) };
94
+ }
95
+ }
96
+
97
+ async function removeAdapter(id) {
98
+ const idx = adapters.findIndex((a) => a.id === id);
99
+ if (idx < 0) return { ok: false, error: `No adapter ${id}` };
100
+ const [adapter] = adapters.splice(idx, 1);
101
+ notifyCron();
102
+ try { await adapter.stop(); } catch (e) {}
103
+ return { ok: true };
104
+ }
105
+
106
+ module.exports = {
107
+ bootstrap,
108
+ setHandlers,
109
+ getAdapters,
110
+ findAdapter,
111
+ addAdapter,
112
+ removeAdapter,
113
+ };