@nordbyte/nordrelay 0.5.1 → 0.6.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.
Files changed (57) hide show
  1. package/.env.example +65 -11
  2. package/README.md +97 -23
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/agent-updates.js +18 -2
  6. package/dist/audit-log.js +40 -2
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +492 -7
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +34 -7
  11. package/dist/channel-command-service.js +156 -0
  12. package/dist/channel-turn-service.js +237 -0
  13. package/dist/codex-cli.js +1 -1
  14. package/dist/config-metadata.js +80 -13
  15. package/dist/config.js +77 -7
  16. package/dist/context-key.js +77 -5
  17. package/dist/discord-artifacts.js +165 -0
  18. package/dist/discord-bot.js +2014 -0
  19. package/dist/discord-channel-runtime.js +133 -0
  20. package/dist/discord-command-surface.js +119 -0
  21. package/dist/discord-rate-limit.js +141 -0
  22. package/dist/index.js +16 -5
  23. package/dist/job-store.js +127 -0
  24. package/dist/metrics.js +41 -0
  25. package/dist/operations.js +176 -119
  26. package/dist/relay-external-activity-monitor.js +47 -6
  27. package/dist/relay-runtime.js +1003 -268
  28. package/dist/runtime-cache.js +57 -0
  29. package/dist/session-locks.js +10 -7
  30. package/dist/state-backend.js +3 -0
  31. package/dist/support-bundle.js +18 -1
  32. package/dist/telegram-access-commands.js +15 -2
  33. package/dist/telegram-access-middleware.js +16 -3
  34. package/dist/telegram-agent-commands.js +25 -0
  35. package/dist/telegram-artifact-commands.js +46 -0
  36. package/dist/telegram-diagnostics-command.js +5 -50
  37. package/dist/telegram-general-commands.js +2 -6
  38. package/dist/telegram-operational-commands.js +14 -6
  39. package/dist/telegram-queue-commands.js +74 -4
  40. package/dist/telegram-support-command.js +7 -0
  41. package/dist/telegram-update-commands.js +27 -0
  42. package/dist/user-management.js +208 -0
  43. package/dist/web-api-contract.js +9 -0
  44. package/dist/web-dashboard-access-routes.js +74 -1
  45. package/dist/web-dashboard-artifact-routes.js +3 -3
  46. package/dist/web-dashboard-assets.js +2 -0
  47. package/dist/web-dashboard-pages.js +97 -13
  48. package/dist/web-dashboard-runtime-routes.js +53 -8
  49. package/dist/web-dashboard-session-routes.js +27 -20
  50. package/dist/web-dashboard-ui.js +1 -0
  51. package/dist/web-dashboard.js +149 -6
  52. package/dist/web-state.js +33 -2
  53. package/dist/webui-assets/dashboard.css +75 -1
  54. package/dist/webui-assets/dashboard.js +358 -47
  55. package/package.json +3 -1
  56. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  57. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -22
@@ -7,6 +7,11 @@
7
7
  { path: "/api/snapshot", methods: ["GET"] },
8
8
  { path: "/api/tasks", methods: ["GET"] },
9
9
  { path: "/api/progress", methods: ["GET"] },
10
+ { path: "/api/metrics", methods: ["GET"] },
11
+ { path: "/api/jobs", methods: ["GET"] },
12
+ { re: /^\/api\/jobs\/[^\/]+\/log$/, methods: ["GET"] },
13
+ { re: /^\/api\/jobs\/[^\/]+\/action$/, methods: ["POST"] },
14
+ { path: "/api/active-sessions", methods: ["GET"] },
10
15
  { path: "/api/version", methods: ["GET"] },
11
16
  { path: "/api/update", methods: ["POST"] },
12
17
  { path: "/api/agent-updates", methods: ["GET"] },
@@ -23,10 +28,14 @@
23
28
  { re: /^\/api\/users\/[^\/]+\/sessions\/[^\/]+$/, methods: ["DELETE"] },
24
29
  { re: /^\/api\/users\/[^\/]+\/telegram$/, methods: ["POST"] },
25
30
  { re: /^\/api\/users\/[^\/]+\/telegram\/[^\/]+$/, methods: ["DELETE"] },
31
+ { re: /^\/api\/users\/[^\/]+\/discord$/, methods: ["POST"] },
32
+ { re: /^\/api\/users\/[^\/]+\/discord\/[^\/]+$/, methods: ["DELETE"] },
26
33
  { path: "/api/groups", methods: ["GET", "POST"] },
27
34
  { re: /^\/api\/groups\/[^\/]+$/, methods: ["PATCH"] },
28
35
  { path: "/api/telegram-chats", methods: ["GET", "POST"] },
29
36
  { re: /^\/api\/telegram-chats\/[^\/]+$/, methods: ["PATCH"] },
37
+ { path: "/api/discord-channels", methods: ["GET", "POST"] },
38
+ { re: /^\/api\/discord-channels\/[^\/]+$/, methods: ["PATCH"] },
30
39
  { path: "/api/audit", methods: ["GET"] },
31
40
  { path: "/api/locks", methods: ["GET", "POST", "DELETE"] },
32
41
  { path: "/api/auth/status", methods: ["GET"] },
@@ -139,7 +148,7 @@
139
148
  throw new Error("Unsupported WebUI API method: " + method + " " + path);
140
149
  }
141
150
  }
