@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
@@ -48,12 +48,21 @@ export function registerTelegramQueueCommands(options) {
48
48
  const minutes = Math.min(7 * 24 * 60, Math.max(1, Number(laterMatch[1])));
49
49
  const text = laterMatch[2].trim();
50
50
  const notBefore = Date.now() + minutes * 60 * 1000;
51
- const item = promptStore.enqueue(contextKey, toPromptEnvelope(text), { notBefore });
51
+ const item = promptStore.enqueue(contextKey, {
52
+ ...toPromptEnvelope(text),
53
+ activityActor: options.activityActor?.(ctx),
54
+ }, { notBefore });
52
55
  const message = `Queued prompt ${item.id} for ${formatLocalDateTime(new Date(notBefore))}.`;
53
56
  await safeReply(ctx, escapeHTML(message), {
54
57
  fallbackText: message,
55
58
  replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
56
59
  });
60
+ options.appendActivity?.(ctx, contextKey, session, {
61
+ status: "queued",
62
+ type: "prompt_queued",
63
+ prompt: item.description,
64
+ detail: `Queued prompt ${item.id} for ${formatLocalDateTime(new Date(notBefore))}.`,
65
+ });
57
66
  options.auditContext(ctx, contextKey, session, {
58
67
  action: "prompt_queued",
59
68
  status: "ok",
@@ -81,12 +90,22 @@ export function registerTelegramQueueCommands(options) {
81
90
  const message = `Queue paused. ${promptStore.list(contextKey).length} queued.`;
82
91
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
83
92
  await options.updateQueueStatusMessage(contextKey, message);
93
+ options.appendActivity?.(ctx, contextKey, session, {
94
+ status: "info",
95
+ type: "queue_pause",
96
+ detail: message,
97
+ });
84
98
  return;
85
99
  }
86
100
  if (/^resume$/i.test(argument)) {
87
101
  promptStore.resume(contextKey);
88
102
  const message = `Queue resumed. ${promptStore.list(contextKey).length} queued.`;
89
103
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
104
+ options.appendActivity?.(ctx, contextKey, session, {
105
+ status: "info",
106
+ type: "queue_resume",
107
+ detail: message,
108
+ });
90
109
  if (chatId) {
91
110
  void options.drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
92
111
  console.error("Failed to drain queue after resume:", error);
@@ -110,6 +129,12 @@ export function registerTelegramQueueCommands(options) {
110
129
  }
111
130
  const message = `Moved queued prompt ${item.id} ${direction}.`;
112
131
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
132
+ options.appendActivity?.(ctx, contextKey, session, {
133
+ status: "info",
134
+ type: "queue_move",
135
+ prompt: item.description,
136
+ detail: message,
137
+ });
113
138
  return;
114
139
  }
115
140
  const runMatch = argument.match(/^run\s+([a-z0-9]+)$/i);
@@ -123,6 +148,12 @@ export function registerTelegramQueueCommands(options) {
123
148
  }
124
149
  promptStore.enqueueFront(contextKey, item);
125
150
  promptStore.resume(contextKey);
151
+ options.appendActivity?.(ctx, contextKey, session, {
152
+ status: "info",
153
+ type: "queue_run",
154
+ prompt: item.description,
155
+ detail: `Queued prompt ${item.id} moved to next.`,
156
+ });
126
157
  if (!chatId) {
127
158
  return;
128
159
  }
@@ -159,9 +190,15 @@ export function registerTelegramQueueCommands(options) {
159
190
  if (!contextSession) {
160
191
  return;
161
192
  }
162
- const count = promptStore.clear(contextSession.contextKey);
193
+ const { contextKey, session } = contextSession;
194
+ const count = promptStore.clear(contextKey);
163
195
  const message = `Cleared ${count} queued prompt${count === 1 ? "" : "s"}.`;
164
196
  await safeReply(ctx, escapeHTML(message), { fallbackText: message });
197
+ options.appendActivity?.(ctx, contextKey, session, {
198
+ status: "info",
199
+ type: "queue_clear",
200
+ detail: message,
201
+ });
165
202
  });
166
203
  bot.command("cancel", async (ctx) => {
167
204
  const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
@@ -176,7 +213,8 @@ export function registerTelegramQueueCommands(options) {
176
213
  });
177
214
  return;
178
215
  }
179
- const removed = promptStore.remove(contextSession.contextKey, id);
216
+ const { contextKey, session } = contextSession;
217
+ const removed = promptStore.remove(contextKey, id);
180
218
  if (!removed) {
181
219
  await safeReply(ctx, escapeHTML(`No queued prompt found with id ${id}.`), {
182
220
  fallbackText: `No queued prompt found with id ${id}.`,
@@ -186,6 +224,12 @@ export function registerTelegramQueueCommands(options) {
186
224
  await safeReply(ctx, escapeHTML(`Cancelled queued prompt ${removed.id}.`), {
187
225
  fallbackText: `Cancelled queued prompt ${removed.id}.`,
188
226
  });
227
+ options.appendActivity?.(ctx, contextKey, session, {
228
+ status: "aborted",
229
+ type: "queue_cancel",
230
+ prompt: removed.description,
231
+ detail: `Cancelled queued prompt ${removed.id}.`,
232
+ });
189
233
  });
190
234
  bot.callbackQuery(/^queue_(cancel|remove|top|up|down|run):(-?\d+(?::\d+)?):([a-z0-9]+)$/, async (ctx) => {
191
235
  const action = ctx.match?.[1];
@@ -209,6 +253,15 @@ export function registerTelegramQueueCommands(options) {
209
253
  ? promptStore.moveUp(contextKey, queueId)
210
254
  : promptStore.moveDown(contextKey, queueId);
211
255
  await ctx.answerCallbackQuery({ text: item ? `Moved ${queueId} ${action}.` : "Queued prompt not found." });
256
+ const session = item ? options.getSession(contextKey) : undefined;
257
+ if (item && session) {
258
+ options.appendActivity?.(ctx, contextKey, session, {
259
+ status: "info",
260
+ type: "queue_move",
261
+ prompt: item.description,
262
+ detail: `Moved queued prompt ${item.id} ${action}.`,
263
+ });
264
+ }
212
265
  if (chatId && messageId) {
213
266
  const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
214
267
  await safeEditMessage(bot, chatId, messageId, rendered.html, {
@@ -227,6 +280,15 @@ export function registerTelegramQueueCommands(options) {
227
280
  promptStore.enqueueFront(contextKey, item);
228
281
  promptStore.resume(contextKey);
229
282
  await ctx.answerCallbackQuery({ text: `Queued prompt ${queueId} moved to next.` });
283
+ const session = options.getSession(contextKey);
284
+ if (session) {
285
+ options.appendActivity?.(ctx, contextKey, session, {
286
+ status: "info",
287
+ type: "queue_run",
288
+ prompt: item.description,
289
+ detail: `Queued prompt ${item.id} moved to next.`,
290
+ });
291
+ }
230
292
  if (chatId && messageId) {
231
293
  const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
232
294
  await safeEditMessage(bot, chatId, messageId, rendered.html, {
@@ -234,7 +296,6 @@ export function registerTelegramQueueCommands(options) {
234
296
  replyMarkup: rendered.keyboard,
235
297
  });
236
298
  }
237
- const session = options.getSession(contextKey);
238
299
  if (chatId && session && !options.getBusyReason(contextKey).busy) {
239
300
  void options.drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
240
301
  console.error("Failed to drain queue after run-now callback:", error);
@@ -262,6 +323,15 @@ export function registerTelegramQueueCommands(options) {
262
323
  }
263
324
  const message = `Cancelled queued prompt ${removed.id}.`;
264
325
  await ctx.answerCallbackQuery({ text: message });
326
+ const session = options.getSession(contextKey);
327
+ if (session) {
328
+ options.appendActivity?.(ctx, contextKey, session, {
329
+ status: "aborted",
330
+ type: "queue_cancel",
331
+ prompt: removed.description,
332
+ detail: message,
333
+ });
334
+ }
265
335
  if (!chatId || !messageId) {
266
336
  return;
267
337
  }
@@ -26,6 +26,13 @@ export function registerTelegramSupportCommands(options) {
26
26
  action: "command",
27
27
  status: "ok",
28
28
  contextKey,
29
+ actor: {
30
+ channel: "telegram",
31
+ id: ctx.from?.id !== undefined ? `telegram:${ctx.from.id}` : undefined,
32
+ label: ctx.from?.username || ctx.from?.first_name || (ctx.from?.id !== undefined ? String(ctx.from.id) : undefined),
33
+ username: ctx.from?.username,
34
+ channelUserId: ctx.from?.id !== undefined ? String(ctx.from.id) : undefined,
35
+ },
29
36
  actorId: ctx.from?.id,
30
37
  actorRole: options.getUserRole(ctx),
31
38
  description: "export diagnostics bundle",
@@ -33,12 +33,26 @@ export function registerTelegramUpdateCommands(deps) {
33
33
  }
34
34
  if (subcommand === "cancel" && tokens[1]) {
35
35
  const job = agentUpdates.cancel(tokens[1]);
36
+ deps.appendActivity?.(ctx, {
37
+ status: "aborted",
38
+ type: "agent_update_cancel_requested",
39
+ threadId: null,
40
+ agentId: job.agentId,
41
+ detail: `${job.agentLabel} ${job.operation} cancellation requested.`,
42
+ });
36
43
  const rendered = renderAgentUpdateJobAction(job);
37
44
  await replyChannelAction(ctx, rendered);
38
45
  return;
39
46
  }
40
47
  if ((subcommand === "input" || subcommand === "send") && tokens[1] && tokens.slice(2).join(" ").trim()) {
41
48
  const job = agentUpdates.sendInput(tokens[1], tokens.slice(2).join(" "));
49
+ deps.appendActivity?.(ctx, {
50
+ status: "info",
51
+ type: "agent_update_input_sent",
52
+ threadId: null,
53
+ agentId: job.agentId,
54
+ detail: `Input sent to ${job.agentLabel} ${job.operation}.`,
55
+ });
42
56
  const rendered = renderAgentUpdateJobAction(job);
43
57
  await replyChannelAction(ctx, rendered);
44
58
  return;
@@ -54,6 +68,12 @@ export function registerTelegramUpdateCommands(deps) {
54
68
  return;
55
69
  }
56
70
  const update = spawnSelfUpdate();
71
+ deps.appendActivity?.(ctx, {
72
+ status: "info",
73
+ type: "update_started",
74
+ threadId: null,
75
+ detail: `${update.method}: ${update.summary}`,
76
+ });
57
77
  const rendered = renderSelfUpdateStartedAction(update);
58
78
  await replyChannelAction(ctx, rendered);
59
79
  });
@@ -87,6 +107,13 @@ export function registerTelegramUpdateCommands(deps) {
87
107
  return;
88
108
  }
89
109
  const job = agentUpdates.cancel(id);
110
+ deps.appendActivity?.(ctx, {
111
+ status: "aborted",
112
+ type: "agent_update_cancel_requested",
113
+ threadId: null,
114
+ agentId: job.agentId,
115
+ detail: `${job.agentLabel} ${job.operation} cancellation requested.`,
116
+ });
90
117
  const rendered = renderAgentUpdateJobAction(job);
91
118
  await replyChannelAction(ctx, rendered);
92
119
  });
@@ -25,12 +25,14 @@ export class UserStore {
25
25
  ...user,
26
26
  groups: this.groupsForUser(payload, user.id),
27
27
  telegramIdentities: payload.telegramIdentities.filter((identity) => identity.userId === user.id),
28
+ discordIdentities: payload.discordIdentities.filter((identity) => identity.userId === user.id),
28
29
  webSessions: payload.webSessions
29
30
  .filter((session) => session.userId === user.id)
30
31
  .map(publicWebSession),
31
32
  })),
32
33
  groups: payload.groups,
33
34
  telegramChats: payload.telegramChats,
35
+ discordChannels: payload.discordChannels,
34
36
  adminConfigured: payload.users.some((user) => user.active && this.groupIdsForUser(payload, user.id).includes(ADMIN_GROUP_ID)),
35
37
  };
36
38
  }
@@ -83,6 +85,11 @@ export class UserStore {
83
85
  telegramUserId: input.telegramUserId,
84
86
  });
85
87
  }
88
+ if (input.discordUserId !== undefined) {
89
+ this.upsertDiscordIdentityInPayload(payload, user.id, {
90
+ discordUserId: input.discordUserId,
91
+ });
92
+ }
86
93
  return this.authenticatedUser(payload, user);
87
94
  });
88
95
  }
@@ -229,6 +236,19 @@ export class UserStore {
229
236
  const user = payload.users.find((candidate) => candidate.id === identity.userId && candidate.active);
230
237
  return user ? this.authenticatedUser(payload, user) : null;
231
238
  }
239
+ resolveDiscordUser(discordUserId) {
240
+ const normalized = normalizeDiscordId(discordUserId);
241
+ if (!normalized) {
242
+ return null;
243
+ }
244
+ const payload = this.readPayload();
245
+ const identity = payload.discordIdentities.find((candidate) => candidate.discordUserId === normalized && candidate.active);
246
+ if (!identity) {
247
+ return null;
248
+ }
249
+ const user = payload.users.find((candidate) => candidate.id === identity.userId && candidate.active);
250
+ return user ? this.authenticatedUser(payload, user) : null;
251
+ }
232
252
  linkTelegramUser(userId, input) {
233
253
  return this.mutatePayload((payload) => {
234
254
  const user = payload.users.find((candidate) => candidate.id === userId);
@@ -245,6 +265,22 @@ export class UserStore {
245
265
  return payload.telegramIdentities.length !== before;
246
266
  });
247
267
  }
268
+ linkDiscordUser(userId, input) {
269
+ return this.mutatePayload((payload) => {
270
+ const user = payload.users.find((candidate) => candidate.id === userId);
271
+ if (!user) {
272
+ throw new Error("User not found.");
273
+ }
274
+ return this.upsertDiscordIdentityInPayload(payload, userId, input);
275
+ });
276
+ }
277
+ unlinkDiscordIdentity(identityId) {
278
+ return this.mutatePayload((payload) => {
279
+ const before = payload.discordIdentities.length;
280
+ payload.discordIdentities = payload.discordIdentities.filter((identity) => identity.id !== identityId);
281
+ return payload.discordIdentities.length !== before;
282
+ });
283
+ }
248
284
  createTelegramLinkCode(userId) {
249
285
  return this.mutatePayload((payload) => {
250
286
  if (!payload.users.some((user) => user.id === userId && user.active)) {
@@ -262,6 +298,23 @@ export class UserStore {
262
298
  return code;
263
299
  });
264
300
  }
301
+ createDiscordLinkCode(userId) {
302
+ return this.mutatePayload((payload) => {
303
+ if (!payload.users.some((user) => user.id === userId && user.active)) {
304
+ throw new Error("Active user not found.");
305
+ }
306
+ const now = Date.now();
307
+ payload.discordLinkCodes = payload.discordLinkCodes.filter((code) => new Date(code.expiresAt).getTime() > now);
308
+ const code = {
309
+ code: randomLinkCode(),
310
+ userId,
311
+ createdAt: new Date(now).toISOString(),
312
+ expiresAt: new Date(now + LINK_CODE_TTL_MS).toISOString(),
313
+ };
314
+ payload.discordLinkCodes.push(code);
315
+ return code;
316
+ });
317
+ }
265
318
  consumeTelegramLinkCode(code, input) {
266
319
  return this.mutatePayload((payload) => {
267
320
  const normalized = code.trim().toUpperCase();
@@ -279,6 +332,23 @@ export class UserStore {
279
332
  return this.authenticatedUser(payload, user);
280
333
  });
281
334
  }
335
+ consumeDiscordLinkCode(code, input) {
336
+ return this.mutatePayload((payload) => {
337
+ const normalized = code.trim().toUpperCase();
338
+ const now = Date.now();
339
+ const link = payload.discordLinkCodes.find((candidate) => candidate.code === normalized && new Date(candidate.expiresAt).getTime() > now);
340
+ if (!link) {
341
+ throw new Error("Invalid or expired link code.");
342
+ }
343
+ const user = payload.users.find((candidate) => candidate.id === link.userId && candidate.active);
344
+ if (!user) {
345
+ throw new Error("Linked user is not active.");
346
+ }
347
+ this.upsertDiscordIdentityInPayload(payload, user.id, input);
348
+ payload.discordLinkCodes = payload.discordLinkCodes.filter((candidate) => candidate.code !== normalized);
349
+ return this.authenticatedUser(payload, user);
350
+ });
351
+ }
282
352
  registerTelegramChat(input) {
283
353
  return this.mutatePayload((payload) => {
284
354
  const now = new Date().toISOString();
@@ -322,6 +392,55 @@ export class UserStore {
322
392
  return chat;
323
393
  });
324
394
  }
395
+ registerDiscordChannel(input) {
396
+ return this.mutatePayload((payload) => {
397
+ const now = new Date().toISOString();
398
+ const channelId = normalizeDiscordId(input.channelId);
399
+ if (!channelId) {
400
+ throw new Error("Discord channel id is required.");
401
+ }
402
+ const guildId = normalizeDiscordId(input.guildId);
403
+ const existing = payload.discordChannels.find((channel) => channel.channelId === channelId && channel.guildId === guildId);
404
+ const allowedGroupIds = normalizeGroupIds(payload, input.allowedGroupIds ?? [], null);
405
+ if (existing) {
406
+ existing.title = input.title ?? existing.title;
407
+ existing.type = input.type ?? existing.type;
408
+ existing.enabled = input.enabled ?? existing.enabled;
409
+ existing.allowedGroupIds = allowedGroupIds;
410
+ existing.updatedAt = now;
411
+ return existing;
412
+ }
413
+ const channel = {
414
+ id: randomId(),
415
+ guildId,
416
+ channelId,
417
+ title: input.title,
418
+ type: input.type,
419
+ enabled: input.enabled ?? true,
420
+ allowedGroupIds,
421
+ createdAt: now,
422
+ updatedAt: now,
423
+ };
424
+ payload.discordChannels.push(channel);
425
+ return channel;
426
+ });
427
+ }
428
+ updateDiscordChannel(id, patch) {
429
+ return this.mutatePayload((payload) => {
430
+ const channel = payload.discordChannels.find((candidate) => candidate.id === id);
431
+ if (!channel) {
432
+ throw new Error("Discord channel not found.");
433
+ }
434
+ if (patch.enabled !== undefined)
435
+ channel.enabled = patch.enabled;
436
+ if (patch.title !== undefined)
437
+ channel.title = patch.title;
438
+ if (patch.allowedGroupIds !== undefined)
439
+ channel.allowedGroupIds = normalizeGroupIds(payload, patch.allowedGroupIds, null);
440
+ channel.updatedAt = new Date().toISOString();
441
+ return channel;
442
+ });
443
+ }
325
444
  isTelegramChatAllowed(chatId, chatType, user) {
326
445
  if (chatId === undefined) {
327
446
  return false;
@@ -340,6 +459,26 @@ export class UserStore {
340
459
  const userGroupIds = new Set(user.groups.map((group) => group.id));
341
460
  return access.allowedGroupIds.some((groupId) => userGroupIds.has(groupId)) && this.canUseTelegramChat(user, chatId);
342
461
  }
462
+ isDiscordChannelAllowed(input, user) {
463
+ const channelId = normalizeDiscordId(input.channelId);
464
+ if (!channelId) {
465
+ return false;
466
+ }
467
+ if (input.isDirectMessage) {
468
+ return this.canUseDiscordChannel(user, channelId);
469
+ }
470
+ const guildId = normalizeDiscordId(input.guildId);
471
+ const payload = this.readPayload();
472
+ const access = payload.discordChannels.find((channel) => channel.channelId === channelId && channel.guildId === guildId);
473
+ if (!access?.enabled) {
474
+ return false;
475
+ }
476
+ if (access.allowedGroupIds.length === 0) {
477
+ return this.canUseDiscordChannel(user, channelId);
478
+ }
479
+ const userGroupIds = new Set(user.groups.map((group) => group.id));
480
+ return access.allowedGroupIds.some((groupId) => userGroupIds.has(groupId)) && this.canUseDiscordChannel(user, channelId);
481
+ }
343
482
  hasPermission(user, permission) {
344
483
  return Boolean(permission && user?.permissions.includes(permission));
345
484
  }
@@ -363,6 +502,13 @@ export class UserStore {
363
502
  }
364
503
  return user.groups.some((group) => group.telegramChatIds.length === 0 || group.telegramChatIds.includes(chatId));
365
504
  }
505
+ canUseDiscordChannel(user, channelId) {
506
+ const normalized = normalizeDiscordId(channelId);
507
+ if (!user || !normalized) {
508
+ return true;
509
+ }
510
+ return user.groups.some((group) => group.discordChannelIds.length === 0 || group.discordChannelIds.includes(normalized));
511
+ }
366
512
  createGroup(input) {
367
513
  return this.mutatePayload((payload) => {
368
514
  const now = new Date().toISOString();
@@ -382,6 +528,7 @@ export class UserStore {
382
528
  agentIds: normalizeStringList(input.agentIds ?? []),
383
529
  workspaceRoots: normalizeStringList(input.workspaceRoots ?? []),
384
530
  telegramChatIds: normalizeNumberList(input.telegramChatIds ?? []),
531
+ discordChannelIds: normalizeStringList(input.discordChannelIds ?? []),
385
532
  createdAt: now,
386
533
  updatedAt: now,
387
534
  };
@@ -411,6 +558,8 @@ export class UserStore {
411
558
  group.workspaceRoots = normalizeStringList(patch.workspaceRoots);
412
559
  if (patch.telegramChatIds !== undefined)
413
560
  group.telegramChatIds = normalizeNumberList(patch.telegramChatIds);
561
+ if (patch.discordChannelIds !== undefined)
562
+ group.discordChannelIds = normalizeStringList(patch.discordChannelIds);
414
563
  group.updatedAt = new Date().toISOString();
415
564
  return group;
416
565
  });
@@ -460,6 +609,38 @@ export class UserStore {
460
609
  payload.telegramIdentities.push(identity);
461
610
  return identity;
462
611
  }
612
+ upsertDiscordIdentityInPayload(payload, userId, input) {
613
+ const discordUserId = normalizeDiscordId(input.discordUserId);
614
+ if (!discordUserId) {
615
+ throw new Error("Discord user id is required.");
616
+ }
617
+ const now = new Date().toISOString();
618
+ for (const identity of payload.discordIdentities) {
619
+ if (identity.discordUserId === discordUserId && identity.userId !== userId) {
620
+ identity.active = false;
621
+ }
622
+ }
623
+ const existing = payload.discordIdentities.find((identity) => identity.userId === userId && identity.discordUserId === discordUserId);
624
+ if (existing) {
625
+ existing.username = input.username ?? existing.username;
626
+ existing.globalName = input.globalName ?? existing.globalName;
627
+ existing.active = true;
628
+ existing.updatedAt = now;
629
+ return existing;
630
+ }
631
+ const identity = {
632
+ id: randomId(),
633
+ userId,
634
+ discordUserId,
635
+ username: input.username,
636
+ globalName: input.globalName,
637
+ active: true,
638
+ linkedAt: now,
639
+ updatedAt: now,
640
+ };
641
+ payload.discordIdentities.push(identity);
642
+ return identity;
643
+ }
463
644
  pruneExpiredSessionsInPayload(payload) {
464
645
  const now = Date.now();
465
646
  payload.webSessions = payload.webSessions.filter((session) => new Date(session.expiresAt).getTime() > now);
@@ -551,6 +732,7 @@ function normalizePayload(payload) {
551
732
  agentIds: [],
552
733
  workspaceRoots: [],
553
734
  telegramChatIds: [],
735
+ discordChannelIds: [],
554
736
  createdAt: now,
555
737
  updatedAt: now,
556
738
  });
@@ -565,6 +747,7 @@ function normalizePayload(payload) {
565
747
  agentIds: normalizeStringList(group.agentIds),
566
748
  workspaceRoots: normalizeStringList(group.workspaceRoots),
567
749
  telegramChatIds: normalizeNumberList(group.telegramChatIds),
750
+ discordChannelIds: normalizeStringList(group.discordChannelIds),
568
751
  });
569
752
  }
570
753
  const groups = Array.from(groupsById.values());
@@ -581,8 +764,14 @@ function normalizePayload(payload) {
581
764
  ...chat,
582
765
  allowedGroupIds: chat.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
583
766
  })),
767
+ discordIdentities: (payload?.discordIdentities ?? []).filter((item) => isDiscordIdentityRecord(item) && userIds.has(item.userId)),
768
+ discordChannels: (payload?.discordChannels ?? []).filter(isDiscordChannelAccessRecord).map((channel) => ({
769
+ ...channel,
770
+ allowedGroupIds: channel.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
771
+ })),
584
772
  webSessions: (payload?.webSessions ?? []).filter((item) => isWebSessionRecord(item) && userIds.has(item.userId)),
585
773
  telegramLinkCodes: (payload?.telegramLinkCodes ?? []).filter((item) => isTelegramLinkCodeRecord(item) && userIds.has(item.userId)),
774
+ discordLinkCodes: (payload?.discordLinkCodes ?? []).filter((item) => isDiscordLinkCodeRecord(item) && userIds.has(item.userId)),
586
775
  };
587
776
  }
588
777
  function normalizeEmail(email) {
@@ -619,6 +808,10 @@ function normalizeStringList(values) {
619
808
  function normalizeNumberList(values) {
620
809
  return Array.from(new Set((values ?? []).filter((value) => Number.isInteger(value))));
621
810
  }
811
+ function normalizeDiscordId(value) {
812
+ const normalized = String(value ?? "").trim();
813
+ return normalized || undefined;
814
+ }
622
815
  function assertActiveAdminExists(payload) {
623
816
  const hasAdmin = payload.users.some((user) => user.active && payload.userGroups.some((item) => item.userId === user.id && item.groupId === ADMIN_GROUP_ID));
624
817
  if (!hasAdmin) {
@@ -696,6 +889,16 @@ function isTelegramChatAccessRecord(value) {
696
889
  return Boolean(candidate) && typeof candidate.id === "string" && Number.isInteger(candidate.chatId) &&
697
890
  typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
698
891
  }
892
+ function isDiscordIdentityRecord(value) {
893
+ const candidate = value;
894
+ return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
895
+ typeof candidate.discordUserId === "string" && typeof candidate.active === "boolean";
896
+ }
897
+ function isDiscordChannelAccessRecord(value) {
898
+ const candidate = value;
899
+ return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.channelId === "string" &&
900
+ typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
901
+ }
699
902
  function isWebSessionRecord(value) {
700
903
  const candidate = value;
701
904
  return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
@@ -706,3 +909,8 @@ function isTelegramLinkCodeRecord(value) {
706
909
  return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
707
910
  typeof candidate.expiresAt === "string";
708
911
  }
912
+ function isDiscordLinkCodeRecord(value) {
913
+ const candidate = value;
914
+ return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
915
+ typeof candidate.expiresAt === "string";
916
+ }
@@ -7,6 +7,11 @@ export const WEB_API_ROUTE_DEFINITIONS = [
7
7
  exact("/api/snapshot", ["GET"], "inspect"),
8
8
  exact("/api/tasks", ["GET"], "inspect"),
9
9
  exact("/api/progress", ["GET"], "inspect"),
10
+ exact("/api/metrics", ["GET"], "inspect"),
11
+ exact("/api/jobs", ["GET"], "inspect"),
12
+ dynamic("/api/jobs/:id/log", "^/api/jobs/[^/]+/log$", ["GET"], "inspect", `/api/jobs/${stringToken}/log`),
13
+ dynamic("/api/jobs/:id/action", "^/api/jobs/[^/]+/action$", ["POST"], "inspect", `/api/jobs/${stringToken}/action`),
14
+ exact("/api/active-sessions", ["GET"], "sessions.read"),
10
15
  exact("/api/version", ["GET"], "inspect"),
11
16
  exact("/api/update", ["POST"], "updates.run"),
12
17
  exact("/api/agent-updates", ["GET"], "updates.run"),
@@ -23,10 +28,14 @@ export const WEB_API_ROUTE_DEFINITIONS = [
23
28
  dynamic("/api/users/:id/sessions/:sessionId", "^/api/users/[^/]+/sessions/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/sessions/${stringToken}`),
24
29
  dynamic("/api/users/:id/telegram", "^/api/users/[^/]+/telegram$", ["POST"], "users.write", `/api/users/${stringToken}/telegram`),
25
30
  dynamic("/api/users/:id/telegram/:identityId", "^/api/users/[^/]+/telegram/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/telegram/${stringToken}`),
31
+ dynamic("/api/users/:id/discord", "^/api/users/[^/]+/discord$", ["POST"], "users.write", `/api/users/${stringToken}/discord`),
32
+ dynamic("/api/users/:id/discord/:identityId", "^/api/users/[^/]+/discord/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/discord/${stringToken}`),
26
33
  exact("/api/groups", ["GET", "POST"], readWrite("users.read", "users.write")),
27
34
  dynamic("/api/groups/:id", "^/api/groups/[^/]+$", ["PATCH"], "users.write", `/api/groups/${stringToken}`),
28
35
  exact("/api/telegram-chats", ["GET", "POST"], readWrite("users.read", "users.write")),
29
36
  dynamic("/api/telegram-chats/:id", "^/api/telegram-chats/[^/]+$", ["PATCH"], "users.write", `/api/telegram-chats/${stringToken}`),
37
+ exact("/api/discord-channels", ["GET", "POST"], readWrite("users.read", "users.write")),
38
+ dynamic("/api/discord-channels/:id", "^/api/discord-channels/[^/]+$", ["PATCH"], "users.write", `/api/discord-channels/${stringToken}`),
30
39
  exact("/api/audit", ["GET"], "audit.read"),
31
40
  exact("/api/locks", ["GET", "POST", "DELETE"], readWrite("sessions.read", "sessions.write")),
32
41
  exact("/api/auth/status", ["GET"], "inspect"),