@inetafrica/open-claudia 3.5.16 → 3.5.17
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/CHANGELOG.md +35 -0
- package/README.md +1 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +19 -0
- package/channels/kazee/adapter.js +130 -21
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/config.js +19 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +24 -12
- package/core/lessons.js +11 -5
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +259 -6
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +137 -36
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +506 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +4 -1
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +21 -0
- package/test-provider-language.js +8 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-thread-lanes.js +6 -1
- package/web.js +104 -51
package/web.js
CHANGED
|
@@ -373,6 +373,8 @@ async function liveChannels() {
|
|
|
373
373
|
|
|
374
374
|
// ── API routes ─────────────────────────────────────────────────────
|
|
375
375
|
|
|
376
|
+
const USER_ACCESS_ROUTES = new Set(["/api/auth/approve", "/api/auth/deny", "/api/auth/remove"]);
|
|
377
|
+
|
|
376
378
|
async function handleAPI(req, res, body, dependencies = {}) {
|
|
377
379
|
const url = req.url;
|
|
378
380
|
|
|
@@ -406,7 +408,9 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
406
408
|
}
|
|
407
409
|
|
|
408
410
|
// ── Authentication (Claude / Codex) ──
|
|
409
|
-
|
|
411
|
+
// /api/auth/approve|deny|remove are user access (the Users tab), handled further down; they
|
|
412
|
+
// must not fall into this provider sign-in block, whose catch-all answers 404.
|
|
413
|
+
if (url.startsWith("/api/auth/") && !USER_ACCESS_ROUTES.has(url)) {
|
|
410
414
|
const webAuth = require("./core/web-auth");
|
|
411
415
|
const json = (obj, c = 200) => { res.writeHead(c, { "Content-Type": "application/json" }); return res.end(JSON.stringify(obj)); };
|
|
412
416
|
const post = req.method === "POST";
|
|
@@ -652,7 +656,8 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
652
656
|
|
|
653
657
|
// Token usage history (for the Usage dashboard)
|
|
654
658
|
if (url === "/api/usage") {
|
|
655
|
-
|
|
659
|
+
// Turn records only: compaction records (kind "compaction") carry no turn.
|
|
660
|
+
const records = loadUsageHistory().filter((r) => r.kind !== "compaction");
|
|
656
661
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
|
|
657
662
|
const env = loadEnv();
|
|
658
663
|
const alertPolicy = usageAlertPolicy(env);
|
|
@@ -810,8 +815,14 @@ function botDisplayName() {
|
|
|
810
815
|
return "Open Claudia";
|
|
811
816
|
}
|
|
812
817
|
|
|
818
|
+
// BOT_NAME lands inside the page's HTML and inside JS template literals in
|
|
819
|
+
// the page script, so it is escaped for both (entities decode back to text).
|
|
820
|
+
function pageEsc(s) {
|
|
821
|
+
return String(s).replace(/[&<>"'`$\\]/g, (c) => "&#" + c.charCodeAt(0) + ";");
|
|
822
|
+
}
|
|
823
|
+
|
|
813
824
|
function getHTML() {
|
|
814
|
-
const botName = botDisplayName();
|
|
825
|
+
const botName = pageEsc(botDisplayName());
|
|
815
826
|
return `<!DOCTYPE html>
|
|
816
827
|
<html lang="en">
|
|
817
828
|
<head>
|
|
@@ -922,7 +933,7 @@ async function doLogin() {
|
|
|
922
933
|
const pw = $("#pw").value;
|
|
923
934
|
const r = await api("/api/login", { method: "POST", body: { password: pw } });
|
|
924
935
|
if (r?.ok) init();
|
|
925
|
-
else $("#login-msg").innerHTML = '<div class="msg err">' + (r?.error || "Wrong password") + '</div>';
|
|
936
|
+
else $("#login-msg").innerHTML = '<div class="msg err">' + esc(r?.error || "Wrong password") + '</div>';
|
|
926
937
|
}
|
|
927
938
|
|
|
928
939
|
function render() {
|
|
@@ -946,7 +957,7 @@ function render() {
|
|
|
946
957
|
$("#app").innerHTML = \`
|
|
947
958
|
<div class="container">
|
|
948
959
|
<h1>${botName}</h1>
|
|
949
|
-
<p class="subtitle">v\${status.version}</p>
|
|
960
|
+
<p class="subtitle">v\${esc(status.version)}</p>
|
|
950
961
|
<div class="tabs">
|
|
951
962
|
\${tabs.map(t => \`<div class="tab \${currentTab===t.id?'active':''}" onclick="switchTab('\${t.id}')">\${t.label}</div>\`).join("")}
|
|
952
963
|
</div>
|
|
@@ -962,27 +973,34 @@ function switchTab(tab) {
|
|
|
962
973
|
render();
|
|
963
974
|
}
|
|
964
975
|
|
|
965
|
-
//
|
|
966
|
-
function
|
|
967
|
-
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
976
|
+
// Every server-provided string that goes into innerHTML passes through esc.
|
|
977
|
+
function esc(s) {
|
|
978
|
+
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
968
979
|
}
|
|
980
|
+
// A value passed as an argument in an inline onclick="...": a JS string
|
|
981
|
+
// literal, then escaped for the attribute.
|
|
982
|
+
function jsArg(v) { return esc(JSON.stringify(String(v == null ? "" : v))); }
|
|
983
|
+
// Only http(s) links are clickable.
|
|
984
|
+
function safeUrl(u) { return /^https?:\\/\\//i.test(String(u || "")) ? esc(u) : "#"; }
|
|
985
|
+
|
|
986
|
+
// ── Chat tab ──
|
|
969
987
|
|
|
970
988
|
function chatFrameHTML(f) {
|
|
971
989
|
if (f.kind === "message") {
|
|
972
990
|
const cls = f.role === "user" ? "user" : "bot";
|
|
973
|
-
let inner =
|
|
991
|
+
let inner = esc(f.text);
|
|
974
992
|
if (f.buttons && f.buttons.length) {
|
|
975
993
|
inner += '<div class="chat-btns">' + f.buttons.map(function (b) {
|
|
976
|
-
return '<button onclick="chatAction(
|
|
994
|
+
return '<button onclick="chatAction(' + jsArg(b.id) + ',' + jsArg(b.payload || b.id) + ',' + jsArg(f.messageId) + ')">' + esc(b.label) + '</button>';
|
|
977
995
|
}).join("") + '</div>';
|
|
978
996
|
}
|
|
979
|
-
return '<div class="chat-bubble ' + cls + '" data-mid="' +
|
|
997
|
+
return '<div class="chat-bubble ' + cls + '" data-mid="' + esc(f.messageId) + '">' + inner + '</div>';
|
|
980
998
|
}
|
|
981
999
|
if (f.kind === "file") {
|
|
982
|
-
return '<div class="chat-bubble bot" data-mid="' +
|
|
1000
|
+
return '<div class="chat-bubble bot" data-mid="' + esc(f.messageId) + '">📎 <a href="' + safeUrl(f.url) + '" target="_blank" rel="noopener">' + esc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + esc(f.caption) : '') + '</div>';
|
|
983
1001
|
}
|
|
984
1002
|
if (f.kind === "voice") {
|
|
985
|
-
return '<div class="chat-bubble bot" data-mid="' +
|
|
1003
|
+
return '<div class="chat-bubble bot" data-mid="' + esc(f.messageId) + '"><audio controls src="' + esc(f.url) + '"></audio></div>';
|
|
986
1004
|
}
|
|
987
1005
|
return "";
|
|
988
1006
|
}
|
|
@@ -1071,13 +1089,13 @@ async function loadTab() {
|
|
|
1071
1089
|
<div class="card">
|
|
1072
1090
|
<div class="status-bar">
|
|
1073
1091
|
<span><span class="status-dot green"></span> Bot configured</span>
|
|
1074
|
-
<span>v\${status.version}</span>
|
|
1092
|
+
<span>v\${esc(status.version)}</span>
|
|
1075
1093
|
</div>
|
|
1076
|
-
<div class="list-item"><span>Workspace</span><span>\${status.workspace}</span></div>
|
|
1077
|
-
<div class="list-item"><span>Bot Token</span><span>\${status.botToken}</span></div>
|
|
1078
|
-
<div class="list-item"><span>Chat ID</span><span>\${status.chatId}</span></div>
|
|
1079
|
-
<div class="list-item"><span>Default provider</span><span>\${status.defaultProvider?.label || "None (model turns unavailable)"}</span></div>
|
|
1080
|
-
\${(status.providers || []).map(
|
|
1094
|
+
<div class="list-item"><span>Workspace</span><span>\${esc(status.workspace)}</span></div>
|
|
1095
|
+
<div class="list-item"><span>Bot Token</span><span>\${esc(status.botToken)}</span></div>
|
|
1096
|
+
<div class="list-item"><span>Chat ID</span><span>\${esc(status.chatId)}</span></div>
|
|
1097
|
+
<div class="list-item"><span>Default provider</span><span>\${esc(status.defaultProvider?.label || "None (model turns unavailable)")}</span></div>
|
|
1098
|
+
\${(status.providers || []).map(providerRowHTML).join("")}
|
|
1081
1099
|
</div>\`;
|
|
1082
1100
|
}
|
|
1083
1101
|
else if (currentTab === "usage") {
|
|
@@ -1094,7 +1112,7 @@ async function loadTab() {
|
|
|
1094
1112
|
el.innerHTML =
|
|
1095
1113
|
'<div class="card">' +
|
|
1096
1114
|
'<h2>Token Rate</h2>' +
|
|
1097
|
-
'<p style="color:#888;font-size:13px;">Current: v' + u.currentVersion + ' · ' + u.totalRecords + ' turns logged</p>' +
|
|
1115
|
+
'<p style="color:#888;font-size:13px;">Current: v' + esc(u.currentVersion) + ' · ' + esc(u.totalRecords) + ' turns logged</p>' +
|
|
1098
1116
|
'<div class="metric-grid">' +
|
|
1099
1117
|
'<div class="metric"><div class="label">Latest context</div><div class="value">' + fmtK(latestCtx) + '</div></div>' +
|
|
1100
1118
|
'<div class="metric"><div class="label">Recent baseline</div><div class="value">' + fmtK(baselineCtx) + '</div></div>' +
|
|
@@ -1123,8 +1141,8 @@ async function loadTab() {
|
|
|
1123
1141
|
<h2>Authorized Users</h2>
|
|
1124
1142
|
\${auth.authorized.map(a => \`
|
|
1125
1143
|
<div class="list-item">
|
|
1126
|
-
<span>\${a.name || a.chatId} \${a.username ? "@"+a.username : ""} \${a.isOwner ? "(owner)" : ""}</span>
|
|
1127
|
-
\${!a.isOwner ? \`<button class="danger" onclick="removeAuth(
|
|
1144
|
+
<span>\${esc(a.name || a.chatId)} \${a.username ? "@"+esc(a.username) : ""} \${a.isOwner ? "(owner)" : ""}</span>
|
|
1145
|
+
\${!a.isOwner ? \`<button class="danger" onclick="removeAuth(\${jsArg(a.chatId)})">Remove</button>\` : ""}
|
|
1128
1146
|
</div>\`).join("") || "<p>None</p>"}
|
|
1129
1147
|
</div>
|
|
1130
1148
|
\${auth.pending.length > 0 ? \`
|
|
@@ -1132,10 +1150,10 @@ async function loadTab() {
|
|
|
1132
1150
|
<h2>Pending Requests</h2>
|
|
1133
1151
|
\${auth.pending.map(p => \`
|
|
1134
1152
|
<div class="list-item">
|
|
1135
|
-
<span>\${p.name} \${p.username ? "@"+p.username : ""}</span>
|
|
1153
|
+
<span>\${esc(p.name)} \${p.username ? "@"+esc(p.username) : ""}</span>
|
|
1136
1154
|
<span>
|
|
1137
|
-
<button onclick="approveAuth(
|
|
1138
|
-
<button class="danger" onclick="denyAuth(
|
|
1155
|
+
<button onclick="approveAuth(\${jsArg(p.chatId)})">Approve</button>
|
|
1156
|
+
<button class="danger" onclick="denyAuth(\${jsArg(p.chatId)})">Deny</button>
|
|
1139
1157
|
</span>
|
|
1140
1158
|
</div>\`).join("")}
|
|
1141
1159
|
</div>\` : ""}\`;
|
|
@@ -1175,7 +1193,7 @@ async function loadTab() {
|
|
|
1175
1193
|
<div class="card">
|
|
1176
1194
|
<div class="form-group">
|
|
1177
1195
|
<label>Assistant Identity & Personality</label>
|
|
1178
|
-
<textarea id="soul-content" rows="15">\${soul.content}</textarea>
|
|
1196
|
+
<textarea id="soul-content" rows="15">\${esc(soul.content)}</textarea>
|
|
1179
1197
|
</div>
|
|
1180
1198
|
<button onclick="saveSoul()">Save</button>
|
|
1181
1199
|
<span id="soul-msg" style="margin-left:12px;font-size:13px;color:#888;"></span>
|
|
@@ -1189,7 +1207,7 @@ async function loadTab() {
|
|
|
1189
1207
|
<h2>Scheduled Tasks</h2>
|
|
1190
1208
|
\${crons.map((c, i) => \`
|
|
1191
1209
|
<div class="list-item">
|
|
1192
|
-
<span>\${c.label || c.prompt.slice(0,40)} — \${c.schedule} — \${c.project}</span>
|
|
1210
|
+
<span>\${esc(c.label || String(c.prompt || "").slice(0,40))} — \${esc(c.schedule)} — \${esc(c.project)}</span>
|
|
1193
1211
|
</div>\`).join("") || "<p>No cron jobs. Use /cron in Telegram to add them.</p>"}
|
|
1194
1212
|
</div>\`;
|
|
1195
1213
|
}
|
|
@@ -1201,7 +1219,7 @@ async function loadTab() {
|
|
|
1201
1219
|
<h2>Conversation History</h2>
|
|
1202
1220
|
\${sessions.slice().reverse().slice(0,25).map(s => \`
|
|
1203
1221
|
<div class="list-item">
|
|
1204
|
-
<span>\${s.title || s.id} · \${s.provider || "legacy"} · \${s.project}</span>
|
|
1222
|
+
<span>\${esc(s.title || s.id)} · \${esc(s.provider || "legacy")} · \${esc(s.project)}</span>
|
|
1205
1223
|
<span style="color:#666;font-size:12px;">\${s.lastUsed ? new Date(s.lastUsed).toLocaleDateString() : "unknown"}</span>
|
|
1206
1224
|
</div>\`).join("") || "<p>No sessions yet.</p>"}
|
|
1207
1225
|
</div>\`;
|
|
@@ -1220,7 +1238,7 @@ async function loadTab() {
|
|
|
1220
1238
|
el.innerHTML = '<div class="card">' +
|
|
1221
1239
|
'<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;">' +
|
|
1222
1240
|
'<h2 style="margin:0">Browser</h2>' +
|
|
1223
|
-
(list.length > 1 ? '<select onchange="window.browserPick=this.value;loadTab()">' + list.map(x => '<option value="' +
|
|
1241
|
+
(list.length > 1 ? '<select onchange="window.browserPick=this.value;loadTab()">' + list.map(x => '<option value="' + esc(x.name) + '"' + (x.name === pick ? ' selected' : '') + '>' + esc(x.name) + '</option>').join('') + '</select>' : '') +
|
|
1224
1242
|
'</div>' +
|
|
1225
1243
|
'<div id="bv-url" style="color:#888;font-size:12px;margin-bottom:8px;word-break:break-all"></div>' +
|
|
1226
1244
|
'<img id="bv-img" alt="" style="width:100%;border-radius:8px;background:#000;min-height:120px">' +
|
|
@@ -1247,7 +1265,7 @@ async function loadTab() {
|
|
|
1247
1265
|
<h2 style="margin:0">Logs</h2>
|
|
1248
1266
|
<button class="secondary" onclick="loadTab()">Refresh</button>
|
|
1249
1267
|
</div>
|
|
1250
|
-
<pre>\${logs.content}</pre>
|
|
1268
|
+
<pre>\${esc(logs.content)}</pre>
|
|
1251
1269
|
</div>\`;
|
|
1252
1270
|
}
|
|
1253
1271
|
else if (currentTab === "settings") {
|
|
@@ -1307,7 +1325,7 @@ async function loadTab() {
|
|
|
1307
1325
|
<div class="form-group">
|
|
1308
1326
|
<label>\${f.label}</label>
|
|
1309
1327
|
<div style="color:#777;font-size:11px;margin:2px 0 6px;">\${f.desc}</div>
|
|
1310
|
-
<input id="cfg-\${f.k}" value="\${config[f.k] || ""}" placeholder="\${f.ph || ""}">
|
|
1328
|
+
<input id="cfg-\${f.k}" value="\${esc(config[f.k] || "")}" placeholder="\${f.ph || ""}">
|
|
1311
1329
|
</div>\`).join("")}
|
|
1312
1330
|
</div>\`).join("");
|
|
1313
1331
|
}
|
|
@@ -1323,12 +1341,12 @@ function usagePolicyHTML(policy, trend) {
|
|
|
1323
1341
|
const parts = [];
|
|
1324
1342
|
if (policy.absoluteContextTokens > 0) parts.push('absolute ceiling ' + fmtK(policy.absoluteContextTokens));
|
|
1325
1343
|
if (policy.rateMultiplier > 0) {
|
|
1326
|
-
parts.push('rate ceiling ' + policy.rateMultiplier + 'x after ' + policy.minBaselineTurns + ' baseline turns');
|
|
1344
|
+
parts.push('rate ceiling ' + esc(policy.rateMultiplier) + 'x after ' + esc(policy.minBaselineTurns) + ' baseline turns');
|
|
1327
1345
|
}
|
|
1328
1346
|
const baseline = trend && trend.baselineTurns ? trend.baselineTurns : 0;
|
|
1329
1347
|
return '<div class="policy">Alert policy: ' + parts.join(' · ') +
|
|
1330
1348
|
' · cooldown ' + Math.round((policy.cooldownMs || 0) / 60000) + ' min' +
|
|
1331
|
-
' · baseline sample ' + baseline + '/' + policy.baselineTurns + ' turns</div>';
|
|
1349
|
+
' · baseline sample ' + esc(baseline) + '/' + esc(policy.baselineTurns) + ' turns</div>';
|
|
1332
1350
|
}
|
|
1333
1351
|
|
|
1334
1352
|
function usageTableHTML(rows, keyName) {
|
|
@@ -1337,9 +1355,9 @@ function usageTableHTML(rows, keyName) {
|
|
|
1337
1355
|
'</th><th>turns</th><th>avg ctx/turn</th><th>avg in</th><th>avg out</th><th>cost</th></tr></thead><tbody>';
|
|
1338
1356
|
const body = rows.map(function (r) {
|
|
1339
1357
|
const name = r[keyName] || "—";
|
|
1340
|
-
return '<tr><td>' + name + '</td><td>' + r.turns + '</td><td>' + fmtK(r.avgContextTokens) +
|
|
1358
|
+
return '<tr><td>' + esc(name) + '</td><td>' + esc(r.turns) + '</td><td>' + fmtK(r.avgContextTokens) +
|
|
1341
1359
|
'</td><td>' + fmtK(r.avgInputTokens) + '</td><td>' + fmtK(r.avgOutputTokens) +
|
|
1342
|
-
'</td><td>$' + (r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
|
|
1360
|
+
'</td><td>$' + Number(r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
|
|
1343
1361
|
}).join("");
|
|
1344
1362
|
return head + body + "</tbody></table>";
|
|
1345
1363
|
}
|
|
@@ -1410,7 +1428,7 @@ async function pollClaudeAuth() {
|
|
|
1410
1428
|
}
|
|
1411
1429
|
if (st.url) {
|
|
1412
1430
|
box.innerHTML = \`<p style="font-size:13px;">Open this URL, sign in, then paste the code Claude gives you:</p>
|
|
1413
|
-
<p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
|
|
1431
|
+
<p style="word-break:break-all;"><a href="\${safeUrl(st.url)}" target="_blank" rel="noopener">\${esc(st.url)}</a></p>
|
|
1414
1432
|
<input id="claude-code" placeholder="Paste code here">
|
|
1415
1433
|
<button onclick="submitClaudeCodeWeb()" style="margin-top:8px;">Submit code</button>\`;
|
|
1416
1434
|
} else {
|
|
@@ -1433,7 +1451,7 @@ async function saveClaudeTokenWeb() {
|
|
|
1433
1451
|
async function startCodexAuth() {
|
|
1434
1452
|
$("#codex-flow").innerHTML = "<p class='subtitle'>Starting…</p>";
|
|
1435
1453
|
const r = await api("/api/auth/codex/start", { method: "POST" });
|
|
1436
|
-
if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + r.error + "</div>"; return; }
|
|
1454
|
+
if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + esc(r.error) + "</div>"; return; }
|
|
1437
1455
|
pollCodexAuth();
|
|
1438
1456
|
}
|
|
1439
1457
|
async function pollCodexAuth() {
|
|
@@ -1449,8 +1467,8 @@ async function pollCodexAuth() {
|
|
|
1449
1467
|
}
|
|
1450
1468
|
if (st.url) {
|
|
1451
1469
|
box.innerHTML = \`<p style="font-size:13px;">Open this URL and enter the code:</p>
|
|
1452
|
-
<p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
|
|
1453
|
-
\${st.code ? "<p>Device code: <b>" + st.code + "</b></p>" : ""}
|
|
1470
|
+
<p style="word-break:break-all;"><a href="\${safeUrl(st.url)}" target="_blank" rel="noopener">\${esc(st.url)}</a></p>
|
|
1471
|
+
\${st.code ? "<p>Device code: <b>" + esc(st.code) + "</b></p>" : ""}
|
|
1454
1472
|
<p class='subtitle'>Waiting for you to finish in the browser…</p>\`;
|
|
1455
1473
|
} else {
|
|
1456
1474
|
box.innerHTML = "<p class='subtitle'>Starting…</p>";
|
|
@@ -1486,10 +1504,14 @@ async function saveConfig() {
|
|
|
1486
1504
|
setTimeout(() => $("#config-msg").textContent = "", 3000);
|
|
1487
1505
|
}
|
|
1488
1506
|
|
|
1507
|
+
function providerRowHTML(p) {
|
|
1508
|
+
return '<div class="list-item"><span>' + esc(p.label) + '</span><span>' + esc(p.availability.state) + ' · ' + esc(p.compatibility.state) + ' · auth ' + esc(p.auth.state) + '</span></div>';
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1489
1511
|
function renderSetup() {
|
|
1490
1512
|
const providerRows = (status.providers || []).map(p =>
|
|
1491
|
-
|
|
1492
|
-
(p.recovery ? '<div style="color:#888;font-size:11px;margin:-8px 0 10px;">' + p.recovery.split("\\n")[0] + '</div>' : "")
|
|
1513
|
+
providerRowHTML(p) +
|
|
1514
|
+
(p.recovery ? '<div style="color:#888;font-size:11px;margin:-8px 0 10px;">' + esc(String(p.recovery).split("\\n")[0]) + '</div>' : "")
|
|
1493
1515
|
).join("");
|
|
1494
1516
|
$("#app").innerHTML = \`
|
|
1495
1517
|
<div class="container">
|
|
@@ -1529,7 +1551,7 @@ async function doSetup() {
|
|
|
1529
1551
|
currentTab = "dashboard";
|
|
1530
1552
|
await init();
|
|
1531
1553
|
} else {
|
|
1532
|
-
$("#setup-msg").innerHTML = \`<div class="msg err">\${r?.error || "Setup failed"}</div>\`;
|
|
1554
|
+
$("#setup-msg").innerHTML = \`<div class="msg err">\${esc(r?.error || "Setup failed")}</div>\`;
|
|
1533
1555
|
}
|
|
1534
1556
|
}
|
|
1535
1557
|
|
|
@@ -1541,23 +1563,55 @@ init();
|
|
|
1541
1563
|
|
|
1542
1564
|
// ── Server ─────────────────────────────────────────────────────────
|
|
1543
1565
|
|
|
1566
|
+
// A failing handler answers 500 and is logged here. It must never become an
|
|
1567
|
+
// unhandled rejection: bot.js reports those to the owner, and some routes
|
|
1568
|
+
// (/otp/) need no sign-in, so anyone could make the bot message its owner.
|
|
1569
|
+
function answerFailure(res, err) {
|
|
1570
|
+
console.error("web: request failed:", err?.message || err);
|
|
1571
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
1572
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1573
|
+
res.end("Server error\n");
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1544
1576
|
let webServer = null;
|
|
1545
1577
|
|
|
1546
1578
|
function startWebServer(options = {}) {
|
|
1547
1579
|
if (webServer) return webServer;
|
|
1548
1580
|
ensureConfigDir(CONFIG_DIR);
|
|
1549
1581
|
|
|
1550
|
-
const server = http.createServer(
|
|
1582
|
+
const server = http.createServer((req, res) => {
|
|
1583
|
+
handleRequest(req, res).catch((e) => answerFailure(res, e));
|
|
1584
|
+
});
|
|
1585
|
+
async function handleRequest(req, res) {
|
|
1551
1586
|
// CORS for local dev
|
|
1552
1587
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1553
1588
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1554
1589
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1555
1590
|
if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
|
|
1556
1591
|
|
|
1557
|
-
// One-time magic-link login: /otp/<token> from the bot's /dashboard
|
|
1558
|
-
//
|
|
1559
|
-
|
|
1560
|
-
|
|
1592
|
+
// One-time magic-link login: /otp/<token> from the bot's /dashboard command. Opening the
|
|
1593
|
+
// link (GET) only shows a page that posts it back; the POST burns the token, sets the
|
|
1594
|
+
// session cookie and redirects to /. Link previews and scanners only fetch (Kazee Chat
|
|
1595
|
+
// fetches every link in a message, the bot's own replies included: HEAD, then GET), so they
|
|
1596
|
+
// can no longer use the link up before the person opens it. A malformed link answers 400.
|
|
1597
|
+
if (req.url.startsWith("/otp/") && ["GET", "HEAD", "POST"].includes(req.method)) {
|
|
1598
|
+
let token;
|
|
1599
|
+
try { token = decodeURIComponent(req.url.slice(5).split("?")[0].split("/")[0]); }
|
|
1600
|
+
catch (e) {
|
|
1601
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1602
|
+
return res.end("Bad link. Send /dashboard in the bot for a fresh one.\n");
|
|
1603
|
+
}
|
|
1604
|
+
const page = (status, inner, extra = "") => {
|
|
1605
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "same-origin", "X-Robots-Tag": "noindex" });
|
|
1606
|
+
if (req.method === "HEAD") return res.end();
|
|
1607
|
+
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}button{font-size:16px;padding:10px 18px;border-radius:8px;border:0;background:#6366f1;color:#fff}</style></head><body><div>${inner}</div>${extra}</body></html>`);
|
|
1608
|
+
};
|
|
1609
|
+
const expired = () => page(401, '<h2>Link expired or already used</h2><p style="color:#888">Send /dashboard in the bot for a fresh link.</p>');
|
|
1610
|
+
if (!/^[0-9a-f]{64}$/.test(token)) return expired();
|
|
1611
|
+
if (req.method !== "POST") {
|
|
1612
|
+
return page(200, `<form method="post" action="/otp/${token}"><h2>Opening your dashboard…</h2><button type="submit">Open dashboard</button></form>`, "<script>document.forms[0].submit()</script>");
|
|
1613
|
+
}
|
|
1614
|
+
req.resume();
|
|
1561
1615
|
const { consume } = require("./core/web-otp");
|
|
1562
1616
|
if (consume(token)) {
|
|
1563
1617
|
res.writeHead(302, {
|
|
@@ -1566,21 +1620,20 @@ function startWebServer(options = {}) {
|
|
|
1566
1620
|
});
|
|
1567
1621
|
return res.end();
|
|
1568
1622
|
}
|
|
1569
|
-
|
|
1570
|
-
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>');
|
|
1623
|
+
return expired();
|
|
1571
1624
|
}
|
|
1572
1625
|
|
|
1573
1626
|
if (req.url.startsWith("/api/")) {
|
|
1574
1627
|
let body = "";
|
|
1575
1628
|
req.on("data", (d) => { body += d; });
|
|
1576
|
-
req.on("end", () => handleAPI(req, res, body, options));
|
|
1629
|
+
req.on("end", () => handleAPI(req, res, body, options).catch((e) => answerFailure(res, e)));
|
|
1577
1630
|
return;
|
|
1578
1631
|
}
|
|
1579
1632
|
|
|
1580
1633
|
// Serve HTML
|
|
1581
1634
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1582
1635
|
res.end(getHTML());
|
|
1583
|
-
}
|
|
1636
|
+
}
|
|
1584
1637
|
|
|
1585
1638
|
server.listen(PORT, () => {
|
|
1586
1639
|
console.log(`Web UI running on http://localhost:${PORT}`);
|