142
- const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0 };
151
+ const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessionsTimer: null };
143
152
  function toast(msg, options = {}) {
144
153
  const el = document.getElementById("toast");
145
154
  el.textContent = msg;
@@ -239,14 +248,14 @@
239
248
  ["#restartBtn", "system.restart"],
240
249
  ["#updateBtn", "updates.run"],
241
250
  ["#clearLogsBtn", "logs.clear"],
242
- ["#createUserBtn,#createGroupBtn,#createChatBtn", "users.write"],
251
+ ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
243
252
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
244
253
  ["[data-switch]", "sessions.write"],
245
254
  ["[data-queue],[data-q]", "queue.write"],
246
255
  ["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
247
256
  ["[data-auth-login],[data-auth-logout]", "auth.manage"],
248
257
  ["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
249
- ["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle]", "users.write"]
258
+ ["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-discord-code],[data-user-discord-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-discord-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle],[data-discord-channel-edit],[data-discord-channel-toggle]", "users.write"]
250
259
  ];
251
260
  disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
252
261
  el.disabled = !can(permission);
@@ -288,6 +297,7 @@
288
297
  }
289
298
  async function reloadCurrentPage(options = {}) {
290
299
  const name = state.currentPage;
300
+ if (name === "overview") await loadActiveSessions();
291
301
  if (name === "chat") {
292
302
  await loadChatHistory();
293
303
  scrollChatToBottom();
@@ -299,6 +309,7 @@
299
309
  if (name === "artifacts") await loadArtifacts();
300
310
  if (name === "activity") await loadActivity();
301
311
  if (name === "tasks") await loadTasks();
312
+ if (name === "metrics") await loadMetrics();
302
313
  if (name === "adapters") await loadAdapterHealth();
303
314
  if (name === "access") await loadAccess();
304
315
  if (name === "version") await loadVersion();
@@ -351,6 +362,7 @@
351
362
  state.enabledAgents = data.enabledAgents || [];
352
363
  applyPermissions();
353
364
  renderSnapshot(state.snapshot);
365
+ safe(loadActiveSessions);
354
366
  renderSessionControls();
355
367
  populateNewSessionForm(data.enabledAgents);
356
368
  renderAdapters(data.channels, data.agentAdapters);
@@ -375,7 +387,6 @@
375
387
  }
376
388
  function renderSnapshot(s) {
377
389
  document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
378
- document.getElementById("sessionText").textContent = s.sessionText || "";
379
390
  document.getElementById("metrics").innerHTML = [
380
391
  ["Status", s.processing ? "working" : "idle"],
381
392
  ["Agent", s.session.agentLabel],
@@ -387,6 +398,67 @@
387
398
  ].map(([k, v]) => '<div class="metric"><div class="label">' + esc(k) + '</div><div class="value">' + esc(v) + "</div></div>").join("");
388
399
  renderQueue(s.queue, s.queuePaused);
389
400
  }
401
+ async function loadActiveSessions() {
402
+ const box = document.getElementById("activeSessions");
403
+ if (!box) return;
404
+ if (!can("sessions.read")) {
405
+ box.innerHTML = '<div class="item">Permission required: sessions.read</div>';
406
+ return;
407
+ }
408
+ const data = await api("/api/active-sessions");
409
+ renderActiveSessions(data.sessions || []);
410
+ }
411
+ function renderActiveSessions(items) {
412
+ const box = document.getElementById("activeSessions");
413
+ if (!box) return;
414
+ box.innerHTML = (items || []).map(activeSessionCard).join("") || '<div class="item">No active sessions.</div>';
415
+ document.querySelectorAll("[data-active-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.activeCopy || "", "Thread ID copied"));
416
+ document.querySelectorAll("[data-active-switch]").forEach((b) => b.onclick = () => safe(async () => {
417
+ if (!can("sessions.write")) {
418
+ toast("Permission required: sessions.write");
419
+ return;
420
+ }
421
+ const agentId = b.dataset.activeAgent;
422
+ const threadId = b.dataset.activeSwitch;
423
+ if (agentId && state.snapshot?.session?.agentId !== agentId) {
424
+ await api("/api/agent", { method: "POST", body: { agentId } });
425
+ }
426
+ if (threadId) {
427
+ await api("/api/sessions/switch", { method: "POST", body: { threadId } });
428
+ }
429
+ toast("Session switched");
430
+ await loadBootstrap();
431
+ page("chat");
432
+ }));
433
+ document.querySelectorAll("[data-active-detail]").forEach((b) => b.onclick = () => safe(async () => {
434
+ const agentId = b.dataset.activeAgent;
435
+ const threadId = b.dataset.activeDetail;
436
+ if (agentId && state.snapshot?.session?.agentId !== agentId) {
437
+ await api("/api/agent", { method: "POST", body: { agentId } });
438
+ await loadBootstrap();
439
+ }
440
+ if (threadId) await loadSessionDetail(threadId);
441
+ }));
442
+ applyPermissions();
443
+ }
444
+ function activeSessionCard(s) {
445
+ const thread = s.threadId || "not started";
446
+ const prompt = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
447
+ const tool2 = s.currentTool || s.lastTool || "-";
448
+ const queue = s.queueLength ? " \xB7 " + s.queueLength + " queued" + (s.queuePaused ? " paused" : "") : "";
449
+ const sourceLabel = activeSourceLabel(s.source);
450
+ const mirrors = (s.mirrorChannels || []).map((m) => activeSourceLabel(m.source) + " " + m.mode + (m.queueLength ? " \xB7 " + m.queueLength + " queued" + (m.queuePaused ? " paused" : "") : "")).join(", ");
451
+ const meta = ["Source " + sourceLabel, s.workspace, fmtDuration(s.durationMs), tool2 && tool2 !== "-" ? "tool " + tool2 : ""].filter(Boolean).join(" | ");
452
+ const mirrorLine = mirrors ? "<small>Mirroring: " + esc(mirrors) + "</small>" : s.source === "cli" ? "<small>Mirroring: none</small>" : "";
453
+ return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
454
+ }
455
+ function activeSourceLabel(source) {
456
+ if (source === "cli") return "CLI";
457
+ if (source === "telegram") return "Telegram";
458
+ if (source === "discord") return "Discord";
459
+ if (source === "web") return "WebUI";
460
+ return source || "-";
461
+ }
390
462
  function renderSessionControls() {
391
463
  const c = state.controls || {};
392
464
  const s = state.snapshot?.session || {};
@@ -428,7 +500,10 @@
428
500
  });
429
501
  }
430
502
  function renderAdapters(channels, agents) {
431
- const channelCards = (channels || []).map((c) => adapterCard(c.label, c.status, "", c.capabilities.join(", ")));
503
+ const channelCards = (channels || []).map((c) => {
504
+ const status = c.status === "available" ? c.enabled === false ? "disabled" : "enabled" : c.status || "planned";
505
+ return adapterCard(c.label, status, "", c.capabilities.join(", ") + (c.notes ? " - " + c.notes : ""));
506
+ });
432
507
  const agentCards = (agents || []).map((a) => {
433
508
  const available = a.status === "available";
434
509
  const status = available ? state.enabledAgents.includes(a.id) ? "enabled" : "disabled" : a.status || "planned";
@@ -477,6 +552,9 @@
477
552
  if (status === "running") return "planned";
478
553
  return "disabled";
479
554
  }
555
+ state.activeSessionsTimer = setInterval(() => {
556
+ if (state.currentPage === "overview") safe(loadActiveSessions);
557
+ }, 5e3);
480
558
  function scrollChatToBottom() {
481
559
  const box = document.getElementById("messages");
482
560
  if (!box) return;
@@ -1037,30 +1115,6 @@
1037
1115
  const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
1038
1116
  renderQueue(r.queue, r.paused);
1039
1117
  }));
1040
- async function loadTasks() {
1041
- setLoading("tasksList", "Loading tasks...");
1042
- const d = await api("/api/tasks");
1043
- renderTasks(d);
1044
- }
1045
- function taskCard(t, title) {
1046
- if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
1047
- const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
1048
- return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1049
- }
1050
- function renderTasks(d) {
1051
- document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1052
- document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
1053
- if (!can("queue.write")) {
1054
- toast("Permission required: queue.write");
1055
- return;
1056
- }
1057
- const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
1058
- renderQueue(r.queue, r.paused);
1059
- loadTasks();
1060
- }));
1061
- applyPermissions();
1062
- }
1063
- document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
1064
1118
  async function loadArtifacts() {
1065
1119
  setLoading("artifactList", "Loading artifacts...");
1066
1120
  document.getElementById("artifactPreview").innerHTML = "";
@@ -1158,9 +1212,114 @@
1158
1212
  throw err;
1159
1213
  }
