@inetafrica/open-claudia 3.0.12 → 3.0.14
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 +14 -0
- package/bot.js +21 -1
- package/channels/telegram/adapter.js +10 -2
- package/channels/web/adapter.js +263 -0
- package/core/access.js +4 -0
- package/core/actions.js +1 -0
- package/core/adapter-registry.js +2 -0
- package/core/handlers.js +3 -0
- package/core/identity.js +3 -0
- package/core/restart-reason.js +42 -0
- package/docker-entrypoint.sh +23 -0
- package/package.json +1 -1
- package/test-provider-language.js +6 -6
- package/web.js +208 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.14 — the Telegram poll stops wedging, and every reboot says why
|
|
4
|
+
|
|
5
|
+
- **Root-cause fix for the restart flap.** In the cluster, DNS hands back a dead IPv6 address for `api.telegram.org` first and IPv6 egress is unreachable; under Node's default `verbatim` resolution the long-poll dials the dead address first (a latency penalty always, and a hard wedge on any AAAA-only window). `bot.js` now sets `dns.setDefaultResultOrder("ipv4first")` before any socket opens, eliminating that path for every request the process makes.
|
|
6
|
+
- **Half-open long-polls now fail fast instead of wedging for 10 minutes.** The Telegram `request` block gained `timeout: 40000` — with keep-alive off, `request` arms a socket-idle timeout, so a poll whose socket silently dies is killed (`ESOCKETTIMEDOUT` → `EFATAL` → hiccup → heal on a fresh socket) in ~40s rather than sitting dead until the 10-minute backstop calls `process.exit(1)` and the pod restarts. 40s sits safely above the 30s long-poll hold so healthy idle polls are never chopped; `ESOCKETTIMEDOUT` was also added explicitly to the transient-error classifier.
|
|
7
|
+
- **Every code-initiated reboot now reports its reason.** New `core/restart-reason.js` records *why* right before any deliberate exit (polling-wedge, uncaught exception, boot failure, graceful SIGTERM/SIGINT, `/restart`, `/upgrade`, runtime-mode switch); the "Back online" greeting reads and clears it and appends `↳ Reboot reason: …`. No record on boot ⇒ an external hard restart (SIGKILL, OOM, host or k8s force-restart) that nothing could log — the greeting says so explicitly. This turns a silent "Back online" into an actionable one.
|
|
8
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
|
|
9
|
+
|
|
10
|
+
## v3.0.13 — chat with the bot from its own dashboard
|
|
11
|
+
|
|
12
|
+
- **The web dashboard gains a Chat tab — a real channel, not a side door.** A new in-process `WebAdapter` (`channels/web/`) registers as a first-class channel whenever the web UI is up: messages typed in the dashboard flow through the same router, identity, packs, transcripts, and approval pipeline as Telegram/Kazee/voice, and the bot's replies (including inline approval buttons, files, voice notes, live typing) stream back over `/api/chat/stream` (SSE with history replay on connect). New session-gated routes: `GET /api/chat/stream`, `POST /api/chat/send`, `POST /api/chat/action`, `GET /api/chat/media/<id>`. The channel is fixed single-owner (`web-owner`): the dashboard session already gates every call, so `access.js`/`identity.js` authorize it as the owner and — under unified identity — it resolves to the owner's canonical brain like every other owner channel.
|
|
13
|
+
- **Platform SSO: `POST /api/otp/mint` (control-token only).** AgentSpace can mint a one-time magic-link token (`/otp/<token>`, 10-min TTL, single-use — the existing `core/web-otp` machinery behind `/dashboard`) over `BOT_CONTROL_TOKEN`. Dashboard sessions get a 403: they are already logged in and gain nothing, so the surface stays strictly machine-to-machine.
|
|
14
|
+
- **SSE unsubscribe bug fixed before it shipped.** Cleanup was hooked on `req.on("close")`, which in modern Node fires as soon as the GET body completes — every stream subscriber was torn down instantly after the history frame. Cleanup now hooks `res.on("close")` (actual connection teardown), verified by an end-to-end live-frame test.
|
|
15
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up only on their next approved image upgrade; new pods get it immediately. `channels/` already ships in `files`, so the adapter rides the existing package layout.
|
|
16
|
+
|
|
3
17
|
## v3.0.10 — Kazee-only pods are first-class
|
|
4
18
|
|
|
5
19
|
- **/doctor no longer demands Telegram env on a Kazee-only install.** `health.js` required `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` unconditionally — a pod provisioned with `CHANNELS=kazee` failed its env check despite being perfectly configured. Required keys are now derived from `CHANNELS` (default `telegram`, preserving legacy behaviour): telegram installs require the Telegram pair, kazee installs require `KAZEE_BOT_TOKEN`, and `WORKSPACE` stays universal.
|
package/bot.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
// - Handle process signals + crash notifications.
|
|
7
7
|
// Heavy lifting lives in core/* and channels/*; this file is just the wire.
|
|
8
8
|
|
|
9
|
+
// Prefer IPv4 for every DNS lookup in this process. In the cluster, DNS returns
|
|
10
|
+
// a dead IPv6 address for api.telegram.org first and IPv6 egress is unreachable;
|
|
11
|
+
// with Node's default 'verbatim' order the Telegram long-poll dials the dead
|
|
12
|
+
// address first (250-600ms penalty, and a hard wedge on any AAAA-only window).
|
|
13
|
+
// ipv4first removes that path. Must run before any module opens a socket.
|
|
14
|
+
require("dns").setDefaultResultOrder("ipv4first");
|
|
15
|
+
|
|
9
16
|
const path = require("path");
|
|
10
17
|
const fs = require("fs");
|
|
11
18
|
const { execSync } = require("child_process");
|
|
@@ -134,6 +141,7 @@ async function gracefulShutdown(signal) {
|
|
|
134
141
|
}
|
|
135
142
|
} catch (e) {}
|
|
136
143
|
persist();
|
|
144
|
+
try { require("./core/restart-reason").recordExitReason(`Graceful shutdown on ${signal} (external stop/rollout/scale)`, { code: 0 }); } catch (e) {}
|
|
137
145
|
process.exit(0);
|
|
138
146
|
}
|
|
139
147
|
|
|
@@ -183,6 +191,7 @@ function notifyError(label, err) {
|
|
|
183
191
|
|
|
184
192
|
process.on("uncaughtException", (err) => {
|
|
185
193
|
notifyError("Uncaught exception", err);
|
|
194
|
+
try { require("./core/restart-reason").recordExitReason(`Uncaught exception: ${err?.message || err}`, { code: 1 }); } catch (e) {}
|
|
186
195
|
try { persist(); } catch (e) {}
|
|
187
196
|
setTimeout(() => process.exit(1), 2000);
|
|
188
197
|
});
|
|
@@ -271,6 +280,10 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
271
280
|
if (process.env.WEB_UI === "true") {
|
|
272
281
|
try {
|
|
273
282
|
require("./web.js").startWebServer();
|
|
283
|
+
// Dashboard chat rides a real channel adapter so it gets the full
|
|
284
|
+
// pipeline (identity, packs, transcripts, approval buttons).
|
|
285
|
+
const webChat = await registry.addAdapter({ type: "web", id: "web" }, { publicCommands: publicCommands() });
|
|
286
|
+
if (!webChat.ok) console.error("Web chat adapter failed:", webChat.error);
|
|
274
287
|
} catch (e) {
|
|
275
288
|
console.error("Web UI start failed:", e.message);
|
|
276
289
|
}
|
|
@@ -299,10 +312,16 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
299
312
|
// chat_id — boot onboarding below opens the DM when outreach is needed.
|
|
300
313
|
// Skipped entirely pre-onboarding: a fresh pod's first words should be
|
|
301
314
|
// the onboarding greeting, not "Back online".
|
|
315
|
+
// Consume (and clear) the exit reason once, up front: a code-initiated exit
|
|
316
|
+
// recorded why; no record ⇒ an external hard restart nothing could log.
|
|
317
|
+
const exitInfo = require("./core/restart-reason").consumeExitReason();
|
|
318
|
+
const reasonLine = exitInfo
|
|
319
|
+
? `\n↳ Reboot reason: ${exitInfo.reason}`
|
|
320
|
+
: "\n↳ Reboot reason: external / hard restart — no code-side reason recorded (e.g. SIGKILL, OOM, host or k8s force-restart).";
|
|
302
321
|
for (const a of adapters) {
|
|
303
322
|
try {
|
|
304
323
|
if (a.type === "telegram" && CHAT_ID && isOnboarded()) {
|
|
305
|
-
await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}
|
|
324
|
+
await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.${reasonLine}`);
|
|
306
325
|
}
|
|
307
326
|
} catch (e) {}
|
|
308
327
|
}
|
|
@@ -343,5 +362,6 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
343
362
|
}
|
|
344
363
|
})().catch((e) => {
|
|
345
364
|
console.error("Boot failure:", e);
|
|
365
|
+
try { require("./core/restart-reason").recordExitReason(`Boot failure: ${e?.message || e}`, { code: 1 }); } catch (e2) {}
|
|
346
366
|
process.exit(1);
|
|
347
367
|
});
|
|
@@ -28,7 +28,14 @@ class TelegramAdapter {
|
|
|
28
28
|
this._agent = new https.Agent({ keepAlive: false });
|
|
29
29
|
this.bot = new TelegramBot(token, {
|
|
30
30
|
polling: { autoStart: false, params: { timeout: 30 } },
|
|
31
|
-
request
|
|
31
|
+
// Socket-idle timeout on every request (getUpdates included). With
|
|
32
|
+
// keepAlive off, `request` arms req.setTimeout, so a half-open long-poll
|
|
33
|
+
// that stops sending bytes is killed here (ESOCKETTIMEDOUT → EFATAL →
|
|
34
|
+
// _onPollingHiccup → heal on a fresh socket) in ~40s instead of silently
|
|
35
|
+
// wedging until the 10-min backstop exits the process. MUST exceed the
|
|
36
|
+
// 30s long-poll hold (params.timeout:30) or it would abort healthy idle
|
|
37
|
+
// polls; 40s leaves a 10s margin.
|
|
38
|
+
request: { agent: this._agent, timeout: 40000 },
|
|
32
39
|
});
|
|
33
40
|
this._listeners = { message: new Set(), action: new Set() };
|
|
34
41
|
this._healTimer = null; // one active recovery in flight at a time
|
|
@@ -143,6 +150,7 @@ class TelegramAdapter {
|
|
|
143
150
|
// wedge, and exiting busts the prompt cache (full uncached re-bill of the
|
|
144
151
|
// conversation window on the next turn).
|
|
145
152
|
console.error("Polling wedged >10min — exiting for a clean restart.");
|
|
153
|
+
try { require("../../core/restart-reason").recordExitReason("Telegram polling wedged >10min (long-poll unrecoverable) — self-exit for a clean restart", { code: 1 }); } catch (e) {}
|
|
146
154
|
process.exit(1);
|
|
147
155
|
}
|
|
148
156
|
try {
|
|
@@ -216,7 +224,7 @@ class TelegramAdapter {
|
|
|
216
224
|
this._on409Conflict();
|
|
217
225
|
return;
|
|
218
226
|
}
|
|
219
|
-
if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
|
|
227
|
+
if (/ESOCKETTIMEDOUT|ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
|
|
220
228
|
this._onPollingHiccup(msg); // transient — heal quietly, never exit
|
|
221
229
|
return;
|
|
222
230
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// WebAdapter — the dashboard chat channel.
|
|
2
|
+
//
|
|
3
|
+
// Unlike Telegram/Kazee/Voice this adapter owns no transport: web.js runs
|
|
4
|
+
// in the same process and feeds it directly. Inbound: postText/postAction
|
|
5
|
+
// called by the /api/chat endpoints (session- or control-token-gated).
|
|
6
|
+
// Outbound: send/edit/typing broadcast frames to subscribed SSE clients
|
|
7
|
+
// and into a small in-memory history ring so a page reload can replay the
|
|
8
|
+
// conversation since boot. Everything routes through the same core
|
|
9
|
+
// handlers as the other channels, so web chat has the full agent
|
|
10
|
+
// capability set — including approval buttons.
|
|
11
|
+
//
|
|
12
|
+
// Security: single-owner channel. web.js only calls in after its own auth
|
|
13
|
+
// (dashboard session cookie or BOT_CONTROL_TOKEN), so every envelope
|
|
14
|
+
// carries the fixed owner id and access.js authorizes it as the owner.
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const crypto = require("crypto");
|
|
19
|
+
const { canonicalForChannel } = require("../../core/identity");
|
|
20
|
+
const { inlineKeyboardToPortable } = require("../types");
|
|
21
|
+
|
|
22
|
+
const HISTORY_LIMIT = 200;
|
|
23
|
+
const MEDIA_TTL_MS = 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
const WEB_OWNER_ID = "web-owner";
|
|
26
|
+
|
|
27
|
+
class WebAdapter {
|
|
28
|
+
constructor({ id = "web" } = {}) {
|
|
29
|
+
this.id = id;
|
|
30
|
+
this.type = "web";
|
|
31
|
+
this.ownerUserId = WEB_OWNER_ID;
|
|
32
|
+
// Single conversation: the channel id is the owner identity.
|
|
33
|
+
this.channelId = WEB_OWNER_ID;
|
|
34
|
+
this._listeners = { message: new Set(), action: new Set() };
|
|
35
|
+
this._subscribers = new Set();
|
|
36
|
+
this._history = [];
|
|
37
|
+
this._media = new Map(); // id -> { path, mime, fileName, keep, expires }
|
|
38
|
+
this._commands = [];
|
|
39
|
+
this._sweepTimer = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
on(event, fn) {
|
|
43
|
+
if (!this._listeners[event]) return () => {};
|
|
44
|
+
this._listeners[event].add(fn);
|
|
45
|
+
return () => this._listeners[event].delete(fn);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_emit(event, envelope) {
|
|
49
|
+
for (const fn of this._listeners[event] || []) {
|
|
50
|
+
try { Promise.resolve(fn(envelope)).catch((e) => console.error(`web ${event} handler:`, e.message)); }
|
|
51
|
+
catch (e) { console.error(`web ${event} handler:`, e.message); }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async start() {
|
|
56
|
+
this._sweepTimer = setInterval(() => this._sweepMedia(), 5 * 60 * 1000);
|
|
57
|
+
if (this._sweepTimer.unref) this._sweepTimer.unref();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async stop() {
|
|
61
|
+
if (this._sweepTimer) clearInterval(this._sweepTimer);
|
|
62
|
+
this._subscribers.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── inbound (called by web.js /api/chat routes) ─────────────────
|
|
66
|
+
|
|
67
|
+
postText(text) {
|
|
68
|
+
const body = String(text || "");
|
|
69
|
+
if (!body.trim()) return null;
|
|
70
|
+
const messageId = this._mkId("t");
|
|
71
|
+
// Echo into history/stream so every connected tab sees the user's turn.
|
|
72
|
+
this._pushFrame({ kind: "message", role: "user", messageId, text: body, ts: Date.now() });
|
|
73
|
+
this._emit("message", {
|
|
74
|
+
adapter: this,
|
|
75
|
+
channelId: this.channelId,
|
|
76
|
+
canonicalUserId: canonicalForChannel("web", this.channelId),
|
|
77
|
+
userId: this.ownerUserId,
|
|
78
|
+
type: body.trim().startsWith("/") ? "command" : "text",
|
|
79
|
+
text: body,
|
|
80
|
+
messageId,
|
|
81
|
+
from: { id: this.ownerUserId, name: "Owner", username: "" },
|
|
82
|
+
raw: { source: "web" },
|
|
83
|
+
});
|
|
84
|
+
return messageId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
postAction({ id, payload, messageId } = {}) {
|
|
88
|
+
const p = String(payload != null ? payload : (id != null ? id : ""));
|
|
89
|
+
if (!p) return null;
|
|
90
|
+
const callbackId = this._mkId("cb");
|
|
91
|
+
this._emit("action", {
|
|
92
|
+
adapter: this,
|
|
93
|
+
channelId: this.channelId,
|
|
94
|
+
canonicalUserId: canonicalForChannel("web", this.channelId),
|
|
95
|
+
userId: this.ownerUserId,
|
|
96
|
+
type: "action",
|
|
97
|
+
messageId,
|
|
98
|
+
from: { id: this.ownerUserId, name: "Owner", username: "" },
|
|
99
|
+
action: {
|
|
100
|
+
buttonId: String(id != null ? id : p),
|
|
101
|
+
payload: p,
|
|
102
|
+
sourceMessageId: messageId,
|
|
103
|
+
callbackId,
|
|
104
|
+
},
|
|
105
|
+
raw: { source: "web" },
|
|
106
|
+
});
|
|
107
|
+
return callbackId;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── stream plumbing (web.js SSE endpoint) ───────────────────────
|
|
111
|
+
|
|
112
|
+
subscribe(fn) {
|
|
113
|
+
this._subscribers.add(fn);
|
|
114
|
+
return () => this._subscribers.delete(fn);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
history() {
|
|
118
|
+
return this._history.slice();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getMedia(id) {
|
|
122
|
+
const entry = this._media.get(id);
|
|
123
|
+
if (!entry || !fs.existsSync(entry.path)) return null;
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_pushFrame(frame) {
|
|
128
|
+
this._history.push(frame);
|
|
129
|
+
if (this._history.length > HISTORY_LIMIT) this._history.splice(0, this._history.length - HISTORY_LIMIT);
|
|
130
|
+
for (const fn of this._subscribers) {
|
|
131
|
+
try { fn(frame); } catch (e) {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── outbound contract (called by core/io.js) ────────────────────
|
|
136
|
+
|
|
137
|
+
_normalizeKeyboard(keyboard) {
|
|
138
|
+
if (!keyboard) return null;
|
|
139
|
+
if (keyboard.buttons) return keyboard.buttons;
|
|
140
|
+
if (keyboard.inline_keyboard) return inlineKeyboardToPortable(keyboard.inline_keyboard);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async send(channelId, text, opts = {}) {
|
|
145
|
+
const messageId = this._mkId("a");
|
|
146
|
+
this._pushFrame({
|
|
147
|
+
kind: "message",
|
|
148
|
+
role: "assistant",
|
|
149
|
+
messageId,
|
|
150
|
+
text: text || "",
|
|
151
|
+
buttons: this._normalizeKeyboard(opts.keyboard),
|
|
152
|
+
replyTo: opts.replyTo || null,
|
|
153
|
+
ts: Date.now(),
|
|
154
|
+
});
|
|
155
|
+
return messageId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async edit(channelId, messageId, text, opts = {}) {
|
|
159
|
+
const frame = {
|
|
160
|
+
kind: "edit",
|
|
161
|
+
messageId,
|
|
162
|
+
text: text || "",
|
|
163
|
+
buttons: this._normalizeKeyboard(opts.keyboard),
|
|
164
|
+
ts: Date.now(),
|
|
165
|
+
};
|
|
166
|
+
// Keep history coherent: apply the edit to the stored message so a
|
|
167
|
+
// reload shows the final text, not the first streamed fragment.
|
|
168
|
+
const original = this._history.find((f) => f.kind === "message" && f.messageId === messageId);
|
|
169
|
+
if (original) { original.text = frame.text; original.buttons = frame.buttons; }
|
|
170
|
+
this._pushFrame(frame);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async delete(channelId, messageId) {
|
|
174
|
+
// Prune the message AND its edit frames so a reload replays nothing stale.
|
|
175
|
+
this._history = this._history.filter(
|
|
176
|
+
(f) => !((f.kind === "message" || f.kind === "edit") && f.messageId === messageId)
|
|
177
|
+
);
|
|
178
|
+
this._pushFrame({ kind: "delete", messageId, ts: Date.now() });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async sendVoice(channelId, audioPath) {
|
|
182
|
+
try {
|
|
183
|
+
const id = this._registerMedia(audioPath, this._guessMime(audioPath), path.basename(audioPath));
|
|
184
|
+
this._pushFrame({ kind: "voice", messageId: this._mkId("v"), url: `/api/chat/media/${id}`, ts: Date.now() });
|
|
185
|
+
return true;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async sendPhoto(channelId, filePath, caption) { return this.sendFile(channelId, filePath, caption); }
|
|
192
|
+
|
|
193
|
+
async sendFile(channelId, filePath, caption) {
|
|
194
|
+
try {
|
|
195
|
+
const fileName = path.basename(filePath);
|
|
196
|
+
const id = this._registerMedia(filePath, this._guessMime(fileName), fileName, /* keep */ true);
|
|
197
|
+
this._pushFrame({
|
|
198
|
+
kind: "file",
|
|
199
|
+
messageId: this._mkId("f"),
|
|
200
|
+
url: `/api/chat/media/${id}`,
|
|
201
|
+
fileName,
|
|
202
|
+
caption: caption || "",
|
|
203
|
+
ts: Date.now(),
|
|
204
|
+
});
|
|
205
|
+
return true;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async typing(channelId) {
|
|
212
|
+
// Ephemeral — streamed to live clients but never recorded in history.
|
|
213
|
+
for (const fn of this._subscribers) {
|
|
214
|
+
try { fn({ kind: "typing", ts: Date.now() }); } catch (e) {}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async downloadMedia(media) {
|
|
219
|
+
if (!media) return null;
|
|
220
|
+
return media.fileId || null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async registerCommands(commands) {
|
|
224
|
+
this._commands = (commands || [])
|
|
225
|
+
.filter((c) => c && c.name)
|
|
226
|
+
.map((c) => ({ name: String(c.name).replace(/^\//, ""), description: String(c.description || ""), args: typeof c.args === "string" ? c.args : "" }));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── media store ─────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
_registerMedia(filePath, mime, fileName, keep = false) {
|
|
232
|
+
const id = crypto.randomBytes(9).toString("hex");
|
|
233
|
+
this._media.set(id, { path: filePath, mime, fileName, keep, expires: Date.now() + MEDIA_TTL_MS });
|
|
234
|
+
return id;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_sweepMedia() {
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
for (const [id, entry] of this._media) {
|
|
240
|
+
if (entry.expires <= now) {
|
|
241
|
+
this._media.delete(id);
|
|
242
|
+
if (!entry.keep) { try { fs.unlinkSync(entry.path); } catch (e) {} }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── helpers ─────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
_mkId(prefix) { return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; }
|
|
250
|
+
|
|
251
|
+
_guessMime(fileName) {
|
|
252
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
253
|
+
const map = {
|
|
254
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
255
|
+
".webp": "image/webp", ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".m4a": "audio/mp4",
|
|
256
|
+
".wav": "audio/wav", ".mp4": "video/mp4", ".pdf": "application/pdf", ".txt": "text/plain",
|
|
257
|
+
".json": "application/json", ".csv": "text/csv",
|
|
258
|
+
};
|
|
259
|
+
return map[ext] || "application/octet-stream";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { WebAdapter, WEB_OWNER_ID };
|
package/core/access.js
CHANGED
|
@@ -17,6 +17,10 @@ function transportOwnerUserId(transport) {
|
|
|
17
17
|
// The voice channel is single-owner: the bearer token gates the connection,
|
|
18
18
|
// and every envelope carries this fixed owner id, so it authorizes as owner.
|
|
19
19
|
if (transport === "voice") return config.VOICE_OWNER_USER_ID || "voice-owner";
|
|
20
|
+
// The web channel is single-owner: the dashboard session (password or
|
|
21
|
+
// magic-link) gates every call into the adapter, so its fixed owner id
|
|
22
|
+
// authorizes as owner.
|
|
23
|
+
if (transport === "web") return "web-owner";
|
|
20
24
|
return "";
|
|
21
25
|
}
|
|
22
26
|
|
package/core/actions.js
CHANGED
|
@@ -557,6 +557,7 @@ async function handleAction(envelope) {
|
|
|
557
557
|
fs.writeFileSync(modeFile, newMode);
|
|
558
558
|
await send(`Switching to ${newMode} mode... restarting.`);
|
|
559
559
|
await sideChatCoordinator.cancelAll();
|
|
560
|
+
try { require("./restart-reason").recordExitReason(`Runtime mode switch → ${newMode}`, { code: 0 }); } catch (e) {}
|
|
560
561
|
setTimeout(() => process.exit(0), 500);
|
|
561
562
|
return;
|
|
562
563
|
}
|
package/core/adapter-registry.js
CHANGED
|
@@ -13,6 +13,7 @@ const { setAdapters } = require("./scheduler");
|
|
|
13
13
|
const { TelegramAdapter } = require("../channels/telegram/adapter");
|
|
14
14
|
const { KazeeAdapter } = require("../channels/kazee/adapter");
|
|
15
15
|
const { VoiceAdapter } = require("../channels/voice/adapter");
|
|
16
|
+
const { WebAdapter } = require("../channels/web/adapter");
|
|
16
17
|
|
|
17
18
|
const adapters = [];
|
|
18
19
|
let messageHandler = null;
|
|
@@ -22,6 +23,7 @@ function createAdapter(spec) {
|
|
|
22
23
|
if (spec.type === "telegram") return new TelegramAdapter({ id: spec.id, ...spec.opts });
|
|
23
24
|
if (spec.type === "kazee") return new KazeeAdapter({ id: spec.id, ...spec.opts });
|
|
24
25
|
if (spec.type === "voice") return new VoiceAdapter({ id: spec.id, ...spec.opts });
|
|
26
|
+
if (spec.type === "web") return new WebAdapter({ id: spec.id, ...spec.opts });
|
|
25
27
|
console.error(`Unknown adapter type: ${spec.type}`);
|
|
26
28
|
return null;
|
|
27
29
|
}
|
package/core/handlers.js
CHANGED
|
@@ -565,6 +565,7 @@ register({
|
|
|
565
565
|
handler: async (env) => {
|
|
566
566
|
if (!ownerEnv(env)) return;
|
|
567
567
|
await send("Going offline for a quick restart — back in a moment.");
|
|
568
|
+
try { require("./restart-reason").recordExitReason("User /restart command", { code: 0 }); } catch (e) {}
|
|
568
569
|
setTimeout(() => process.exit(0), 1000);
|
|
569
570
|
},
|
|
570
571
|
});
|
|
@@ -734,6 +735,7 @@ register({
|
|
|
734
735
|
const tailNote = "Source refreshed in /app. If this release also changed the Dockerfile (apt packages, env, base image), the host still needs a docker pull + recreate for those.";
|
|
735
736
|
const msg = `Installed v${newVersion}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\n${tailNote}\n\nRestarting...`;
|
|
736
737
|
await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
738
|
+
try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
|
|
737
739
|
setTimeout(() => process.exit(0), 2000);
|
|
738
740
|
return;
|
|
739
741
|
}
|
|
@@ -781,6 +783,7 @@ register({
|
|
|
781
783
|
const doctorReport = formatDoctorReport(runDoctorChecks());
|
|
782
784
|
const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
|
|
783
785
|
await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
786
|
+
try { require("./restart-reason").recordExitReason(`User /upgrade → v${newPkg.version}`, { code: 0 }); } catch (e) {}
|
|
784
787
|
} catch (e) {
|
|
785
788
|
const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
|
|
786
789
|
await send(`Upgrade failed:\n${errOutput}`);
|
package/core/identity.js
CHANGED
|
@@ -47,6 +47,9 @@ function isConfiguredOwnerChannel(transport, channelId) {
|
|
|
47
47
|
if (t === "telegram") return (cfg.CHAT_IDS || []).map(String).includes(c);
|
|
48
48
|
if (t === "kazee") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
|
|
49
49
|
if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
|
|
50
|
+
// Web dashboard chat: fixed single-owner channel gated by the dashboard
|
|
51
|
+
// session, so its identity is operator-implied rather than env-declared.
|
|
52
|
+
if (t === "web") return c === "web-owner";
|
|
50
53
|
return false;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Records WHY the process is about to exit so the next boot can report the
|
|
2
|
+
// cause in its "Back online" greeting. Only code-initiated exits get a chance
|
|
3
|
+
// to write the file — an external hard-kill (SIGKILL, OOM, host reboot, k8s
|
|
4
|
+
// force-restart) leaves no record, which is itself the signal that the restart
|
|
5
|
+
// was NOT code-initiated.
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { CONFIG_DIR } = require("./config");
|
|
10
|
+
|
|
11
|
+
const FILE = path.join(CONFIG_DIR, "last-exit.json");
|
|
12
|
+
// A reason older than this is treated as stale (the file was left behind by a
|
|
13
|
+
// much earlier exit and the real cause of *this* boot is unknown/external).
|
|
14
|
+
const FRESH_MS = 10 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
// Call synchronously right before a code-initiated process.exit(). Best-effort:
|
|
17
|
+
// never throws, never blocks the exit.
|
|
18
|
+
function recordExitReason(reason, extra = {}) {
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(FILE, JSON.stringify({
|
|
21
|
+
reason: String(reason || "unspecified").slice(0, 300),
|
|
22
|
+
code: extra.code,
|
|
23
|
+
at: Date.now(),
|
|
24
|
+
}));
|
|
25
|
+
} catch (e) { /* the recorder must never itself throw */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Read + clear the recorded reason on boot. Returns null when there is no fresh
|
|
29
|
+
// record ⇒ the restart was external/uncontrolled (nothing got to write a file).
|
|
30
|
+
function consumeExitReason() {
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(FILE, "utf8");
|
|
33
|
+
try { fs.unlinkSync(FILE); } catch (e) {}
|
|
34
|
+
const rec = JSON.parse(raw);
|
|
35
|
+
if (!rec || typeof rec.at !== "number" || Date.now() - rec.at > FRESH_MS) return null;
|
|
36
|
+
return rec;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return null; // absent/unreadable ⇒ external restart
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { recordExitReason, consumeExitReason };
|
package/docker-entrypoint.sh
CHANGED
|
@@ -60,5 +60,28 @@ SOUL
|
|
|
60
60
|
echo "Configuration written to $ENV_FILE"
|
|
61
61
|
fi
|
|
62
62
|
|
|
63
|
+
# Identity sync: pod-provisioned env is authoritative for identity keys.
|
|
64
|
+
# The config loader prefers .env file values over process env, so a retained
|
|
65
|
+
# disk attached to a NEW bot would otherwise keep the old bot's identity
|
|
66
|
+
# (tokens, owner). Re-seed identity keys from env on every boot — only keys
|
|
67
|
+
# actually set in the environment; everything else in .env is untouched.
|
|
68
|
+
if [ -f "$ENV_FILE" ]; then
|
|
69
|
+
sync_env_key() {
|
|
70
|
+
key="$1"; value="$2"
|
|
71
|
+
[ -n "$value" ] || return 0
|
|
72
|
+
tmp="$ENV_FILE.identity-sync"
|
|
73
|
+
grep -v "^${key}=" "$ENV_FILE" > "$tmp" || true
|
|
74
|
+
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
|
75
|
+
mv "$tmp" "$ENV_FILE"
|
|
76
|
+
}
|
|
77
|
+
sync_env_key CHANNELS "$CHANNELS"
|
|
78
|
+
sync_env_key TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN"
|
|
79
|
+
sync_env_key TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
|
|
80
|
+
sync_env_key KAZEE_URL "$KAZEE_URL"
|
|
81
|
+
sync_env_key KAZEE_BOT_TOKEN "$KAZEE_BOT_TOKEN"
|
|
82
|
+
sync_env_key KAZEE_BOT_USER_ID "$KAZEE_BOT_USER_ID"
|
|
83
|
+
sync_env_key KAZEE_OWNER_USER_ID "$KAZEE_OWNER_USER_ID"
|
|
84
|
+
fi
|
|
85
|
+
|
|
63
86
|
# Execute the main command
|
|
64
87
|
exec "$@"
|
package/package.json
CHANGED
|
@@ -32,12 +32,12 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
assert.strictEqual(pkg.version, "3.0.
|
|
35
|
+
// 3.0.14 approved by Sumeet 2026-07-13 ("Proceed" on the Telegram poll-wedge
|
|
36
|
+
// fix): dns ipv4first + 40s socket-idle timeout on the Telegram long-poll so
|
|
37
|
+
// the half-open socket heals on a fresh poll instead of wedging into a 10-min
|
|
38
|
+
// self-exit restart flap, plus code-side reboot-reason reporting appended to
|
|
39
|
+
// the "Back online" greeting via core/restart-reason.js.
|
|
40
|
+
assert.strictEqual(pkg.version, "3.0.14", "release version must remain unchanged without explicit approval");
|
|
41
41
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
42
42
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
43
43
|
}
|
package/web.js
CHANGED
|
@@ -664,6 +664,93 @@ async function handleAPI(req, res, body, dependencies = {}) {
|
|
|
664
664
|
}));
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
+
// ── Chat (dashboard ↔ bot, via the in-process web channel adapter) ──
|
|
668
|
+
if (url.startsWith("/api/chat/")) {
|
|
669
|
+
let webChat = null;
|
|
670
|
+
try { webChat = require("./core/adapter-registry").findAdapter("web"); } catch (e) {}
|
|
671
|
+
if (!webChat) {
|
|
672
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
673
|
+
return res.end(JSON.stringify({ error: "Chat unavailable — bot not running in this process" }));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Live event stream. Replays history on connect, then pushes frames as
|
|
677
|
+
// the bot replies (message / edit / delete / typing / file / voice).
|
|
678
|
+
if (url.startsWith("/api/chat/stream") && req.method === "GET") {
|
|
679
|
+
res.writeHead(200, {
|
|
680
|
+
"Content-Type": "text/event-stream",
|
|
681
|
+
"Cache-Control": "no-cache",
|
|
682
|
+
"Connection": "keep-alive",
|
|
683
|
+
"X-Accel-Buffering": "no",
|
|
684
|
+
});
|
|
685
|
+
res.write(`data: ${JSON.stringify({ kind: "history", frames: webChat.history() })}\n\n`);
|
|
686
|
+
const unsubscribe = webChat.subscribe((frame) => {
|
|
687
|
+
try { res.write(`data: ${JSON.stringify(frame)}\n\n`); } catch (e) {}
|
|
688
|
+
});
|
|
689
|
+
const heartbeat = setInterval(() => { try { res.write(": ping\n\n"); } catch (e) {} }, 25000);
|
|
690
|
+
// res "close" = connection torn down. (req "close" fires as soon as the
|
|
691
|
+
// GET body completes in modern Node, which would unsubscribe instantly.)
|
|
692
|
+
res.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (url === "/api/chat/send" && req.method === "POST") {
|
|
697
|
+
let text = "";
|
|
698
|
+
try { ({ text } = JSON.parse(body || "{}")); } catch (e) {}
|
|
699
|
+
if (!text || !String(text).trim()) {
|
|
700
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
701
|
+
return res.end(JSON.stringify({ error: "Empty message" }));
|
|
702
|
+
}
|
|
703
|
+
const messageId = webChat.postText(String(text));
|
|
704
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
705
|
+
return res.end(JSON.stringify({ ok: true, messageId }));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (url === "/api/chat/action" && req.method === "POST") {
|
|
709
|
+
let parsed = {};
|
|
710
|
+
try { parsed = JSON.parse(body || "{}"); } catch (e) {}
|
|
711
|
+
const callbackId = webChat.postAction(parsed);
|
|
712
|
+
if (!callbackId) {
|
|
713
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
714
|
+
return res.end(JSON.stringify({ error: "Missing action payload" }));
|
|
715
|
+
}
|
|
716
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
717
|
+
return res.end(JSON.stringify({ ok: true, callbackId }));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (url.startsWith("/api/chat/media/") && req.method === "GET") {
|
|
721
|
+
const id = url.slice("/api/chat/media/".length).split("?")[0];
|
|
722
|
+
const entry = webChat.getMedia(id);
|
|
723
|
+
if (!entry) {
|
|
724
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
725
|
+
return res.end(JSON.stringify({ error: "Expired or unknown media" }));
|
|
726
|
+
}
|
|
727
|
+
const stat = fs.statSync(entry.path);
|
|
728
|
+
res.writeHead(200, {
|
|
729
|
+
"Content-Type": entry.mime || "application/octet-stream",
|
|
730
|
+
"Content-Length": stat.size,
|
|
731
|
+
"Content-Disposition": `inline; filename="${(entry.fileName || "file").replace(/"/g, "")}"`,
|
|
732
|
+
});
|
|
733
|
+
return fs.createReadStream(entry.path).pipe(res);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
737
|
+
return res.end(JSON.stringify({ error: "Unknown chat route" }));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Platform SSO: mint a one-time magic-link token. Control-token only —
|
|
741
|
+
// this is a machine-to-machine surface for AgentSpace, never exposed to
|
|
742
|
+
// dashboard sessions (they are already logged in and gain nothing).
|
|
743
|
+
if (url === "/api/otp/mint" && req.method === "POST") {
|
|
744
|
+
if (!checkBearerAuth(req)) {
|
|
745
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
746
|
+
return res.end(JSON.stringify({ error: "Control token required" }));
|
|
747
|
+
}
|
|
748
|
+
const { mint } = require("./core/web-otp");
|
|
749
|
+
const { token, expiresAt } = mint();
|
|
750
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
751
|
+
return res.end(JSON.stringify({ ok: true, token, path: `/otp/${token}`, expiresAt }));
|
|
752
|
+
}
|
|
753
|
+
|
|
667
754
|
// Change password (voluntary — never forced)
|
|
668
755
|
if (url === "/api/password" && req.method === "POST") {
|
|
669
756
|
const { current, newPassword } = JSON.parse(body);
|
|
@@ -764,6 +851,19 @@ function getHTML() {
|
|
|
764
851
|
table.usage-table th, table.usage-table td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #222; white-space: nowrap; }
|
|
765
852
|
table.usage-table th { color: #888; font-weight: 500; }
|
|
766
853
|
table.usage-table td:first-child { color: #e0e0e0; }
|
|
854
|
+
.chat-box { display: flex; flex-direction: column; height: calc(100vh - 210px); min-height: 320px; }
|
|
855
|
+
.chat-log { flex: 1; overflow-y: auto; padding: 4px 2px; display: flex; flex-direction: column; gap: 10px; }
|
|
856
|
+
.chat-bubble { max-width: 85%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
|
|
857
|
+
.chat-bubble.user { align-self: flex-end; background: #6366f1; color: #fff; border-bottom-right-radius: 4px; }
|
|
858
|
+
.chat-bubble.bot { align-self: flex-start; background: #1a1a1a; border: 1px solid #2a2a2a; border-bottom-left-radius: 4px; }
|
|
859
|
+
.chat-bubble .chat-btns { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
860
|
+
.chat-bubble .chat-btns button { padding: 6px 12px; font-size: 13px; background: #333; }
|
|
861
|
+
.chat-bubble .chat-btns button:hover { background: #444; }
|
|
862
|
+
.chat-bubble a { color: #a5b4fc; }
|
|
863
|
+
.chat-typing { align-self: flex-start; color: #888; font-size: 13px; padding: 4px 8px; }
|
|
864
|
+
.chat-input-row { display: flex; gap: 8px; margin-top: 12px; }
|
|
865
|
+
.chat-input-row textarea { min-height: 44px; max-height: 140px; resize: none; flex: 1; }
|
|
866
|
+
.chat-input-row button { align-self: flex-end; }
|
|
767
867
|
</style>
|
|
768
868
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|
769
869
|
</head>
|
|
@@ -814,6 +914,7 @@ async function doLogin() {
|
|
|
814
914
|
function render() {
|
|
815
915
|
const tabs = [
|
|
816
916
|
{ id: "dashboard", label: "Dashboard" },
|
|
917
|
+
{ id: "chat", label: "Chat" },
|
|
817
918
|
{ id: "usage", label: "Usage" },
|
|
818
919
|
{ id: "auth", label: "Users" },
|
|
819
920
|
{ id: "credentials", label: "Credentials" },
|
|
@@ -839,10 +940,116 @@ function render() {
|
|
|
839
940
|
loadTab();
|
|
840
941
|
}
|
|
841
942
|
|
|
842
|
-
function switchTab(tab) {
|
|
943
|
+
function switchTab(tab) {
|
|
944
|
+
if (tab !== "chat" && window.chatES) { try { window.chatES.close(); } catch (e) {} window.chatES = null; }
|
|
945
|
+
currentTab = tab;
|
|
946
|
+
render();
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ── Chat tab ──
|
|
950
|
+
function chatEsc(s) {
|
|
951
|
+
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function chatFrameHTML(f) {
|
|
955
|
+
if (f.kind === "message") {
|
|
956
|
+
const cls = f.role === "user" ? "user" : "bot";
|
|
957
|
+
let inner = chatEsc(f.text);
|
|
958
|
+
if (f.buttons && f.buttons.length) {
|
|
959
|
+
inner += '<div class="chat-btns">' + f.buttons.map(function (b) {
|
|
960
|
+
return '<button onclick="chatAction(\\'' + chatEsc(b.id) + '\\',\\'' + chatEsc(b.payload || b.id) + '\\',\\'' + chatEsc(f.messageId) + '\\')">' + chatEsc(b.label) + '</button>';
|
|
961
|
+
}).join("") + '</div>';
|
|
962
|
+
}
|
|
963
|
+
return '<div class="chat-bubble ' + cls + '" data-mid="' + chatEsc(f.messageId) + '">' + inner + '</div>';
|
|
964
|
+
}
|
|
965
|
+
if (f.kind === "file") {
|
|
966
|
+
return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '">📎 <a href="' + chatEsc(f.url) + '" target="_blank" rel="noopener">' + chatEsc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + chatEsc(f.caption) : '') + '</div>';
|
|
967
|
+
}
|
|
968
|
+
if (f.kind === "voice") {
|
|
969
|
+
return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '"><audio controls src="' + chatEsc(f.url) + '"></audio></div>';
|
|
970
|
+
}
|
|
971
|
+
return "";
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function chatApplyFrame(f) {
|
|
975
|
+
const log = $("#chat-log");
|
|
976
|
+
if (!log) return;
|
|
977
|
+
const typingEl = $("#chat-typing");
|
|
978
|
+
if (f.kind === "typing") {
|
|
979
|
+
if (typingEl) typingEl.classList.remove("hidden");
|
|
980
|
+
clearTimeout(window.chatTypingTimer);
|
|
981
|
+
window.chatTypingTimer = setTimeout(function () { if ($("#chat-typing")) $("#chat-typing").classList.add("hidden"); }, 8000);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (typingEl && (f.kind === "message" || f.kind === "edit")) typingEl.classList.add("hidden");
|
|
985
|
+
if (f.kind === "edit" || f.kind === "delete") {
|
|
986
|
+
const target = log.querySelector('[data-mid="' + (window.CSS && CSS.escape ? CSS.escape(f.messageId) : f.messageId) + '"]');
|
|
987
|
+
if (f.kind === "delete") { if (target) target.remove(); return; }
|
|
988
|
+
if (target) {
|
|
989
|
+
target.outerHTML = chatFrameHTML({ kind: "message", role: "assistant", messageId: f.messageId, text: f.text, buttons: f.buttons });
|
|
990
|
+
}
|
|
991
|
+
log.scrollTop = log.scrollHeight;
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const html = chatFrameHTML(f);
|
|
995
|
+
if (!html) return;
|
|
996
|
+
typingEl.insertAdjacentHTML("beforebegin", html);
|
|
997
|
+
log.scrollTop = log.scrollHeight;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function chatConnect() {
|
|
1001
|
+
if (window.chatES) { try { window.chatES.close(); } catch (e) {} }
|
|
1002
|
+
const es = new EventSource("/api/chat/stream");
|
|
1003
|
+
window.chatES = es;
|
|
1004
|
+
es.onmessage = function (ev) {
|
|
1005
|
+
let f;
|
|
1006
|
+
try { f = JSON.parse(ev.data); } catch (e) { return; }
|
|
1007
|
+
if (f.kind === "history") {
|
|
1008
|
+
const log = $("#chat-log");
|
|
1009
|
+
if (!log) return;
|
|
1010
|
+
log.innerHTML = f.frames.map(chatFrameHTML).join("") + '<div id="chat-typing" class="chat-typing hidden">thinking…</div>';
|
|
1011
|
+
log.scrollTop = log.scrollHeight;
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
chatApplyFrame(f);
|
|
1015
|
+
};
|
|
1016
|
+
es.onerror = function () {
|
|
1017
|
+
// EventSource auto-reconnects; a 401 (expired session) won't, so bounce
|
|
1018
|
+
// through the login screen on persistent failure.
|
|
1019
|
+
setTimeout(function () { if (window.chatES === es && es.readyState === EventSource.CLOSED) api("/api/status"); }, 3000);
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function chatSend() {
|
|
1024
|
+
const input = $("#chat-input");
|
|
1025
|
+
const text = input.value.trim();
|
|
1026
|
+
if (!text) return;
|
|
1027
|
+
input.value = "";
|
|
1028
|
+
input.style.height = "";
|
|
1029
|
+
await api("/api/chat/send", { method: "POST", body: { text: text } });
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async function chatAction(id, payload, messageId) {
|
|
1033
|
+
await api("/api/chat/action", { method: "POST", body: { id: id, payload: payload, messageId: messageId } });
|
|
1034
|
+
}
|
|
843
1035
|
|
|
844
1036
|
async function loadTab() {
|
|
845
1037
|
const el = $("#content");
|
|
1038
|
+
if (currentTab === "chat") {
|
|
1039
|
+
el.innerHTML =
|
|
1040
|
+
'<div class="chat-box">' +
|
|
1041
|
+
'<div id="chat-log" class="chat-log"><div id="chat-typing" class="chat-typing hidden">thinking…</div></div>' +
|
|
1042
|
+
'<div class="chat-input-row">' +
|
|
1043
|
+
'<textarea id="chat-input" placeholder="Message ${botName}…" rows="1"' +
|
|
1044
|
+
' onkeydown="if(event.key===\\'Enter\\'&&!event.shiftKey){event.preventDefault();chatSend();}"' +
|
|
1045
|
+
' oninput="this.style.height=\\'\\';this.style.height=Math.min(this.scrollHeight,140)+\\'px\\';"></textarea>' +
|
|
1046
|
+
'<button onclick="chatSend()">Send</button>' +
|
|
1047
|
+
'</div>' +
|
|
1048
|
+
'</div>';
|
|
1049
|
+
chatConnect();
|
|
1050
|
+
setTimeout(function () { $("#chat-input") && $("#chat-input").focus(); }, 100);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
846
1053
|
if (currentTab === "dashboard") {
|
|
847
1054
|
el.innerHTML = \`
|
|
848
1055
|
<div class="card">
|