@inetafrica/open-claudia 1.3.3 → 1.4.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/.dockerignore ADDED
@@ -0,0 +1,15 @@
1
+ node_modules
2
+ .env
3
+ vault.enc
4
+ vault.json
5
+ auth.json
6
+ crons.json
7
+ sessions.json
8
+ state.json
9
+ bot.log
10
+ *.log
11
+ .setup-state.json
12
+ .web-password
13
+ .telegram-media
14
+ .DS_Store
15
+ .git
package/Dockerfile ADDED
@@ -0,0 +1,33 @@
1
+ FROM node:20-slim
2
+
3
+ # Install system dependencies
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ curl \
6
+ ffmpeg \
7
+ ca-certificates \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Install Claude Code CLI
11
+ RUN curl -fsSL https://claude.ai/install.sh | sh || \
12
+ npm install -g @anthropic-ai/claude-code
13
+
14
+ # Create app directory
15
+ WORKDIR /app
16
+
17
+ # Copy package files and install
18
+ COPY package*.json ./
19
+ RUN npm ci --production
20
+
21
+ # Copy app source
22
+ COPY . .
23
+
24
+ # Config and data volume
25
+ ENV HOME=/data
26
+ ENV WEB_UI=true
27
+ ENV WEB_PORT=8080
28
+ VOLUME /data
29
+
30
+ EXPOSE 8080
31
+
32
+ # Start with web UI enabled
33
+ CMD ["node", "bin/cli.js", "web"]
package/bin/cli.js CHANGED
@@ -40,9 +40,26 @@ switch (command) {
40
40
 
41
41
  case "start":
42
42
  console.log("Starting Open Claudia...");
43
+ // Start web UI alongside bot if WEB_UI=true or --web flag
44
+ if (process.env.WEB_UI === "true" || args.includes("--web")) {
45
+ const { startWebServer } = require(path.join(botDir, "web.js"));
46
+ startWebServer();
47
+ }
43
48
  require(path.join(botDir, "bot.js"));
44
49
  break;
45
50
 
51
+ case "web":
52
+ // Web UI only (for initial setup or config management)
53
+ const { startWebServer: startWeb, isConfigured } = require(path.join(botDir, "web.js"));
54
+ startWeb();
55
+ if (isConfigured()) {
56
+ console.log("Bot config found. Starting bot too...");
57
+ require(path.join(botDir, "bot.js"));
58
+ } else {
59
+ console.log("No config found. Complete setup in the web UI.");
60
+ }
61
+ break;
62
+
46
63
  case "auth":
47
64
  // Pass through to setup.js auth mode
48
65
  process.argv = [process.argv[0], process.argv[1], "--auth"];
@@ -88,7 +105,8 @@ Open Claudia — AI Coding Assistant via Telegram
88
105
  Commands:
89
106
  open-claudia setup Interactive setup wizard
90
107
  open-claudia auth Manage chat authorizations
91
- open-claudia start Start the bot
108
+ open-claudia start Start the bot (add --web for web UI)
109
+ open-claudia web Start with web UI for setup/config
92
110
  open-claudia stop Stop the bot
93
111
  open-claudia status Check if running
94
112
  open-claudia logs View recent logs
package/bot.js CHANGED
@@ -834,7 +834,20 @@ bot.onText(/\/restart$/, async (msg) => {
834
834
 
835
835
  bot.onText(/\/upgrade$/, async (msg) => {
836
836
  if (!isOwner(msg)) return;
837
- await send("Upgrading I'll be unreachable for a few seconds...");
837
+ // Check if there's actually a newer version
838
+ try {
839
+ const latest = execSync("npm view @inetafrica/open-claudia version", {
840
+ encoding: "utf-8", timeout: 15000,
841
+ env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
842
+ }).trim();
843
+ if (latest === CURRENT_VERSION) {
844
+ await send(`Already on the latest version (v${CURRENT_VERSION}).`);
845
+ return;
846
+ }
847
+ await send(`Upgrading v${CURRENT_VERSION} → v${latest}...`);
848
+ } catch (e) {
849
+ await send("Upgrading...");
850
+ }
838
851
  try {
839
852
  execSync("npm install -g @inetafrica/open-claudia@latest 2>&1", {
840
853
  encoding: "utf-8", timeout: 120000,
@@ -0,0 +1,61 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: open-claudia
5
+ labels:
6
+ app: open-claudia
7
+ spec:
8
+ replicas: 1
9
+ strategy:
10
+ type: Recreate # Only one instance can poll Telegram
11
+ selector:
12
+ matchLabels:
13
+ app: open-claudia
14
+ template:
15
+ metadata:
16
+ labels:
17
+ app: open-claudia
18
+ spec:
19
+ containers:
20
+ - name: open-claudia
21
+ image: registry.example.com/open-claudia:latest
22
+ ports:
23
+ - containerPort: 8080
24
+ name: web
25
+ env:
26
+ - name: WEB_UI
27
+ value: "true"
28
+ - name: WEB_PORT
29
+ value: "8080"
30
+ - name: ANTHROPIC_API_KEY
31
+ valueFrom:
32
+ secretKeyRef:
33
+ name: open-claudia-secrets
34
+ key: anthropic-api-key
35
+ optional: true
36
+ volumeMounts:
37
+ - name: data
38
+ mountPath: /data
39
+ resources:
40
+ requests:
41
+ memory: "256Mi"
42
+ cpu: "100m"
43
+ limits:
44
+ memory: "1Gi"
45
+ cpu: "1000m"
46
+ livenessProbe:
47
+ httpGet:
48
+ path: /api/status
49
+ port: 8080
50
+ initialDelaySeconds: 10
51
+ periodSeconds: 30
52
+ readinessProbe:
53
+ httpGet:
54
+ path: /api/status
55
+ port: 8080
56
+ initialDelaySeconds: 5
57
+ periodSeconds: 10
58
+ volumes:
59
+ - name: data
60
+ persistentVolumeClaim:
61
+ claimName: open-claudia-data
@@ -0,0 +1,26 @@
1
+ apiVersion: networking.k8s.io/v1
2
+ kind: Ingress
3
+ metadata:
4
+ name: open-claudia
5
+ labels:
6
+ app: open-claudia
7
+ annotations:
8
+ # Uncomment for cert-manager TLS
9
+ # cert-manager.io/cluster-issuer: letsencrypt-prod
10
+ spec:
11
+ rules:
12
+ - host: claudia.example.com # Replace with your domain
13
+ http:
14
+ paths:
15
+ - path: /
16
+ pathType: Prefix
17
+ backend:
18
+ service:
19
+ name: open-claudia
20
+ port:
21
+ number: 8080
22
+ # Uncomment for TLS
23
+ # tls:
24
+ # - hosts:
25
+ # - claudia.example.com
26
+ # secretName: open-claudia-tls
package/k8s/pvc.yaml ADDED
@@ -0,0 +1,12 @@
1
+ apiVersion: v1
2
+ kind: PersistentVolumeClaim
3
+ metadata:
4
+ name: open-claudia-data
5
+ labels:
6
+ app: open-claudia
7
+ spec:
8
+ accessModes:
9
+ - ReadWriteOnce
10
+ resources:
11
+ requests:
12
+ storage: 1Gi
@@ -0,0 +1,10 @@
1
+ apiVersion: v1
2
+ kind: Secret
3
+ metadata:
4
+ name: open-claudia-secrets
5
+ labels:
6
+ app: open-claudia
7
+ type: Opaque
8
+ stringData:
9
+ # Replace with your actual API key
10
+ anthropic-api-key: "sk-ant-your-key-here"
@@ -0,0 +1,14 @@
1
+ apiVersion: v1
2
+ kind: Service
3
+ metadata:
4
+ name: open-claudia
5
+ labels:
6
+ app: open-claudia
7
+ spec:
8
+ type: ClusterIP
9
+ ports:
10
+ - port: 8080
11
+ targetPort: 8080
12
+ name: web
13
+ selector:
14
+ app: open-claudia
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "Your always-on AI coding assistant — Claude Code via Telegram",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -15,8 +15,12 @@
15
15
  "bot.js",
16
16
  "vault.js",
17
17
  "setup.js",
18
+ "web.js",
18
19
  "config-dir.js",
19
20
  "bin/",
21
+ "k8s/",
22
+ "Dockerfile",
23
+ ".dockerignore",
20
24
  ".env.example",
21
25
  "README.md"
22
26
  ],
package/web.js ADDED
@@ -0,0 +1,651 @@
1
+ const http = require("http");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+ const https = require("https");
6
+ const CONFIG_DIR = require("./config-dir");
7
+ const Vault = require("./vault");
8
+
9
+ const ENV_FILE = path.join(CONFIG_DIR, ".env");
10
+ const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
11
+ const SOUL_FILE = path.join(CONFIG_DIR, "soul.md");
12
+ const CRONS_FILE = path.join(CONFIG_DIR, "crons.json");
13
+ const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
14
+ const WEB_PASSWORD_FILE = path.join(CONFIG_DIR, ".web-password");
15
+ const PORT = parseInt(process.env.WEB_PORT || "8080", 10);
16
+
17
+ // ── Password management ────────────────────────────────────────────
18
+
19
+ function getPassword() {
20
+ if (fs.existsSync(WEB_PASSWORD_FILE)) {
21
+ return fs.readFileSync(WEB_PASSWORD_FILE, "utf-8").trim();
22
+ }
23
+ // Generate initial password
24
+ const initial = crypto.randomBytes(4).toString("hex");
25
+ fs.writeFileSync(WEB_PASSWORD_FILE, initial);
26
+ console.log(`\n Web UI initial password: ${initial}`);
27
+ console.log(` Change it in Settings after first login.\n`);
28
+ return initial;
29
+ }
30
+
31
+ function setPassword(newPassword) {
32
+ fs.writeFileSync(WEB_PASSWORD_FILE, newPassword);
33
+ }
34
+
35
+ function checkAuth(req) {
36
+ const cookie = (req.headers.cookie || "").split(";").find((c) => c.trim().startsWith("oc_session="));
37
+ if (!cookie) return false;
38
+ const token = cookie.split("=")[1]?.trim();
39
+ const expected = crypto.createHash("sha256").update(getPassword()).digest("hex");
40
+ return token === expected;
41
+ }
42
+
43
+ function authToken() {
44
+ return crypto.createHash("sha256").update(getPassword()).digest("hex");
45
+ }
46
+
47
+ // ── Config helpers ─────────────────────────────────────────────────
48
+
49
+ function loadEnv() {
50
+ if (!fs.existsSync(ENV_FILE)) return {};
51
+ const env = {};
52
+ for (const line of fs.readFileSync(ENV_FILE, "utf-8").split("\n")) {
53
+ const idx = line.indexOf("=");
54
+ if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
55
+ }
56
+ return env;
57
+ }
58
+
59
+ function saveEnv(env) {
60
+ const content = Object.entries(env).map(([k, v]) => `${k}=${v}`).join("\n");
61
+ fs.writeFileSync(ENV_FILE, content);
62
+ }
63
+
64
+ function loadAuth() {
65
+ try { return JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); } catch (e) { return { authorized: [], pending: [] }; }
66
+ }
67
+
68
+ function saveAuth(auth) {
69
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
70
+ }
71
+
72
+ function loadSoul() {
73
+ try { return fs.readFileSync(SOUL_FILE, "utf-8"); } catch (e) { return "# Soul\n\nYou are a helpful AI coding assistant running via Telegram.\n"; }
74
+ }
75
+
76
+ function loadCrons() {
77
+ try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
78
+ }
79
+
80
+ function loadSessions() {
81
+ try { return JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return {}; }
82
+ }
83
+
84
+ function loadLogs(lines = 100) {
85
+ const logFile = path.join(CONFIG_DIR, "bot.log");
86
+ try {
87
+ const content = fs.readFileSync(logFile, "utf-8");
88
+ return content.split("\n").slice(-lines).join("\n");
89
+ } catch (e) { return "No logs yet."; }
90
+ }
91
+
92
+ function isConfigured() {
93
+ return fs.existsSync(ENV_FILE);
94
+ }
95
+
96
+ // ── Telegram helpers ───────────────────────────────────────────────
97
+
98
+ function telegramGet(token, method) {
99
+ return new Promise((resolve) => {
100
+ https.get(`https://api.telegram.org/bot${token}/getMe`, (res) => {
101
+ let data = "";
102
+ res.on("data", (d) => { data += d; });
103
+ res.on("end", () => {
104
+ try { resolve(JSON.parse(data)); } catch (e) { resolve({ ok: false }); }
105
+ });
106
+ }).on("error", () => resolve({ ok: false }));
107
+ });
108
+ }
109
+
110
+ // ── API routes ─────────────────────────────────────────────────────
111
+
112
+ async function handleAPI(req, res, body) {
113
+ const url = req.url;
114
+
115
+ // Login — no auth required
116
+ if (url === "/api/login" && req.method === "POST") {
117
+ const { password } = JSON.parse(body);
118
+ if (password === getPassword()) {
119
+ res.writeHead(200, {
120
+ "Content-Type": "application/json",
121
+ "Set-Cookie": `oc_session=${authToken()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
122
+ });
123
+ return res.end(JSON.stringify({ ok: true }));
124
+ }
125
+ res.writeHead(401, { "Content-Type": "application/json" });
126
+ return res.end(JSON.stringify({ ok: false, error: "Wrong password" }));
127
+ }
128
+
129
+ // All other API routes require auth
130
+ if (!checkAuth(req)) {
131
+ res.writeHead(401, { "Content-Type": "application/json" });
132
+ return res.end(JSON.stringify({ error: "Unauthorized" }));
133
+ }
134
+
135
+ // Status
136
+ if (url === "/api/status") {
137
+ const env = loadEnv();
138
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
139
+ res.writeHead(200, { "Content-Type": "application/json" });
140
+ return res.end(JSON.stringify({
141
+ configured: isConfigured(),
142
+ version: pkg.version,
143
+ workspace: env.WORKSPACE || "",
144
+ botToken: env.TELEGRAM_BOT_TOKEN ? "***" + env.TELEGRAM_BOT_TOKEN.slice(-8) : "",
145
+ chatId: env.TELEGRAM_CHAT_ID || "",
146
+ claudePath: env.CLAUDE_PATH || "",
147
+ onboarded: env.ONBOARDED === "true",
148
+ }));
149
+ }
150
+
151
+ // Setup — save initial config
152
+ if (url === "/api/setup" && req.method === "POST") {
153
+ const data = JSON.parse(body);
154
+
155
+ // Verify token
156
+ const botInfo = await telegramGet(data.botToken, "getMe");
157
+ if (!botInfo.ok) {
158
+ res.writeHead(400, { "Content-Type": "application/json" });
159
+ return res.end(JSON.stringify({ error: "Invalid Telegram bot token" }));
160
+ }
161
+
162
+ const env = {
163
+ TELEGRAM_BOT_TOKEN: data.botToken,
164
+ TELEGRAM_CHAT_ID: data.chatId,
165
+ WORKSPACE: data.workspace || path.join(CONFIG_DIR, "Workspace"),
166
+ CLAUDE_PATH: data.claudePath || "claude",
167
+ ANTHROPIC_API_KEY: data.apiKey || "",
168
+ WHISPER_CLI: "",
169
+ WHISPER_MODEL: "",
170
+ FFMPEG: "",
171
+ VAULT_FILE: path.join(CONFIG_DIR, "vault.enc"),
172
+ SOUL_FILE: SOUL_FILE,
173
+ CRONS_FILE: CRONS_FILE,
174
+ AUTH_FILE: AUTH_FILE,
175
+ ONBOARDED: "false",
176
+ };
177
+ saveEnv(env);
178
+
179
+ // Create workspace dir
180
+ const wsDir = env.WORKSPACE;
181
+ if (!fs.existsSync(wsDir)) fs.mkdirSync(wsDir, { recursive: true });
182
+
183
+ // Create default files
184
+ if (!fs.existsSync(CRONS_FILE)) fs.writeFileSync(CRONS_FILE, "[]");
185
+ if (!fs.existsSync(SOUL_FILE)) fs.writeFileSync(SOUL_FILE, "# Soul\n\nYou are a helpful AI coding assistant running via Telegram.\n");
186
+
187
+ // Save auth
188
+ const auth = loadAuth();
189
+ if (data.chatId && !auth.authorized.some((a) => a.chatId === data.chatId)) {
190
+ auth.authorized.push({
191
+ chatId: data.chatId,
192
+ name: "Owner",
193
+ username: "",
194
+ isOwner: true,
195
+ authorizedAt: new Date().toISOString(),
196
+ });
197
+ saveAuth(auth);
198
+ }
199
+
200
+ res.writeHead(200, { "Content-Type": "application/json" });
201
+ return res.end(JSON.stringify({ ok: true, bot: botInfo.result }));
202
+ }
203
+
204
+ // Get config
205
+ if (url === "/api/config") {
206
+ const env = loadEnv();
207
+ res.writeHead(200, { "Content-Type": "application/json" });
208
+ return res.end(JSON.stringify(env));
209
+ }
210
+
211
+ // Update config
212
+ if (url === "/api/config" && req.method === "POST") {
213
+ const updates = JSON.parse(body);
214
+ const env = loadEnv();
215
+ Object.assign(env, updates);
216
+ saveEnv(env);
217
+ res.writeHead(200, { "Content-Type": "application/json" });
218
+ return res.end(JSON.stringify({ ok: true }));
219
+ }
220
+
221
+ // Auth management
222
+ if (url === "/api/auth") {
223
+ res.writeHead(200, { "Content-Type": "application/json" });
224
+ return res.end(JSON.stringify(loadAuth()));
225
+ }
226
+
227
+ if (url === "/api/auth/approve" && req.method === "POST") {
228
+ const { chatId } = JSON.parse(body);
229
+ const auth = loadAuth();
230
+ const idx = auth.pending.findIndex((p) => p.chatId === chatId);
231
+ if (idx >= 0) {
232
+ const approved = auth.pending.splice(idx, 1)[0];
233
+ auth.authorized.push({ ...approved, isOwner: false, authorizedAt: new Date().toISOString() });
234
+ saveAuth(auth);
235
+ // Update .env
236
+ const env = loadEnv();
237
+ env.TELEGRAM_CHAT_ID = auth.authorized.map((a) => a.chatId).join(",");
238
+ saveEnv(env);
239
+ }
240
+ res.writeHead(200, { "Content-Type": "application/json" });
241
+ return res.end(JSON.stringify({ ok: true }));
242
+ }
243
+
244
+ if (url === "/api/auth/deny" && req.method === "POST") {
245
+ const { chatId } = JSON.parse(body);
246
+ const auth = loadAuth();
247
+ auth.pending = auth.pending.filter((p) => p.chatId !== chatId);
248
+ saveAuth(auth);
249
+ res.writeHead(200, { "Content-Type": "application/json" });
250
+ return res.end(JSON.stringify({ ok: true }));
251
+ }
252
+
253
+ if (url === "/api/auth/remove" && req.method === "POST") {
254
+ const { chatId } = JSON.parse(body);
255
+ const auth = loadAuth();
256
+ auth.authorized = auth.authorized.filter((a) => a.chatId !== chatId);
257
+ saveAuth(auth);
258
+ const env = loadEnv();
259
+ env.TELEGRAM_CHAT_ID = auth.authorized.map((a) => a.chatId).join(",");
260
+ saveEnv(env);
261
+ res.writeHead(200, { "Content-Type": "application/json" });
262
+ return res.end(JSON.stringify({ ok: true }));
263
+ }
264
+
265
+ // Soul
266
+ if (url === "/api/soul") {
267
+ res.writeHead(200, { "Content-Type": "application/json" });
268
+ return res.end(JSON.stringify({ content: loadSoul() }));
269
+ }
270
+
271
+ if (url === "/api/soul" && req.method === "POST") {
272
+ const { content } = JSON.parse(body);
273
+ fs.writeFileSync(SOUL_FILE, content);
274
+ res.writeHead(200, { "Content-Type": "application/json" });
275
+ return res.end(JSON.stringify({ ok: true }));
276
+ }
277
+
278
+ // Crons
279
+ if (url === "/api/crons") {
280
+ res.writeHead(200, { "Content-Type": "application/json" });
281
+ return res.end(JSON.stringify(loadCrons()));
282
+ }
283
+
284
+ // Sessions
285
+ if (url === "/api/sessions") {
286
+ res.writeHead(200, { "Content-Type": "application/json" });
287
+ return res.end(JSON.stringify(loadSessions()));
288
+ }
289
+
290
+ // Logs
291
+ if (url === "/api/logs") {
292
+ res.writeHead(200, { "Content-Type": "application/json" });
293
+ return res.end(JSON.stringify({ content: loadLogs() }));
294
+ }
295
+
296
+ // Change password
297
+ if (url === "/api/password" && req.method === "POST") {
298
+ const { current, newPassword } = JSON.parse(body);
299
+ if (current !== getPassword()) {
300
+ res.writeHead(400, { "Content-Type": "application/json" });
301
+ return res.end(JSON.stringify({ error: "Wrong current password" }));
302
+ }
303
+ setPassword(newPassword);
304
+ res.writeHead(200, {
305
+ "Content-Type": "application/json",
306
+ "Set-Cookie": `oc_session=${authToken()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
307
+ });
308
+ return res.end(JSON.stringify({ ok: true }));
309
+ }
310
+
311
+ res.writeHead(404, { "Content-Type": "application/json" });
312
+ res.end(JSON.stringify({ error: "Not found" }));
313
+ }
314
+
315
+ // ── HTML UI ────────────────────────────────────────────────────────
316
+
317
+ function getHTML() {
318
+ return `<!DOCTYPE html>
319
+ <html lang="en">
320
+ <head>
321
+ <meta charset="UTF-8">
322
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
323
+ <title>Open Claudia</title>
324
+ <style>
325
+ * { margin: 0; padding: 0; box-sizing: border-box; }
326
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f0f0f; color: #e0e0e0; min-height: 100vh; }
327
+ .container { max-width: 720px; margin: 0 auto; padding: 20px; }
328
+ h1 { font-size: 24px; margin-bottom: 8px; color: #fff; }
329
+ h2 { font-size: 18px; margin: 24px 0 12px; color: #fff; }
330
+ .subtitle { color: #888; margin-bottom: 24px; }
331
+ .card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
332
+ .form-group { margin-bottom: 16px; }
333
+ label { display: block; font-size: 13px; color: #888; margin-bottom: 6px; }
334
+ input, textarea, select { width: 100%; padding: 10px 12px; background: #0f0f0f; border: 1px solid #333; border-radius: 8px; color: #e0e0e0; font-size: 14px; font-family: inherit; }
335
+ input:focus, textarea:focus { outline: none; border-color: #6366f1; }
336
+ textarea { min-height: 120px; resize: vertical; }
337
+ button { padding: 10px 20px; background: #6366f1; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
338
+ button:hover { background: #5558e6; }
339
+ button.danger { background: #dc2626; }
340
+ button.danger:hover { background: #b91c1c; }
341
+ button.secondary { background: #333; }
342
+ button.secondary:hover { background: #444; }
343
+ .tabs { display: flex; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
344
+ .tab { padding: 8px 16px; background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px; cursor: pointer; font-size: 13px; color: #888; }
345
+ .tab.active { background: #6366f1; color: #fff; border-color: #6366f1; }
346
+ .badge { display: inline-block; background: #dc2626; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; margin-left: 4px; }
347
+ .status-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding: 12px 16px; background: #1a1a1a; border-radius: 8px; font-size: 13px; }
348
+ .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
349
+ .status-dot.green { background: #22c55e; }
350
+ .status-dot.red { background: #dc2626; }
351
+ .status-dot.yellow { background: #eab308; }
352
+ .list-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid #222; }
353
+ .list-item:last-child { border-bottom: none; }
354
+ pre { background: #0a0a0a; padding: 12px; border-radius: 8px; overflow-x: auto; font-size: 12px; color: #aaa; max-height: 400px; overflow-y: auto; }
355
+ .login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
356
+ .login-box { width: 320px; }
357
+ .msg { padding: 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
358
+ .msg.ok { background: #052e16; color: #22c55e; }
359
+ .msg.err { background: #2a0a0a; color: #dc2626; }
360
+ .hidden { display: none; }
361
+ </style>
362
+ </head>
363
+ <body>
364
+ <div id="app"></div>
365
+ <script>
366
+ const $ = (s) => document.querySelector(s);
367
+ const api = async (url, opts = {}) => {
368
+ const r = await fetch(url, { ...opts, headers: { "Content-Type": "application/json", ...opts.headers }, body: opts.body ? JSON.stringify(opts.body) : undefined });
369
+ if (r.status === 401 && url !== "/api/login") { showLogin(); return null; }
370
+ return r.json();
371
+ };
372
+
373
+ let currentTab = "dashboard";
374
+ let status = {};
375
+
376
+ async function init() {
377
+ const r = await api("/api/status");
378
+ if (!r) return;
379
+ status = r;
380
+ if (!status.configured) currentTab = "setup";
381
+ render();
382
+ }
383
+
384
+ function showLogin() {
385
+ $("#app").innerHTML = \`
386
+ <div class="login-page"><div class="login-box">
387
+ <h1>Open Claudia</h1>
388
+ <p class="subtitle">Enter your admin password</p>
389
+ <div id="login-msg"></div>
390
+ <div class="card">
391
+ <div class="form-group">
392
+ <input type="password" id="pw" placeholder="Password" onkeydown="if(event.key==='Enter')doLogin()">
393
+ </div>
394
+ <button onclick="doLogin()" style="width:100%">Sign in</button>
395
+ </div>
396
+ </div></div>\`;
397
+ setTimeout(() => $("#pw")?.focus(), 100);
398
+ }
399
+
400
+ async function doLogin() {
401
+ const pw = $("#pw").value;
402
+ const r = await api("/api/login", { method: "POST", body: { password: pw } });
403
+ if (r?.ok) init();
404
+ else $("#login-msg").innerHTML = '<div class="msg err">Wrong password</div>';
405
+ }
406
+
407
+ function render() {
408
+ const tabs = [
409
+ { id: "dashboard", label: "Dashboard" },
410
+ { id: "auth", label: "Users" },
411
+ { id: "soul", label: "Soul" },
412
+ { id: "crons", label: "Crons" },
413
+ { id: "sessions", label: "Sessions" },
414
+ { id: "logs", label: "Logs" },
415
+ { id: "settings", label: "Settings" },
416
+ ];
417
+ if (!status.configured) {
418
+ renderSetup();
419
+ return;
420
+ }
421
+ $("#app").innerHTML = \`
422
+ <div class="container">
423
+ <h1>Open Claudia</h1>
424
+ <p class="subtitle">v\${status.version}</p>
425
+ <div class="tabs">
426
+ \${tabs.map(t => \`<div class="tab \${currentTab===t.id?'active':''}" onclick="switchTab('\${t.id}')">\${t.label}</div>\`).join("")}
427
+ </div>
428
+ <div id="content"></div>
429
+ </div>\`;
430
+ loadTab();
431
+ }
432
+
433
+ function switchTab(tab) { currentTab = tab; render(); }
434
+
435
+ async function loadTab() {
436
+ const el = $("#content");
437
+ if (currentTab === "dashboard") {
438
+ el.innerHTML = \`
439
+ <div class="card">
440
+ <div class="status-bar">
441
+ <span><span class="status-dot green"></span> Bot configured</span>
442
+ <span>v\${status.version}</span>
443
+ </div>
444
+ <div class="list-item"><span>Workspace</span><span>\${status.workspace}</span></div>
445
+ <div class="list-item"><span>Bot Token</span><span>\${status.botToken}</span></div>
446
+ <div class="list-item"><span>Chat ID</span><span>\${status.chatId}</span></div>
447
+ <div class="list-item"><span>Claude</span><span>\${status.claudePath}</span></div>
448
+ </div>\`;
449
+ }
450
+ else if (currentTab === "auth") {
451
+ const auth = await api("/api/auth");
452
+ if (!auth) return;
453
+ el.innerHTML = \`
454
+ <div class="card">
455
+ <h2>Authorized Users</h2>
456
+ \${auth.authorized.map(a => \`
457
+ <div class="list-item">
458
+ <span>\${a.name || a.chatId} \${a.username ? "@"+a.username : ""} \${a.isOwner ? "(owner)" : ""}</span>
459
+ \${!a.isOwner ? \`<button class="danger" onclick="removeAuth('\${a.chatId}')">Remove</button>\` : ""}
460
+ </div>\`).join("") || "<p>None</p>"}
461
+ </div>
462
+ \${auth.pending.length > 0 ? \`
463
+ <div class="card">
464
+ <h2>Pending Requests</h2>
465
+ \${auth.pending.map(p => \`
466
+ <div class="list-item">
467
+ <span>\${p.name} \${p.username ? "@"+p.username : ""}</span>
468
+ <span>
469
+ <button onclick="approveAuth('\${p.chatId}')">Approve</button>
470
+ <button class="danger" onclick="denyAuth('\${p.chatId}')">Deny</button>
471
+ </span>
472
+ </div>\`).join("")}
473
+ </div>\` : ""}\`;
474
+ }
475
+ else if (currentTab === "soul") {
476
+ const soul = await api("/api/soul");
477
+ if (!soul) return;
478
+ el.innerHTML = \`
479
+ <div class="card">
480
+ <div class="form-group">
481
+ <label>Assistant Identity & Personality</label>
482
+ <textarea id="soul-content" rows="15">\${soul.content}</textarea>
483
+ </div>
484
+ <button onclick="saveSoul()">Save</button>
485
+ <span id="soul-msg" style="margin-left:12px;font-size:13px;color:#888;"></span>
486
+ </div>\`;
487
+ }
488
+ else if (currentTab === "crons") {
489
+ const crons = await api("/api/crons");
490
+ if (!crons) return;
491
+ el.innerHTML = \`
492
+ <div class="card">
493
+ <h2>Scheduled Tasks</h2>
494
+ \${crons.map((c, i) => \`
495
+ <div class="list-item">
496
+ <span>\${c.label || c.prompt.slice(0,40)} — \${c.schedule} — \${c.project}</span>
497
+ </div>\`).join("") || "<p>No cron jobs. Use /cron in Telegram to add them.</p>"}
498
+ </div>\`;
499
+ }
500
+ else if (currentTab === "sessions") {
501
+ const sessions = await api("/api/sessions");
502
+ if (!sessions) return;
503
+ const projects = Object.keys(sessions);
504
+ el.innerHTML = \`
505
+ <div class="card">
506
+ <h2>Conversation History</h2>
507
+ \${projects.map(p => \`
508
+ <h2 style="font-size:14px;color:#6366f1;margin:16px 0 8px;">\${p}</h2>
509
+ \${sessions[p].slice().reverse().slice(0,5).map(s => \`
510
+ <div class="list-item">
511
+ <span>\${s.title}</span>
512
+ <span style="color:#666;font-size:12px;">\${new Date(s.lastUsed).toLocaleDateString()}</span>
513
+ </div>\`).join("")}
514
+ \`).join("") || "<p>No sessions yet.</p>"}
515
+ </div>\`;
516
+ }
517
+ else if (currentTab === "logs") {
518
+ const logs = await api("/api/logs");
519
+ if (!logs) return;
520
+ el.innerHTML = \`
521
+ <div class="card">
522
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
523
+ <h2 style="margin:0">Logs</h2>
524
+ <button class="secondary" onclick="loadTab()">Refresh</button>
525
+ </div>
526
+ <pre>\${logs.content}</pre>
527
+ </div>\`;
528
+ }
529
+ else if (currentTab === "settings") {
530
+ el.innerHTML = \`
531
+ <div class="card">
532
+ <h2>Change Admin Password</h2>
533
+ <div class="form-group"><label>Current Password</label><input type="password" id="cur-pw"></div>
534
+ <div class="form-group"><label>New Password</label><input type="password" id="new-pw"></div>
535
+ <button onclick="changePassword()">Change</button>
536
+ <span id="pw-msg" style="margin-left:12px;font-size:13px;"></span>
537
+ </div>
538
+ <div class="card">
539
+ <h2>Configuration</h2>
540
+ <p style="color:#888;font-size:13px;margin-bottom:12px;">Edit ~/.open-claudia/.env values</p>
541
+ <div id="config-fields"></div>
542
+ <button onclick="saveConfig()">Save</button>
543
+ <span id="config-msg" style="margin-left:12px;font-size:13px;color:#888;"></span>
544
+ </div>\`;
545
+ const config = await api("/api/config");
546
+ if (!config) return;
547
+ const safe = ["WORKSPACE", "CLAUDE_PATH", "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL"];
548
+ $("#config-fields").innerHTML = safe.map(k => \`
549
+ <div class="form-group"><label>\${k}</label><input id="cfg-\${k}" value="\${config[k] || ""}"></div>
550
+ \`).join("");
551
+ }
552
+ }
553
+
554
+ async function removeAuth(chatId) { await api("/api/auth/remove", { method: "POST", body: { chatId } }); loadTab(); }
555
+ async function approveAuth(chatId) { await api("/api/auth/approve", { method: "POST", body: { chatId } }); loadTab(); }
556
+ async function denyAuth(chatId) { await api("/api/auth/deny", { method: "POST", body: { chatId } }); loadTab(); }
557
+
558
+ async function saveSoul() {
559
+ await api("/api/soul", { method: "POST", body: { content: $("#soul-content").value } });
560
+ $("#soul-msg").textContent = "Saved";
561
+ setTimeout(() => $("#soul-msg").textContent = "", 2000);
562
+ }
563
+
564
+ async function changePassword() {
565
+ const r = await api("/api/password", { method: "POST", body: { current: $("#cur-pw").value, newPassword: $("#new-pw").value } });
566
+ $("#pw-msg").textContent = r?.ok ? "Changed!" : (r?.error || "Failed");
567
+ $("#pw-msg").style.color = r?.ok ? "#22c55e" : "#dc2626";
568
+ }
569
+
570
+ async function saveConfig() {
571
+ const updates = {};
572
+ for (const el of document.querySelectorAll("[id^='cfg-']")) {
573
+ updates[el.id.replace("cfg-", "")] = el.value;
574
+ }
575
+ await api("/api/config", { method: "POST", body: updates });
576
+ $("#config-msg").textContent = "Saved — restart bot to apply.";
577
+ setTimeout(() => $("#config-msg").textContent = "", 3000);
578
+ }
579
+
580
+ function renderSetup() {
581
+ $("#app").innerHTML = \`
582
+ <div class="container">
583
+ <h1>Open Claudia Setup</h1>
584
+ <p class="subtitle">Configure your AI coding assistant</p>
585
+ <div id="setup-msg"></div>
586
+ <div class="card">
587
+ <div class="form-group"><label>Telegram Bot Token (from @BotFather)</label><input id="s-token" placeholder="123456:ABC-DEF..."></div>
588
+ <div class="form-group"><label>Your Telegram Chat ID</label><input id="s-chatid" placeholder="e.g. 6251055967"></div>
589
+ <div class="form-group"><label>Anthropic API Key (or leave blank if Claude CLI is authed)</label><input id="s-apikey" type="password" placeholder="sk-ant-..."></div>
590
+ <div class="form-group"><label>Claude CLI Path (auto-detected if on PATH)</label><input id="s-claude" placeholder="claude" value="claude"></div>
591
+ <div class="form-group"><label>Workspace Path</label><input id="s-workspace" placeholder="Leave blank for default"></div>
592
+ <button onclick="doSetup()" style="width:100%">Complete Setup</button>
593
+ </div>
594
+ </div>\`;
595
+ }
596
+
597
+ async function doSetup() {
598
+ const r = await api("/api/setup", { method: "POST", body: {
599
+ botToken: $("#s-token").value,
600
+ chatId: $("#s-chatid").value,
601
+ apiKey: $("#s-apikey").value,
602
+ claudePath: $("#s-claude").value,
603
+ workspace: $("#s-workspace").value,
604
+ }});
605
+ if (r?.ok) {
606
+ status.configured = true;
607
+ currentTab = "dashboard";
608
+ await init();
609
+ } else {
610
+ $("#setup-msg").innerHTML = \`<div class="msg err">\${r?.error || "Setup failed"}</div>\`;
611
+ }
612
+ }
613
+
614
+ init();
615
+ </script>
616
+ </body>
617
+ </html>`;
618
+ }
619
+
620
+ // ── Server ─────────────────────────────────────────────────────────
621
+
622
+ function startWebServer() {
623
+ const server = http.createServer(async (req, res) => {
624
+ // CORS for local dev
625
+ res.setHeader("Access-Control-Allow-Origin", "*");
626
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
627
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
628
+ if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
629
+
630
+ if (req.url.startsWith("/api/")) {
631
+ let body = "";
632
+ req.on("data", (d) => { body += d; });
633
+ req.on("end", () => handleAPI(req, res, body));
634
+ return;
635
+ }
636
+
637
+ // Serve HTML
638
+ res.writeHead(200, { "Content-Type": "text/html" });
639
+ res.end(getHTML());
640
+ });
641
+
642
+ server.listen(PORT, () => {
643
+ const pw = getPassword();
644
+ console.log(`Web UI running on http://localhost:${PORT}`);
645
+ console.log(`Admin password: ${pw}`);
646
+ });
647
+
648
+ return server;
649
+ }
650
+
651
+ module.exports = { startWebServer, isConfigured };