1160
1214
  }
1215
+ async function loadTasks() {
1216
+ setLoading("tasksList", "Loading tasks...");
1217
+ const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
1218
+ renderTasks(d, jobs);
1219
+ }
1220
+ function taskCard(t, title) {
1221
+ if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
1222
+ const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
1223
+ return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1224
+ }
1225
+ function renderTasks(d, jobs) {
1226
+ document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Unified jobs</h2><div class="list">' + renderUnifiedJobs(jobs?.jobs || []) + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1227
+ document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
1228
+ if (!can("queue.write")) {
1229
+ toast("Permission required: queue.write");
1230
+ return;
1231
+ }
1232
+ const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
1233
+ renderQueue(r.queue, r.paused);
1234
+ loadTasks();
1235
+ }));
1236
+ bindUnifiedJobButtons();
1237
+ applyPermissions();
1238
+ }
1239
+ function renderUnifiedJobs(jobs) {
1240
+ return jobs.map((job) => {
1241
+ const retryPermission = jobActionPermission(job, "retry");
1242
+ const cancelPermission = jobActionPermission(job, "cancel");
1243
+ return '<div class="item"><strong>' + esc(job.title) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + "</span></strong><small>" + esc([job.kind, job.source, job.agentLabel || job.agentId, fmtDate(job.startedAt)].filter(Boolean).join(" / ")) + "</small>" + (job.owner ? "<small>" + esc("Owner: " + (job.owner.label || job.owner.username || job.owner.id || "-")) + "</small>" : "") + (job.threadId ? "<small>" + esc("Thread: " + job.threadId) + "</small>" : "") + (job.summary ? "<small>" + esc(short(job.summary, 300)) + "</small>" : "") + (job.logTail ? '<pre class="update-log">' + esc(short(job.logTail, 1200)) + "</pre>" : "") + '<div class="row">' + (job.canReadLog ? '<button class="secondary" data-job-log="' + attr(job.id) + '">Log</button>' : "") + (job.canRetry ? '<button class="secondary" data-job-action="retry" data-job-permission="' + attr(retryPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(retryPermission) + ">Retry</button>" : "") + (job.canCancel ? '<button class="danger" data-job-action="cancel" data-job-permission="' + attr(cancelPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(cancelPermission) + ">Cancel</button>" : "") + "</div></div>";
1244
+ }).join("") || '<div class="item">No jobs.</div>';
1245
+ }
1246
+ function jobActionPermission(job, action) {
1247
+ if (job.id === "web:current" && action === "cancel") return "prompt.abort";
1248
+ if (String(job.id || "").startsWith("queue:")) return "queue.write";
1249
+ if (String(job.id || "").startsWith("support-bundle:")) return "diagnostics.read";
1250
+ return "updates.run";
1251
+ }
1252
+ function bindUnifiedJobButtons() {
1253
+ document.querySelectorAll("[data-job-log]").forEach((b) => b.onclick = () => safe(async () => {
1254
+ const r = await api("/api/jobs/" + encodeURIComponent(b.dataset.jobLog) + "/log");
1255
+ toast((r.plain || "No log").slice(0, 3500), { duration: 12e3 });
1256
+ }));
1257
+ document.querySelectorAll("[data-job-action]").forEach((b) => b.onclick = () => safe(async () => {
1258
+ const permission = b.dataset.jobPermission || "updates.run";
1259
+ if (!can(permission)) {
1260
+ toast("Permission required: " + permission);
1261
+ return;
1262
+ }
1263
+ const action = b.dataset.jobAction;
1264
+ if (confirm((action === "cancel" ? "Cancel" : "Retry") + " job " + b.dataset.jobId + "?")) {
1265
+ await api("/api/jobs/" + encodeURIComponent(b.dataset.jobId) + "/action", { method: "POST", body: JSON.stringify({ action }) });
1266
+ toast("Job " + action + " requested");
1267
+ loadTasks();
1268
+ }
1269
+ }));
1270
+ }
1271
+ document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
1272
+ async function loadMetrics() {
1273
+ setLoading("metricsPanel", "Loading metrics...");
1274
+ const d = await api("/api/metrics");
1275
+ renderMetrics(d);
1276
+ }
1277
+ function metricStatusRows(d) {
1278
+ return [
1279
+ ["Generated", fmtDate(d.generatedAt)],
1280
+ ["Queue", String(d.queue?.length ?? 0) + (d.queue?.paused ? " paused" : " running")],
1281
+ ["Active turns", String(d.turns?.active ?? 0)],
1282
+ ["Completed turns", String(d.turns?.completed ?? 0)],
1283
+ ["Failed turns", String(d.turns?.failed ?? 0)],
1284
+ ["Aborted turns", String(d.turns?.aborted ?? 0)],
1285
+ ["Average turn duration", d.turns?.averageDurationMs === null ? "-" : fmtDuration(d.turns?.averageDurationMs)]
1286
+ ];
1287
+ }
1288
+ function metricJobRows(d) {
1289
+ const jobs = d.jobs || {};
1290
+ return [
1291
+ ["Total", jobs.total],
1292
+ ["Queued", jobs.queued],
1293
+ ["Running", jobs.running],
1294
+ ["Completed", jobs.completed],
1295
+ ["Failed", jobs.failed],
1296
+ ["Aborted", jobs.aborted]
1297
+ ];
1298
+ }
1299
+ function rateRows(name, rate) {
1300
+ return [
1301
+ ["Queued", rate?.queued ?? 0],
1302
+ ["Running", rate?.running ?? 0],
1303
+ ["Completed", rate?.completed ?? 0],
1304
+ ["Failed", rate?.failed ?? 0],
1305
+ ["Retries", rate?.retries ?? 0],
1306
+ ["Rate-limit hits", rate?.rateLimitHits ?? 0],
1307
+ ["Last rate limit", fmtDate(rate?.lastRateLimitAt)],
1308
+ ["Retry after", rate?.lastRetryAfterSeconds ? rate.lastRetryAfterSeconds + "s" : "-"],
1309
+ ["Buckets", (rate?.buckets || []).length]
1310
+ ].map(([k, v]) => [name + " " + k, v]);
1311
+ }
1312
+ function renderMetrics(d) {
1313
+ const adapters = d.adapters || {};
1314
+ document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Jobs", metricJobRows(d)) + card("Telegram rate limits", rateRows("", adapters.telegram).map(([k, v]) => [String(k).trim(), v])) + card("Discord rate limits", rateRows("", adapters.discord).map(([k, v]) => [String(k).trim(), v])) + "</div>";
1315
+ }
1316
+ document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1317
+ function activityQuery() {
1318
+ return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", actor: val("activityActor") || void 0, agent: val("activityAgent") || "all", thread: val("activityThread") || void 0, workspace: val("activityWorkspace") || void 0, type: val("activityType") || void 0, since: val("activitySince") || void 0 };
1319
+ }
1161
1320
  async function loadActivity() {
1162
1321
  setLoading("activityList", "Loading activity...");
1163
- const data = await api("/api/activity", { query: { source: val("activitySource"), status: val("activityStatus"), limit: val("activityLimit") || "100" } });
1322
+ const data = await api("/api/activity", { query: activityQuery() });
1164
1323
  state.activityEvents = data.events || [];
1165
1324
  renderActivity(state.activityEvents);
1166
1325
  }
@@ -1168,28 +1327,32 @@
1168
1327
  const active = state.snapshot?.session;
1169
1328
  return e.workspace || (active?.threadId && e.threadId === active.threadId ? active.workspace : "");
1170
1329
  }
1330
+ function activityActorText(e) {
1331
+ const a = e.actor || {};
1332
+ return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", cli: "CLI", system: "System" }[a.channel] || "System");
1333
+ }
1171
1334
  function activityMetaHtml(e) {
1172
1335
  const workspace = activityWorkspace(e);
1173
1336
  const duration = typeof e.durationMs === "number" ? fmtDuration(e.durationMs) : "";
1337
+ const actor = activityActorText(e);
1174
1338
  const parts = [];
1339
+ if (actor) parts.push("User: " + esc(actor));
1175
1340
  if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
1176
1341
  if (workspace) parts.push(esc(workspace));
1177
1342
  if (duration) parts.push(esc(duration));
1178
1343
  return parts.join(" | ");
1179
1344
  }
1180
1345
  function renderActivity(events) {
1181
- const since = val("activitySince") ? new Date(val("activitySince")).getTime() : 0;
1182
- const filtered = (events || []).filter((e) => !since || new Date(e.timestamp).getTime() >= since);
1183
- document.getElementById("activityList").innerHTML = filtered.map((e) => {
1346
+ document.getElementById("activityList").innerHTML = (events || []).map((e) => {
1184
1347
  const meta = activityMetaHtml(e);
1185
- return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
1348
+ return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.category, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
1186
1349
  }).join("") || '<div class="item">No activity.</div>';
1187
1350
  document.querySelectorAll("#activityList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
1188
1351
  }
1189
1352
  document.getElementById("loadActivityBtn").onclick = () => loadActivity();
1190
- document.getElementById("activitySince").onchange = () => renderActivity(state.activityEvents || []);
1353
+ document.getElementById("activitySince").onchange = () => loadActivity();
1191
1354
  document.getElementById("exportActivityBtn").onclick = () => {
1192
- const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.status, e.type, e.threadId || "", e.prompt || e.detail || ""].join("\\t")).join("\\n");
1355
+ const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.category || "", e.status, e.type, activityActorText(e), e.agentId || "", e.threadId || "", activityWorkspace(e), e.prompt || e.detail || ""].join("\\t")).join("\\n");
1193
1356
  const blob = new Blob([rows], { type: "text/tab-separated-values" });
1194
1357
  const a = document.createElement("a");
1195
1358
  a.href = URL.createObjectURL(blob);
@@ -1203,7 +1366,7 @@
1203
1366
  state.settings = data.settings;
1204
1367
  renderSettings();
1205
1368
  }
