@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 +15 -0
- package/Dockerfile +33 -0
- package/bin/cli.js +19 -1
- package/bot.js +14 -1
- package/k8s/deployment.yaml +61 -0
- package/k8s/ingress.yaml +26 -0
- package/k8s/pvc.yaml +12 -0
- package/k8s/secret.yaml +10 -0
- package/k8s/service.yaml +14 -0
- package/package.json +5 -1
- package/web.js +651 -0
package/.dockerignore
ADDED
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
|
-
|
|
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
|
package/k8s/ingress.yaml
ADDED
|
@@ -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
package/k8s/secret.yaml
ADDED
package/k8s/service.yaml
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "1.
|
|
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 };
|