@inetafrica/open-claudia 2.6.11 → 2.6.13
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/handlers.js +32 -0
- package/core/runner.js +34 -0
- package/core/usage-log.js +17 -0
- package/core/web-otp.js +44 -0
- package/package.json +1 -1
- package/web.js +155 -0
package/core/handlers.js
CHANGED
|
@@ -302,6 +302,38 @@ register({
|
|
|
302
302
|
},
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
// Where the magic link should point. Explicit WEB_PUBLIC_URL wins; else the
|
|
306
|
+
// machine's first non-internal IPv4 (so a phone on the same wifi can reach
|
|
307
|
+
// it); else localhost.
|
|
308
|
+
function dashboardBaseUrl() {
|
|
309
|
+
const explicit = (process.env.WEB_PUBLIC_URL || "").trim().replace(/\/+$/, "");
|
|
310
|
+
if (explicit) return explicit;
|
|
311
|
+
const port = process.env.WEB_PORT || "8080";
|
|
312
|
+
const ifaces = require("os").networkInterfaces();
|
|
313
|
+
for (const name of Object.keys(ifaces)) {
|
|
314
|
+
for (const ni of ifaces[name] || []) {
|
|
315
|
+
if (ni.family === "IPv4" && !ni.internal) return `http://${ni.address}:${port}`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return `http://localhost:${port}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
register({
|
|
322
|
+
name: "dashboard", aliases: ["weblink"], description: "Get a one-time login link to the web dashboard", ownerOnly: true,
|
|
323
|
+
handler: async (env) => {
|
|
324
|
+
if (!ownerEnv(env)) return;
|
|
325
|
+
const { mint, TTL_MS } = require("./web-otp");
|
|
326
|
+
const { token } = mint();
|
|
327
|
+
const url = `${dashboardBaseUrl()}/otp/${token}`;
|
|
328
|
+
const mins = Math.round(TTL_MS / 60000);
|
|
329
|
+
const lines = [`Web dashboard login (valid ${mins} min, single use):`, url];
|
|
330
|
+
if (process.env.WEB_UI !== "true") {
|
|
331
|
+
lines.push("", "Note: the web UI isn't enabled yet — set WEB_UI=true in ~/.open-claudia/.env and /restart, then this link will work.");
|
|
332
|
+
}
|
|
333
|
+
send(lines.join("\n"));
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
305
337
|
async function requestAgentSpaceUpgrade() {
|
|
306
338
|
const apiUrl = process.env.AGENTSPACE_API_URL;
|
|
307
339
|
const token = process.env.AGENTSPACE_POD_TOKEN;
|
package/core/runner.js
CHANGED
|
@@ -28,6 +28,35 @@ const skillsLib = require("./skills");
|
|
|
28
28
|
const packsLib = require("./packs");
|
|
29
29
|
const entitiesLib = require("./entities");
|
|
30
30
|
const packReview = require("./pack-review");
|
|
31
|
+
const { appendUsageRecord } = require("./usage-log");
|
|
32
|
+
|
|
33
|
+
const PKG_VERSION = require("../package.json").version;
|
|
34
|
+
|
|
35
|
+
// One JSONL record per completed user turn (real turns only — compaction
|
|
36
|
+
// summary/seed and sub-agent capture runs are excluded). Powers the web
|
|
37
|
+
// dashboard's per-version tokenomics graphs.
|
|
38
|
+
function logTurnUsage(state, usage, costUsd) {
|
|
39
|
+
if (!usage) return;
|
|
40
|
+
const backend = state.settings?.backend || "claude";
|
|
41
|
+
const input = usage.input_tokens || 0;
|
|
42
|
+
const cacheRead = usage.cache_read_input_tokens || usage.cached_input_tokens || 0;
|
|
43
|
+
const cacheCreation = usage.cache_creation_input_tokens || 0;
|
|
44
|
+
const output = usage.output_tokens || 0;
|
|
45
|
+
appendUsageRecord({
|
|
46
|
+
ts: new Date().toISOString(),
|
|
47
|
+
version: PKG_VERSION,
|
|
48
|
+
backend,
|
|
49
|
+
model: state.settings?.model || (backend === "claude" ? DEFAULT_CLAUDE_MODEL : null),
|
|
50
|
+
userId: state.userId || null,
|
|
51
|
+
sessionId: state[getActiveSessionKey(state)] || null,
|
|
52
|
+
inputTokens: input,
|
|
53
|
+
outputTokens: output,
|
|
54
|
+
cacheReadTokens: cacheRead,
|
|
55
|
+
cacheCreationTokens: cacheCreation,
|
|
56
|
+
contextTokens: input + cacheRead + cacheCreation,
|
|
57
|
+
costUsd: typeof costUsd === "number" ? costUsd : 0,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
31
60
|
|
|
32
61
|
function telegramHtmlOpts(extra = {}) {
|
|
33
62
|
const adapter = currentAdapter();
|
|
@@ -817,6 +846,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
817
846
|
u.outputTokens += output;
|
|
818
847
|
u.cacheReadTokens += cached;
|
|
819
848
|
u.lastInputTokens = input + cached;
|
|
849
|
+
logTurnUsage(state, evt.usage, 0);
|
|
820
850
|
saveState();
|
|
821
851
|
}
|
|
822
852
|
if (evt.type === "result" && evt.session_id) {
|
|
@@ -832,6 +862,10 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
832
862
|
u.lastInputTokens = (evt.usage.input_tokens || 0) +
|
|
833
863
|
(evt.usage.cache_read_input_tokens || 0) +
|
|
834
864
|
(evt.usage.cache_creation_input_tokens || 0);
|
|
865
|
+
// Codex already logged this turn at turn.completed — don't double-count.
|
|
866
|
+
if (settings.backend !== "codex") {
|
|
867
|
+
logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0);
|
|
868
|
+
}
|
|
835
869
|
}
|
|
836
870
|
if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
|
|
837
871
|
saveState();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Durable per-turn token-usage log. Each completed user turn appends one
|
|
2
|
+
// JSONL record tagged with the open-claudia version + model, so the web
|
|
3
|
+
// dashboard can graph tokenomics over time and compare across versions.
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const CONFIG_DIR = require("../config-dir");
|
|
8
|
+
|
|
9
|
+
const USAGE_HISTORY_FILE = path.join(CONFIG_DIR, "usage-history.jsonl");
|
|
10
|
+
|
|
11
|
+
function appendUsageRecord(record) {
|
|
12
|
+
try {
|
|
13
|
+
fs.appendFileSync(USAGE_HISTORY_FILE, JSON.stringify(record) + "\n");
|
|
14
|
+
} catch (e) {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { appendUsageRecord, USAGE_HISTORY_FILE };
|
package/core/web-otp.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// One-time login tokens for the web dashboard. The bot mints a token via
|
|
2
|
+
// the /dashboard command and hands back a magic link; web.js burns the
|
|
3
|
+
// token on first use and sets the session cookie. File-backed so it
|
|
4
|
+
// survives a restart and works whether or not web + bot share a process.
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const crypto = require("crypto");
|
|
9
|
+
const CONFIG_DIR = require("../config-dir");
|
|
10
|
+
|
|
11
|
+
const OTP_FILE = path.join(CONFIG_DIR, ".web-otp.json");
|
|
12
|
+
const TTL_MS = 10 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function _load() {
|
|
15
|
+
try { return JSON.parse(fs.readFileSync(OTP_FILE, "utf-8")); } catch (e) { return []; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function _save(list) {
|
|
19
|
+
try { fs.writeFileSync(OTP_FILE, JSON.stringify(list), { mode: 0o600 }); } catch (e) {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mint(ttlMs = TTL_MS) {
|
|
23
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
24
|
+
const expiresAt = Date.now() + ttlMs;
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const list = _load().filter((e) => e && e.expiresAt > now);
|
|
27
|
+
list.push({ token, expiresAt });
|
|
28
|
+
_save(list);
|
|
29
|
+
return { token, expiresAt };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Validate and burn (single use). Returns true only if the token was
|
|
33
|
+
// present and unexpired; prunes expired entries either way.
|
|
34
|
+
function consume(token) {
|
|
35
|
+
if (!token || typeof token !== "string") return false;
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const list = _load();
|
|
38
|
+
const idx = list.findIndex((e) => e && e.token === token && e.expiresAt > now);
|
|
39
|
+
const remaining = list.filter((e, i) => e && e.expiresAt > now && i !== idx);
|
|
40
|
+
if (remaining.length !== list.length) _save(remaining);
|
|
41
|
+
return idx !== -1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { mint, consume, OTP_FILE, TTL_MS };
|
package/package.json
CHANGED
package/web.js
CHANGED
|
@@ -12,6 +12,7 @@ const SOUL_FILE = path.join(CONFIG_DIR, "soul.md");
|
|
|
12
12
|
const CRONS_FILE = path.join(CONFIG_DIR, "crons.json");
|
|
13
13
|
const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
|
|
14
14
|
const WEB_PASSWORD_FILE = path.join(CONFIG_DIR, ".web-password");
|
|
15
|
+
const USAGE_HISTORY_FILE = path.join(CONFIG_DIR, "usage-history.jsonl");
|
|
15
16
|
const PORT = parseInt(process.env.WEB_PORT || "8080", 10);
|
|
16
17
|
|
|
17
18
|
// ── Password management ────────────────────────────────────────────
|
|
@@ -180,6 +181,48 @@ function isConfigured() {
|
|
|
180
181
|
return fs.existsSync(ENV_FILE);
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
function loadUsageHistory() {
|
|
185
|
+
let raw;
|
|
186
|
+
try { raw = fs.readFileSync(USAGE_HISTORY_FILE, "utf-8"); } catch (e) { return []; }
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const line of raw.split("\n")) {
|
|
189
|
+
const t = line.trim();
|
|
190
|
+
if (!t) continue;
|
|
191
|
+
try { out.push(JSON.parse(t)); } catch (e) {}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Group usage records by a key (version / model) and return per-group
|
|
197
|
+
// averages so the dashboard can compare tokenomics across versions.
|
|
198
|
+
function aggregateUsage(records, keyName) {
|
|
199
|
+
const map = new Map();
|
|
200
|
+
for (const r of records) {
|
|
201
|
+
const k = r[keyName] || r.backend || "unknown";
|
|
202
|
+
let m = map.get(k);
|
|
203
|
+
if (!m) { m = { key: k, turns: 0, input: 0, output: 0, ctx: 0, cost: 0, first: r.ts, last: r.ts }; map.set(k, m); }
|
|
204
|
+
m.turns += 1;
|
|
205
|
+
m.input += r.inputTokens || 0;
|
|
206
|
+
m.output += r.outputTokens || 0;
|
|
207
|
+
m.ctx += r.contextTokens || 0;
|
|
208
|
+
m.cost += r.costUsd || 0;
|
|
209
|
+
if (r.ts && r.ts < m.first) m.first = r.ts;
|
|
210
|
+
if (r.ts && r.ts > m.last) m.last = r.ts;
|
|
211
|
+
}
|
|
212
|
+
return [...map.values()]
|
|
213
|
+
.sort((a, b) => (a.first < b.first ? -1 : a.first > b.first ? 1 : 0))
|
|
214
|
+
.map((m) => ({
|
|
215
|
+
[keyName]: m.key,
|
|
216
|
+
turns: m.turns,
|
|
217
|
+
avgContextTokens: m.turns ? m.ctx / m.turns : 0,
|
|
218
|
+
avgInputTokens: m.turns ? m.input / m.turns : 0,
|
|
219
|
+
avgOutputTokens: m.turns ? m.output / m.turns : 0,
|
|
220
|
+
totalCostUsd: m.cost,
|
|
221
|
+
firstTs: m.first,
|
|
222
|
+
lastTs: m.last,
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
|
|
183
226
|
// ── Telegram helpers ───────────────────────────────────────────────
|
|
184
227
|
|
|
185
228
|
function telegramGet(token, method) {
|
|
@@ -387,6 +430,26 @@ async function handleAPI(req, res, body) {
|
|
|
387
430
|
return res.end(JSON.stringify({ content: loadLogs() }));
|
|
388
431
|
}
|
|
389
432
|
|
|
433
|
+
// Token usage history (for the Usage dashboard)
|
|
434
|
+
if (url === "/api/usage") {
|
|
435
|
+
const records = loadUsageHistory();
|
|
436
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
|
|
437
|
+
const series = records.slice(-500).map((r) => ({
|
|
438
|
+
ts: r.ts,
|
|
439
|
+
version: r.version || "?",
|
|
440
|
+
contextTokens: r.contextTokens || 0,
|
|
441
|
+
outputTokens: r.outputTokens || 0,
|
|
442
|
+
}));
|
|
443
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
444
|
+
return res.end(JSON.stringify({
|
|
445
|
+
currentVersion: pkg.version,
|
|
446
|
+
totalRecords: records.length,
|
|
447
|
+
byVersion: aggregateUsage(records, "version"),
|
|
448
|
+
byModel: aggregateUsage(records, "model"),
|
|
449
|
+
series,
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
|
|
390
453
|
// Change password
|
|
391
454
|
if (url === "/api/password" && req.method === "POST") {
|
|
392
455
|
const { current, newPassword } = JSON.parse(body);
|
|
@@ -457,7 +520,12 @@ function getHTML() {
|
|
|
457
520
|
.msg.ok { background: #052e16; color: #22c55e; }
|
|
458
521
|
.msg.err { background: #2a0a0a; color: #dc2626; }
|
|
459
522
|
.hidden { display: none; }
|
|
523
|
+
table.usage-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
524
|
+
table.usage-table th, table.usage-table td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #222; white-space: nowrap; }
|
|
525
|
+
table.usage-table th { color: #888; font-weight: 500; }
|
|
526
|
+
table.usage-table td:first-child { color: #e0e0e0; }
|
|
460
527
|
</style>
|
|
528
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|
461
529
|
</head>
|
|
462
530
|
<body>
|
|
463
531
|
<div id="app"></div>
|
|
@@ -536,6 +604,7 @@ async function doForceChange() {
|
|
|
536
604
|
function render() {
|
|
537
605
|
const tabs = [
|
|
538
606
|
{ id: "dashboard", label: "Dashboard" },
|
|
607
|
+
{ id: "usage", label: "Usage" },
|
|
539
608
|
{ id: "auth", label: "Users" },
|
|
540
609
|
{ id: "soul", label: "Soul" },
|
|
541
610
|
{ id: "crons", label: "Crons" },
|
|
@@ -576,6 +645,27 @@ async function loadTab() {
|
|
|
576
645
|
<div class="list-item"><span>Claude</span><span>\${status.claudePath}</span></div>
|
|
577
646
|
</div>\`;
|
|
578
647
|
}
|
|
648
|
+
else if (currentTab === "usage") {
|
|
649
|
+
const u = await api("/api/usage");
|
|
650
|
+
if (!u) return;
|
|
651
|
+
if (!u.totalRecords) {
|
|
652
|
+
el.innerHTML = '<div class="card"><h2>Token Usage</h2><p style="color:#888">No usage recorded yet. Send a few messages and check back — each turn is logged with the running version.</p></div>';
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
el.innerHTML =
|
|
656
|
+
'<div class="card">' +
|
|
657
|
+
'<h2>Avg context tokens / turn — by version</h2>' +
|
|
658
|
+
'<p style="color:#888;font-size:13px;margin-bottom:12px;">Lower is better. Current: v' + u.currentVersion + ' · ' + u.totalRecords + ' turns logged</p>' +
|
|
659
|
+
'<canvas id="chart-version" height="150"></canvas>' +
|
|
660
|
+
'</div>' +
|
|
661
|
+
'<div class="card">' +
|
|
662
|
+
'<h2>Trend — recent turns</h2>' +
|
|
663
|
+
'<canvas id="chart-trend" height="150"></canvas>' +
|
|
664
|
+
'</div>' +
|
|
665
|
+
'<div class="card"><h2>By version</h2>' + usageTableHTML(u.byVersion, "version") + '</div>' +
|
|
666
|
+
'<div class="card"><h2>By model</h2>' + usageTableHTML(u.byModel, "model") + '</div>';
|
|
667
|
+
renderUsageCharts(u);
|
|
668
|
+
}
|
|
579
669
|
else if (currentTab === "auth") {
|
|
580
670
|
const auth = await api("/api/auth");
|
|
581
671
|
if (!auth) return;
|
|
@@ -680,6 +770,55 @@ async function loadTab() {
|
|
|
680
770
|
}
|
|
681
771
|
}
|
|
682
772
|
|
|
773
|
+
function fmtK(n) { n = Math.round(n || 0); return n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n); }
|
|
774
|
+
|
|
775
|
+
function usageTableHTML(rows, keyName) {
|
|
776
|
+
if (!rows || !rows.length) return '<p style="color:#888">No data.</p>';
|
|
777
|
+
const head = '<table class="usage-table"><thead><tr><th>' + keyName +
|
|
778
|
+
'</th><th>turns</th><th>avg ctx/turn</th><th>avg in</th><th>avg out</th><th>cost</th></tr></thead><tbody>';
|
|
779
|
+
const body = rows.map(function (r) {
|
|
780
|
+
const name = r[keyName] || "—";
|
|
781
|
+
return '<tr><td>' + name + '</td><td>' + r.turns + '</td><td>' + fmtK(r.avgContextTokens) +
|
|
782
|
+
'</td><td>' + fmtK(r.avgInputTokens) + '</td><td>' + fmtK(r.avgOutputTokens) +
|
|
783
|
+
'</td><td>$' + (r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
|
|
784
|
+
}).join("");
|
|
785
|
+
return head + body + "</tbody></table>";
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
let _usageCharts = {};
|
|
789
|
+
function renderUsageCharts(u) {
|
|
790
|
+
if (typeof Chart === "undefined") return;
|
|
791
|
+
Object.values(_usageCharts).forEach(function (c) { try { c.destroy(); } catch (e) {} });
|
|
792
|
+
_usageCharts = {};
|
|
793
|
+
const grid = { color: "#222" };
|
|
794
|
+
const ticks = { color: "#888" };
|
|
795
|
+
const vc = document.getElementById("chart-version");
|
|
796
|
+
if (vc) {
|
|
797
|
+
_usageCharts.version = new Chart(vc, {
|
|
798
|
+
type: "bar",
|
|
799
|
+
data: {
|
|
800
|
+
labels: u.byVersion.map(function (v) { return v.version; }),
|
|
801
|
+
datasets: [{ label: "Avg context tokens / turn", data: u.byVersion.map(function (v) { return Math.round(v.avgContextTokens); }), backgroundColor: "#6366f1" }],
|
|
802
|
+
},
|
|
803
|
+
options: { plugins: { legend: { display: false } }, scales: { y: { ticks: ticks, grid: grid, beginAtZero: true }, x: { ticks: ticks, grid: grid } } },
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
const tc = document.getElementById("chart-trend");
|
|
807
|
+
if (tc) {
|
|
808
|
+
_usageCharts.trend = new Chart(tc, {
|
|
809
|
+
type: "line",
|
|
810
|
+
data: {
|
|
811
|
+
labels: u.series.map(function (s, i) { return i + 1; }),
|
|
812
|
+
datasets: [
|
|
813
|
+
{ label: "Context tokens", data: u.series.map(function (s) { return s.contextTokens; }), borderColor: "#6366f1", backgroundColor: "transparent", pointRadius: 0, tension: 0.2 },
|
|
814
|
+
{ label: "Output tokens", data: u.series.map(function (s) { return s.outputTokens; }), borderColor: "#22c55e", backgroundColor: "transparent", pointRadius: 0, tension: 0.2 },
|
|
815
|
+
],
|
|
816
|
+
},
|
|
817
|
+
options: { plugins: { legend: { labels: { color: "#888" } } }, scales: { y: { ticks: ticks, grid: grid, beginAtZero: true }, x: { ticks: ticks, grid: grid } } },
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
683
822
|
async function removeAuth(chatId) { await api("/api/auth/remove", { method: "POST", body: { chatId } }); loadTab(); }
|
|
684
823
|
async function approveAuth(chatId) { await api("/api/auth/approve", { method: "POST", body: { chatId } }); loadTab(); }
|
|
685
824
|
async function denyAuth(chatId) { await api("/api/auth/deny", { method: "POST", body: { chatId } }); loadTab(); }
|
|
@@ -756,6 +895,22 @@ function startWebServer() {
|
|
|
756
895
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
757
896
|
if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
|
|
758
897
|
|
|
898
|
+
// One-time magic-link login: /otp/<token> from the bot's /dashboard
|
|
899
|
+
// command. Burns the token, sets the session cookie, redirects to /.
|
|
900
|
+
if (req.method === "GET" && req.url.startsWith("/otp/")) {
|
|
901
|
+
const token = decodeURIComponent(req.url.slice(5).split("?")[0].split("/")[0]);
|
|
902
|
+
const { consume } = require("./core/web-otp");
|
|
903
|
+
if (consume(token)) {
|
|
904
|
+
res.writeHead(302, {
|
|
905
|
+
"Location": "/",
|
|
906
|
+
"Set-Cookie": `oc_session=${authToken()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
907
|
+
});
|
|
908
|
+
return res.end();
|
|
909
|
+
}
|
|
910
|
+
res.writeHead(401, { "Content-Type": "text/html" });
|
|
911
|
+
return res.end('<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,sans-serif;background:#0f0f0f;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;padding:20px}</style></head><body><div><h2>Link expired or already used</h2><p style="color:#888">Send /dashboard in the bot for a fresh link.</p></div></body></html>');
|
|
912
|
+
}
|
|
913
|
+
|
|
759
914
|
if (req.url.startsWith("/api/")) {
|
|
760
915
|
let body = "";
|
|
761
916
|
req.on("data", (d) => { body += d; });
|