1206
- const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
1369
+ const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
1207
1370
  const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
1208
1371
  function orderedSettingsGroups(groups) {
1209
1372
  const known = settingsGroupOrder.filter((name) => groups[name]);
@@ -1213,6 +1376,12 @@
1213
1376
  function agentSettingsNav(current) {
1214
1377
  return '<div class="agent-settings-nav"><strong>Agent settings</strong>' + agentSettingGroups.map((name) => '<button type="button" data-setting-tab="' + attr(name) + '" class="' + (name === current ? "active" : "") + '">' + esc(name) + "</button>").join("") + "</div>";
1215
1378
  }
1379
+ function settingHelp(s) {
1380
+ return s.help ? '<span class="setting-info" tabindex="0" role="img" aria-label="' + attr(s.help) + '" title="' + attr(s.help) + '">i</span>' : "";
1381
+ }
1382
+ function settingLabel(s) {
1383
+ return '<label class="setting-label"><span>' + esc(s.label) + "</span>" + settingHelp(s) + "</label>";
1384
+ }
1216
1385
  function renderSettings() {
1217
1386
  const groups = {};
1218
1387
  state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
@@ -1225,7 +1394,7 @@
1225
1394
  });
1226
1395
  const items = groups[state.settingsGroup] || [];
1227
1396
  const nav = state.settingsGroup === "Agents" || agentSettingGroups.includes(state.settingsGroup) ? agentSettingsNav(state.settingsGroup) : "";
1228
- document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '"><label>' + esc(s.label) + "</label>" + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
1397
+ document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
1229
1398
  document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
1230
1399
  state.settingsGroup = b.dataset.settingTab;
1231
1400
  renderSettings();
@@ -1319,6 +1488,8 @@
1319
1488
  const d = await api("/api/users");
1320
1489
  state.userManagement = d;
1321
1490
  renderUserManagement(d);
1491
+ bindAccessTabs();
1492
+ switchAccessTab(state.accessTab || "users");
1322
1493
  if (can("sessions.read")) await loadLocks();
1323
1494
  else document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
1324
1495
  if (can("audit.read")) await loadAudit();
@@ -1334,7 +1505,7 @@
1334
1505
  return '<div class="permission-grid full-span">' + (state.userManagement?.permissions || []).map((p) => '<label class="checkbox"><input type="checkbox" data-permission-choice="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</div>";
1335
1506
  }
1336
1507
  function selectedValues(selector) {
1337
- return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.value).filter(Boolean);
1508
+ return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.value).filter(Boolean);
1338
1509
  }
1339
1510
  function csv(values = []) {
1340
1511
  return (values || []).join(", ");
@@ -1355,14 +1526,66 @@
1355
1526
  function userGroups(u) {
1356
1527
  return (u.groups || []).map((g) => g.id);
1357
1528
  }
1529
+ function accessCopyButton(value, label) {
1530
+ return value ? '<button type="button" class="copy-id" data-access-copy="' + attr(value) + '" data-access-copy-label="' + attr(label) + '">' + esc(value) + "</button>" : "-";
1531
+ }
1532
+ function bindAccessCopyButtons() {
1533
+ document.querySelectorAll("[data-access-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.accessCopy || "", b.dataset.accessCopyLabel || "Copied"));
1534
+ }
1535
+ function bindAccessTabs() {
1536
+ document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTab(b.dataset.accessTab));
1537
+ const search = document.getElementById("discordChannelSearch");
1538
+ if (search && !search.dataset.bound) {
1539
+ search.dataset.bound = "true";
1540
+ search.oninput = () => renderDiscordChannels();
1541
+ }
1542
+ }
1543
+ function switchAccessTab(tab) {
1544
+ state.accessTab = tab || "users";
1545
+ document.querySelectorAll("[data-access-tab]").forEach((b) => b.classList.toggle("active", b.dataset.accessTab === state.accessTab));
1546
+ document.querySelectorAll("[data-access-tab-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.accessTabPanel === state.accessTab));
1547
+ }
1548
+ function groupNames(ids = []) {
1549
+ const groups = state.userManagement?.groups || [];
1550
+ return (ids || []).map((id) => groups.find((g) => g.id === id)?.name || id).join(", ");
1551
+ }
1552
+ function discordChannelLabel(channel) {
1553
+ return channel.title ? channel.title + " (" + channel.channelId + ")" : channel.channelId;
1554
+ }
1555
+ function discordScopeLabel(ids = []) {
1556
+ const channels = state.userManagement?.discordChannels || [];
1557
+ return (ids || []).map((id) => discordChannelLabel(channels.find((c) => c.channelId === id) || { channelId: id })).join(", ");
1558
+ }
1559
+ function discordChannelOptions(selected = []) {
1560
+ const selectedSet = new Set(selected || []);
1561
+ const channels = state.userManagement?.discordChannels || [];
1562
+ return '<div class="permission-grid full-span">' + (channels.map((c) => '<label class="checkbox"><input type="checkbox" data-discord-channel-choice="' + attr(c.channelId) + '" ' + (selectedSet.has(c.channelId) ? "checked" : "") + "> " + esc(discordChannelLabel(c) + " / " + (c.guildId || "DM")) + "</label>").join("") || "<small>No Discord channels registered.</small>") + "</div>";
1563
+ }
1564
+ function discordIdentityHtml(user, identity) {
1565
+ const id = String(identity.discordUserId || "");
1566
+ return '<span class="access-id-row">' + accessCopyButton(id, "Discord user ID copied") + (identity.username ? " <span>@" + esc(identity.username) + "</span>" : "") + ' <button class="secondary mini-button" data-discord-user="' + attr(user.id) + '" data-discord-unlink="' + attr(identity.id) + '"' + disabledAttr("users.write") + ">Unlink</button></span>";
1567
+ }
1568
+ function renderDiscordChannels(channels = state.userManagement?.discordChannels || []) {
1569
+ const target = document.getElementById("discordChannelsList");
1570
+ if (!target) return;
1571
+ const query = (document.getElementById("discordChannelSearch")?.value || "").toLowerCase();
1572
+ const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.guildId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
1573
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Discord channel ID copied") + '</small><small class="access-id-row">Guild ID: ' + accessCopyButton(c.guildId || "", "Discord guild ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-discord-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-discord-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Discord channels registered.</div>';
1574
+ bindDiscordChannelButtons();
1575
+ bindAccessCopyButtons();
1576
+ applyPermissions();
1577
+ }
1358
1578
  function renderUserManagement(d) {
1359
1579
  document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
1360
1580
  const telegram = (u.telegramIdentities || []).map((t) => t.telegramUserId + (t.username ? " @" + t.username : "") + ' <button class="secondary mini-button" data-telegram-user="' + attr(u.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button>").join(" ");
1361
- return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
1581
+ const discord = (u.discordIdentities || []).map((identity) => discordIdentityHtml(u, identity)).join(" ");
1582
+ return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Discord: " + (discord || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
1362
1583
  }).join("") || '<div class="item">No users.</div>';
1363
- document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
1364
- document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + "</span></strong><small>" + esc("Chat ID: " + c.chatId + " / " + (c.type || "-")) + "</small><small>Groups: " + esc((c.allowedGroupIds || []).join(", ") || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
1584
+ document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + "</small><small>Discord channel scope: " + esc(discordScopeLabel(g.discordChannelIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
1585
+ document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + "</span></strong><small>" + esc("Chat ID: " + c.chatId + " / " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
1586
+ renderDiscordChannels(d.discordChannels || []);
1365
1587
  bindUserButtons();
1588
+ bindAccessCopyButtons();
1366
1589
  applyPermissions();
1367
1590
  }
1368
1591
  function bindUserButtons() {
@@ -1382,6 +1605,11 @@
1382
1605
  toast("Telegram link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
1383
1606
  }));
1384
1607
  document.querySelectorAll("[data-user-link]").forEach((b) => b.onclick = () => openTelegramLinkDialog(b.dataset.userLink));
1608
+ document.querySelectorAll("[data-user-discord-code]").forEach((b) => b.onclick = () => safe(async () => {
1609
+ const r = await api("/api/users/" + encodeURIComponent(b.dataset.userDiscordCode) + "/discord", { method: "POST", body: JSON.stringify({ createCode: true }) });
1610
+ toast("Discord link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
1611
+ }));
1612
+ document.querySelectorAll("[data-user-discord-link]").forEach((b) => b.onclick = () => openDiscordLinkDialog(b.dataset.userDiscordLink));
1385
1613
  document.querySelectorAll("[data-user-password]").forEach((b) => b.onclick = () => openPasswordDialog(b.dataset.userPassword));
1386
1614
  document.querySelectorAll("[data-user-revoke]").forEach((b) => b.onclick = () => safe(async () => {
1387
1615
  if (confirm("Revoke all web sessions for this user?")) {
@@ -1397,6 +1625,13 @@
1397
1625
  loadAccess();
1398
1626
  }
1399
1627
  }));
1628
+ document.querySelectorAll("[data-discord-unlink]").forEach((b) => b.onclick = () => safe(async () => {
1629
+ if (confirm("Unlink this Discord identity?")) {
1630
+ await api("/api/users/" + encodeURIComponent(b.dataset.discordUser) + "/discord/" + encodeURIComponent(b.dataset.discordUnlink), { method: "DELETE" });
1631
+ toast("Discord unlinked");
1632
+ loadAccess();
1633
+ }
1634
+ }));
1400
1635
  document.querySelectorAll("[data-group-edit]").forEach((b) => b.onclick = () => {
1401
1636
  const g = (state.userManagement?.groups || []).find((x) => x.id === b.dataset.groupEdit);
1402
1637
  if (g) openGroupDialog(g);
@@ -1412,6 +1647,30 @@
1412
1647
  toast("Chat updated");
1413
1648
  loadAccess();
1414
1649
  }));
1650
+ document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
1651
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
1652
+ if (c) openDiscordChannelDialog(c);
1653
+ });
1654
+ document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
1655
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
1656
+ if (!c) return;
1657
+ await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
1658
+ toast("Discord channel updated");
1659
+ loadAccess();
1660
+ }));
1661
+ }
1662
+ function bindDiscordChannelButtons() {
1663
+ document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
1664
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
1665
+ if (c) openDiscordChannelDialog(c);
1666
+ });
1667
+ document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
1668
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
1669
+ if (!c) return;
1670
+ await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
1671
+ toast("Discord channel updated");
1672
+ loadAccess();
1673
+ }));
1415
1674
  }
1416
1675
  function openUserDialog(u) {
1417
1676
  adminDialog(u ? "Edit user" : "Create user", '<label>Email<input id="dlgEmail" value="' + attr(u?.email || "") + '"></label><label>Display name<input id="dlgName" value="' + attr(u?.displayName || "") + '"></label>' + (!u ? '<label>Password<input id="dlgPassword" type="password" autocomplete="new-password"></label>' : "") + '<label class="checkbox"><input id="dlgActive" type="checkbox" ' + (!u || u.active ? "checked" : "") + '> Active</label><div class="full-span"><strong>Groups</strong><div class="row">' + groupOptions(u ? userGroups(u) : ["user"]) + "</div></div>", async () => {
@@ -1427,6 +1686,19 @@
1427
1686
  toast("Password updated");
1428
1687
  });
1429
1688
  }
1689
+ function assertDiscordId(value, label, required = true) {
1690
+ const text = String(value || "").trim();
1691
+ if (!text && !required) return "";
1692
+ if (!text) throw new Error(label + " is required");
1693
+ if (!/^\d{5,32}$/.test(text)) throw new Error(label + " must be a numeric Discord ID");
1694
+ return text;
1695
+ }
1696
+ function openDiscordLinkDialog(id) {
1697
+ adminDialog("Link Discord ID", '<label>Discord user ID<input id="dlgDiscordId" inputmode="numeric" pattern="[0-9]*"></label><label>Username<input id="dlgDiscordUsername"></label>', async () => {
1698
+ await api("/api/users/" + encodeURIComponent(id) + "/discord", { method: "POST", body: JSON.stringify({ discordUserId: assertDiscordId(val("dlgDiscordId"), "Discord user ID"), username: val("dlgDiscordUsername") || void 0 }) });
1699
+ toast("Discord linked");
1700
+ });
1701
+ }
1430
1702
  function openTelegramLinkDialog(id) {
1431
1703
  adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
1432
1704
  await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
@@ -1434,12 +1706,20 @@
1434
1706
  });
1435
1707
  }
1436
1708
  function openGroupDialog(g) {
1437
- adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
1438
- const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger) };
1709
+ adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><div class="full-span"><strong>Discord channel scope</strong>' + discordChannelOptions(g?.discordChannelIds || []) + '<small>Leave empty to allow every registered Discord channel.</small></div><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
1710
+ const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger), discordChannelIds: selectedValues("[data-discord-channel-choice]") };
1439
1711
  await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
1440
1712
  toast(g ? "Group updated" : "Group created");
1441
1713
  });
1442
1714
  }
1715
+ function openDiscordChannelDialog(c) {
1716
+ adminDialog(c ? "Edit Discord channel" : "Add Discord channel", '<label>Guild ID<input id="dlgDiscordGuildId" inputmode="numeric" pattern="[0-9]*" value="' + attr(c?.guildId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Channel ID<input id="dlgDiscordChannelId" inputmode="numeric" pattern="[0-9]*" value="' + attr(c?.channelId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgDiscordChannelTitle" value="' + attr(c?.title || "") + '"></label><label>Type<select id="dlgDiscordChannelType"><option value="guild" ' + ((c?.type || "guild") === "guild" ? "selected" : "") + '>guild</option><option value="thread" ' + (c?.type === "thread" ? "selected" : "") + '>thread</option><option value="dm" ' + (c?.type === "dm" ? "selected" : "") + '>dm</option></select></label><label class="checkbox"><input id="dlgDiscordChannelEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
1717
+ const type = val("dlgDiscordChannelType") || "guild";
1718
+ const payload = { guildId: assertDiscordId(val("dlgDiscordGuildId"), "Guild ID", type === "dm" ? false : true) || void 0, channelId: assertDiscordId(val("dlgDiscordChannelId"), "Channel ID"), title: val("dlgDiscordChannelTitle") || void 0, type, enabled: document.getElementById("dlgDiscordChannelEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
1719
+ await api(c ? "/api/discord-channels/" + encodeURIComponent(c.id) : "/api/discord-channels", { method: c ? "PATCH" : "POST", body: JSON.stringify(payload) });
1720
+ toast(c ? "Discord channel updated" : "Discord channel added");
1721
+ });
1722
+ }
1443
1723
  function openChatDialog(c) {
1444
1724
  adminDialog(c ? "Edit Telegram chat" : "Add Telegram chat", '<label>Chat ID<input id="dlgChatId" type="number" value="' + attr(c?.chatId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgChatTitle" value="' + attr(c?.title || "") + '"></label><label>Type<input id="dlgChatType" value="' + attr(c?.type || "supergroup") + '"></label><label class="checkbox"><input id="dlgChatEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
1445
1725
  const payload = { chatId: Number(val("dlgChatId")), title: val("dlgChatTitle") || void 0, type: val("dlgChatType") || void 0, enabled: document.getElementById("dlgChatEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
@@ -1471,13 +1751,23 @@
1471
1751
  }
1472
1752
  openChatDialog(null);
1473
1753
  };
1754
+ document.getElementById("createDiscordChannelBtn").onclick = () => {
1755
+ if (!can("users.write")) {
1756
+ toast("Permission required: users.write");
1757
+ return;
1758
+ }
1759
+ openDiscordChannelDialog(null);
1760
+ };
1474
1761
  async function loadLocks() {
1475
1762
  if (!can("sessions.read")) {
1476
1763
  document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
1477
1764
  return;
1478
1765
  }
1479
1766
  const d = await api("/api/locks");
1480
- document.getElementById("locksList").innerHTML = (d.locks || []).map((l) => '<div class="item"><strong>' + esc(l.contextKey) + "</strong><small>" + esc((l.ownerName || "owner") + " / " + l.ownerId + " / expires " + fmtDate(l.expiresAt)) + "</small></div>").join("") || '<div class="item">No active locks.</div>';
1767
+ document.getElementById("locksList").innerHTML = (d.locks || []).map((l) => {
1768
+ const owner = [l.ownerLabel || l.ownerUserId, l.ownerUserId, l.ownerChannel ? "via " + l.ownerChannel : "", l.ownerChannelUserId ? "channel user " + l.ownerChannelUserId : "", l.expiresAt ? "expires " + fmtDate(l.expiresAt) : ""].filter(Boolean).join(" | ");
1769
+ return '<div class="item"><strong>' + esc(l.contextKey) + "</strong><small>" + esc(owner) + "</small></div>";
1770
+ }).join("") || '<div class="item">No active locks.</div>';
1481
1771
  }
1482
1772
  document.getElementById("lockSessionBtn").onclick = () => safe(async () => {
1483
1773
  if (!can("sessions.write")) {
@@ -1497,15 +1787,36 @@
1497
1787
  toast("Web session unlocked");
1498
1788
  loadLocks();
1499
1789
  });
1790
+ function auditQuery() {
1791
+ return { limit: val("auditLimit") || "50", channel: val("auditChannel") || "all", category: val("auditCategory") || "all", status: val("auditStatus") || "all", actor: val("auditActor") || void 0, agent: val("auditAgent") || "all", thread: val("auditThread") || void 0, workspace: val("auditWorkspace") || void 0, since: val("auditSince") || void 0 };
1792
+ }
1500
1793
  async function loadAudit() {
1501
1794
  if (!can("audit.read")) {
1502
1795
  document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
1503
1796
  return;
1504
1797
  }
1505
- const d = await api("/api/audit", { query: { limit: val("auditLimit") || "50" } });
1506
- document.getElementById("auditList").innerHTML = (d.events || []).map((e) => '<div class="item"><strong>' + esc(fmtDate(e.timestamp) + " / " + (e.channelId || "-") + " / " + e.status + " / " + e.action) + "</strong><small>" + esc((e.contextKey || "-") + " / " + (e.agentId || "-") + " / " + (e.threadId || "-")) + "</small><small>" + esc(e.description || e.detail || "") + "</small></div>").join("") || '<div class="item">No audit events.</div>';
1798
+ const d = await api("/api/audit", { query: auditQuery() });
1799
+ state.auditEvents = d.events || [];
1800
+ renderAudit(state.auditEvents);
1801
+ }
1802
+ function renderAudit(events) {
1803
+ document.getElementById("auditList").innerHTML = (events || []).map((e) => {
1804
+ const actor = e.actor ? [e.actor.label || e.actor.username || e.actor.id, e.actor.channel, e.actor.channelUserId].filter(Boolean).join(" | ") : e.actorId ? String(e.actorId) : "system";
1805
+ const meta = [e.contextKey, e.agentId, e.threadId, e.workspace].filter(Boolean).join(" | ");
1806
+ return '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action].filter(Boolean).join(" | ")) + "</strong><small>" + esc("Actor: " + actor) + "</small>" + (meta ? "<small>" + esc(meta) + "</small>" : "") + "<small>" + esc(e.description || e.detail || "") + "</small></div>";
1807
+ }).join("") || '<div class="item">No audit events.</div>';
1507
1808
  }
1508
1809
  document.getElementById("loadAuditBtn").onclick = () => loadAudit();
1810
+ document.getElementById("exportAuditBtn").onclick = () => {
1811
+ const events = state.auditEvents || [];
1812
+ const text = events.map((e) => [fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action, e.actor?.label || e.actor?.username || e.actor?.id || e.actorId || "system", e.contextKey, e.agentId || "", e.threadId || "", e.workspace || "", e.description || e.detail || ""].join("\\t")).join("\\n");
1813
+ const blob = new Blob([text], { type: "text/tab-separated-values" });
1814
+ const a = document.createElement("a");
1815
+ a.href = URL.createObjectURL(blob);
1816
+ a.download = "nordrelay-audit.tsv";
1817
+ a.click();
1818
+ URL.revokeObjectURL(a.href);
1819
+ };
1509
1820
  async function loadLogs() {
1510
1821
  if (!document.getElementById("logAutoRefresh").checked) setLoading("logs", "Loading logs...");
1511
1822
  const target = document.getElementById("logTarget").value;