@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
@@ -0,0 +1,2014 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Client, Events, GatewayIntentBits, Partials, REST, Routes, } from "discord.js";
3
+ import { ADMIN_GROUP_ID } from "./access-control.js";
4
+ import { agentLabel, agentReasoningLabel, agentReasoningOptions } from "./agent.js";
5
+ import { getAgentActivityLog, getExternalSnapshotForSession } from "./agent-activity.js";
6
+ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
7
+ import { AgentUpdateManager } from "./agent-updates.js";
8
+ import { enabledAgents } from "./agent-factory.js";
9
+ import { collectRecentWorkspaceArtifacts, ensureOutDir, formatArtifactSummary, persistWorkspaceArtifactReport } from "./artifacts.js";
10
+ import { buildFileInstructions, outboxPath, stageFile } from "./attachments.js";
11
+ import { AuditLogStore } from "./audit-log.js";
12
+ import { BotPreferencesStore, parseMirrorMode, parseNotifyMode, parseVoiceBackendPreference } from "./bot-preferences.js";
13
+ import { capabilitiesOf, filterActivityEvents, formatLocalDateTime, parseActivityOptions, renderExternalMirrorEvent, renderExternalMirrorStatus, renderPromptFailure, trimLine } from "./bot-rendering.js";
14
+ import { renderAgentUpdateJobAction, renderAgentUpdateJobsAction, renderAgentUpdateLogAction, renderAgentUpdatePickerAction, renderQueueListAction } from "./channel-actions.js";
15
+ import { ChannelCommandService } from "./channel-command-service.js";
16
+ import { deliverChannelAction } from "./channel-runtime.js";
17
+ import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
18
+ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
19
+ import { discordContextKey, isDiscordContextKey, parseDiscordContextKey } from "./context-key.js";
20
+ import { DiscordBotChannelRuntime, actionFromDiscordCustomId, discordActionRows, splitDiscordMessage, trimDiscordMessage } from "./discord-channel-runtime.js";
21
+ import { createDiscordArtifactCommandHandler, sendRecentDiscordArtifacts } from "./discord-artifacts.js";
22
+ import { argumentFromDiscordInteraction, discordCommands, isUnauthenticatedDiscordCommandAllowed, parseDiscordMessageCommand, permissionForDiscordAction, requiredPermissionForDiscordCommand } from "./discord-command-surface.js";
23
+ import { discordRateLimiter, getDiscordRateLimitMetrics } from "./discord-rate-limit.js";
24
+ import { friendlyErrorText } from "./error-messages.js";
25
+ import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
26
+ import { spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
27
+ import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
28
+ import { checkPiAuthStatus } from "./pi-auth.js";
29
+ import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
30
+ import { RelayArtifactService } from "./relay-artifact-service.js";
31
+ import { configureRedaction, redactText } from "./redaction.js";
32
+ import { renderSessionInfoPlain } from "./session-format.js";
33
+ import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
34
+ import { SessionRegistry } from "./session-registry.js";
35
+ import { transcribeAudio } from "./voice.js";
36
+ import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
37
+ import { UserStore } from "./user-management.js";
38
+ import { WebActivityStore } from "./web-state.js";
39
+ export { isUnauthenticatedDiscordCommandAllowed, permissionForDiscordAction, requiredPermissionForDiscordCommand } from "./discord-command-surface.js";
40
+ const EDIT_DEBOUNCE_MS = 1500;
41
+ const TYPING_INTERVAL_MS = 4500;
42
+ const MAX_SLASH_CHOICES = 25;
43
+ const MAX_ATTACHMENT_DOWNLOAD = 25 * 1024 * 1024;
44
+ export function createDiscordBridge(config, registry) {
45
+ if (!config.discordEnabled) {
46
+ return null;
47
+ }
48
+ if (!config.discordBotToken) {
49
+ throw new Error("DISCORD_ENABLED=true requires DISCORD_BOT_TOKEN.");
50
+ }
51
+ configureRedaction(config.telegramRedactPatterns);
52
+ const intents = [
53
+ GatewayIntentBits.Guilds,
54
+ GatewayIntentBits.GuildMessages,
55
+ GatewayIntentBits.DirectMessages,
56
+ ];
57
+ if (config.discordMessageContentEnabled) {
58
+ intents.push(GatewayIntentBits.MessageContent);
59
+ }
60
+ const client = new Client({
61
+ intents,
62
+ partials: [Partials.Channel],
63
+ });
64
+ const runtime = new DiscordBotChannelRuntime(client);
65
+ const promptStore = new PromptStore(config.workspace, config.stateBackend);
66
+ const preferencesStore = new BotPreferencesStore(config.workspace, config.stateBackend);
67
+ const activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
68
+ const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
69
+ const lockStore = new SessionLockStore(config.workspace, config.stateBackend);
70
+ const userStore = new UserStore();
71
+ const artifactService = new RelayArtifactService(config);
72
+ const agentUpdates = new AgentUpdateManager();
73
+ const commandService = new ChannelCommandService(config);
74
+ const busyStates = new Map();
75
+ const turnProgress = new Map();
76
+ const draining = new Set();
77
+ const picks = new Map();
78
+ const responseOwners = new Map();
79
+ const externalMirrors = new Map();
80
+ const queueStatusMessages = new Map();
81
+ let externalMonitor;
82
+ const getBusyState = (contextKey) => {
83
+ let state = busyStates.get(contextKey);
84
+ if (!state) {
85
+ state = { processing: false, switching: false };
86
+ busyStates.set(contextKey, state);
87
+ }
88
+ return state;
89
+ };
90
+ const actorFor = (request) => ({
91
+ channel: "discord",
92
+ id: request.authUser?.user.id ?? `discord:${request.user.id}`,
93
+ label: request.authUser?.user.displayName || request.authUser?.user.email || request.user.globalName || request.user.username,
94
+ username: request.authUser?.user.email ?? request.user.username,
95
+ channelUserId: request.user.id,
96
+ });
97
+ const appendActivity = (request, input) => {
98
+ activityStore.append({
99
+ source: "discord",
100
+ contextKey: request.contextKey,
101
+ actor: input.actor ?? actorFor(request),
102
+ workspace: input.workspace ?? config.workspace,
103
+ threadId: input.threadId ?? null,
104
+ ...input,
105
+ });
106
+ };
107
+ const audit = (request, input) => {
108
+ auditLog.append({
109
+ channelId: "discord",
110
+ contextKey: input.contextKey ?? request.contextKey,
111
+ actor: input.actor ?? actorFor(request),
112
+ actorId: request.authUser?.user.id ?? request.user.id,
113
+ actorRole: request.authUser?.groups.map((group) => group.name).join(", ") ?? "unauthenticated",
114
+ ...input,
115
+ });
116
+ };
117
+ const hasPermission = (request, permission) => userStore.hasPermission(request.authUser, permission);
118
+ const reply = async (request, content, options = {}) => {
119
+ const chunks = splitDiscordMessage(content);
120
+ if (request.interaction) {
121
+ const interaction = request.interaction;
122
+ const bucket = request.context.topicId ?? request.context.chatId;
123
+ const first = trimDiscordMessage(chunks.shift() ?? ".");
124
+ const payload = {
125
+ content: first,
126
+ components: discordActionRows(options.buttons),
127
+ allowedMentions: { parse: [] },
128
+ ephemeral: options.ephemeral,
129
+ };
130
+ if (interaction.replied || interaction.deferred) {
131
+ await discordRateLimiter.run(bucket, "sendMessage", () => interaction.followUp(payload))
132
+ .catch(() => runtime.sendMessage(request.context, { text: first, fallbackText: first, buttons: options.buttons }));
133
+ }
134
+ else {
135
+ await discordRateLimiter.run(bucket, "sendMessage", () => interaction.reply(payload));
136
+ }
137
+ for (const chunk of chunks) {
138
+ await discordRateLimiter.run(bucket, "sendMessage", () => interaction.followUp({ content: chunk, allowedMentions: { parse: [] } }))
139
+ .catch(() => runtime.sendMessage(request.context, { text: chunk, fallbackText: chunk }));
140
+ }
141
+ return;
142
+ }
143
+ const first = chunks.shift() ?? ".";
144
+ await runtime.sendMessage(request.context, { text: first, fallbackText: first, buttons: options.buttons });
145
+ for (const chunk of chunks) {
146
+ await runtime.sendMessage(request.context, { text: chunk, fallbackText: chunk });
147
+ }
148
+ };
149
+ const authenticate = async (request, permission, commandName) => {
150
+ if (commandName && isUnauthenticatedDiscordCommandAllowed(commandName)) {
151
+ return true;
152
+ }
153
+ if (!userStore.hasAdminUser()) {
154
+ await reply(request, "NordRelay has no admin user yet. Run `nordrelay user create-admin` on the host.", { ephemeral: true });
155
+ return false;
156
+ }
157
+ const authUser = userStore.resolveDiscordUser(request.user.id);
158
+ if (!authUser) {
159
+ audit(request, {
160
+ action: "permission_denied",
161
+ status: "denied",
162
+ description: "Discord account is not linked",
163
+ });
164
+ if (request.isDirectMessage) {
165
+ await reply(request, "Unauthorized. Link this Discord account to a NordRelay user first.", { ephemeral: true });
166
+ }
167
+ return false;
168
+ }
169
+ request.authUser = authUser;
170
+ if (!isDiscordGuildAllowed(request.guildId) || !isDiscordChannelAllowedByEnv(request.channelId)) {
171
+ audit(request, {
172
+ action: "permission_denied",
173
+ status: "denied",
174
+ description: "Discord guild or channel is outside configured allow-list",
175
+ });
176
+ await reply(request, "This Discord guild or channel is not allowed for NordRelay.", { ephemeral: true });
177
+ return false;
178
+ }
179
+ const chatAllowed = userStore.isDiscordChannelAllowed({
180
+ guildId: request.guildId,
181
+ channelId: request.channelId,
182
+ isDirectMessage: request.isDirectMessage,
183
+ }, authUser);
184
+ if (!chatAllowed && commandName !== "register_channel") {
185
+ audit(request, {
186
+ action: "permission_denied",
187
+ status: "denied",
188
+ description: "Discord channel is not enabled or outside user scope",
189
+ });
190
+ if (request.isDirectMessage) {
191
+ await reply(request, "This Discord channel is not enabled for NordRelay. An admin can use `/register_channel` in the channel.", { ephemeral: true });
192
+ }
193
+ return false;
194
+ }
195
+ if (!permission) {
196
+ audit(request, {
197
+ action: "permission_denied",
198
+ status: "denied",
199
+ description: commandName ? `Unsupported command /${commandName}` : "Unsupported action",
200
+ });
201
+ await reply(request, "Unsupported command or action.", { ephemeral: true });
202
+ return false;
203
+ }
204
+ if (!hasPermission(request, permission)) {
205
+ audit(request, {
206
+ action: "permission_denied",
207
+ status: "denied",
208
+ description: `${permission} required`,
209
+ });
210
+ await reply(request, `Access denied: ${permission} permission required.`, { ephemeral: true });
211
+ return false;
212
+ }
213
+ return true;
214
+ };
215
+ const getSession = async (request, options) => registry.getOrCreate(request.contextKey, options);
216
+ const updateSession = (request, session) => {
217
+ registry.updateMetadata(request.contextKey, session);
218
+ };
219
+ const artifactDeps = {
220
+ config,
221
+ runtime,
222
+ artifactService,
223
+ getSession,
224
+ reply,
225
+ appendActivity,
226
+ };
227
+ const commandArtifacts = createDiscordArtifactCommandHandler(artifactDeps);
228
+ const getBusyReason = (contextKey) => {
229
+ const state = busyStates.get(contextKey);
230
+ const session = registry.get(contextKey);
231
+ if (state?.processing || state?.switching || session?.isProcessing()) {
232
+ return { busy: true, kind: "connector", state: state ?? getBusyState(contextKey) };
233
+ }
234
+ const snapshot = session ? getExternalSnapshotForSession(session, config, { maxEvents: 0 }) : null;
235
+ if (snapshot?.activity.active) {
236
+ return { busy: true, kind: "external", agentLabel: snapshot.agentLabel };
237
+ }
238
+ return { busy: false, kind: "idle" };
239
+ };
240
+ const updateQueueStatusMessage = async (contextKey, context, text) => {
241
+ const state = queueStatusMessages.get(contextKey) ?? {};
242
+ if (state.lastText === text && state.messageId) {
243
+ return;
244
+ }
245
+ if (!state.messageId) {
246
+ const sent = await runtime.sendMessage(context, { text, fallbackText: text });
247
+ state.messageId = sent.messageId;
248
+ state.lastText = text;
249
+ queueStatusMessages.set(contextKey, state);
250
+ return;
251
+ }
252
+ await runtime.editMessage(context, state.messageId, { text, fallbackText: text });
253
+ state.lastText = text;
254
+ queueStatusMessages.set(contextKey, state);
255
+ };
256
+ const sendExternalMirrorTyping = async (context, state) => {
257
+ const now = Date.now();
258
+ if (state.lastTypingAt && now - state.lastTypingAt < TYPING_INTERVAL_MS) {
259
+ return;
260
+ }
261
+ state.lastTypingAt = now;
262
+ await runtime.sendTyping(context).catch(() => { });
263
+ };
264
+ const sendExternalWorkingNotice = async (context, state, snapshot) => {
265
+ const turnKey = snapshot.activity.turnId ?? snapshot.activity.startedAt?.toISOString() ?? "unknown";
266
+ if (state.workingNoticeTurnKey === turnKey) {
267
+ return;
268
+ }
269
+ const prompt = trimLine(snapshot.latestUserMessage ?? "", 250);
270
+ const text = prompt
271
+ ? `**Working on** ${prompt}`
272
+ : `**Working on** external ${snapshot.agentLabel} task...`;
273
+ await runtime.sendMessage(context, {
274
+ text,
275
+ fallbackText: prompt ? `Working on ${prompt}` : `Working on external ${snapshot.agentLabel} task...`,
276
+ });
277
+ state.workingNoticeTurnKey = turnKey;
278
+ };
279
+ const mirrorExternalSnapshot = async (contextKey, context, session, snapshot) => {
280
+ const previous = externalMirrors.get(contextKey);
281
+ let state = previous;
282
+ if (!state || state.threadId !== snapshot.threadId || state.rolloutPath !== snapshot.sourcePath) {
283
+ state = {
284
+ threadId: snapshot.threadId,
285
+ rolloutPath: snapshot.sourcePath,
286
+ lastLine: snapshot.lineCount,
287
+ turnId: snapshot.activity.turnId,
288
+ startedAt: snapshot.activity.startedAt,
289
+ };
290
+ externalMirrors.set(contextKey, state);
291
+ }
292
+ const mirrorMode = preferencesStore.get(contextKey).mirrorMode ?? config.discordMirrorMode;
293
+ if (snapshot.activity.active) {
294
+ state.turnId = snapshot.activity.turnId;
295
+ state.startedAt = snapshot.activity.startedAt;
296
+ const turnKey = snapshot.activity.turnId ?? snapshot.activity.startedAt?.toISOString() ?? "unknown";
297
+ if (state.activityStartedTurnKey !== turnKey) {
298
+ const info = session.getInfo();
299
+ activityStore.append({
300
+ source: "cli",
301
+ status: "running",
302
+ type: "cli_turn_started",
303
+ contextKey,
304
+ threadId: snapshot.threadId,
305
+ workspace: info.workspace,
306
+ agentId: info.agentId,
307
+ actor: { channel: "cli", label: `${snapshot.agentLabel} CLI` },
308
+ prompt: snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`,
309
+ detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
310
+ });
311
+ state.activityStartedTurnKey = turnKey;
312
+ state.activityFinishedTurnKey = undefined;
313
+ state.activityToolStartLines = [];
314
+ state.activityToolEndLines = [];
315
+ }
316
+ if (mirrorMode !== "off") {
317
+ await sendExternalMirrorTyping(context, state);
318
+ }
319
+ if (mirrorMode === "final") {
320
+ await sendExternalWorkingNotice(context, state, snapshot);
321
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
322
+ return;
323
+ }
324
+ if (mirrorMode === "off") {
325
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
326
+ return;
327
+ }
328
+ const status = renderExternalMirrorStatus(snapshot, promptStore.list(contextKey).length);
329
+ const statusMessage = { text: status.html, fallbackText: status.plain, parseMode: "html" };
330
+ const now = Date.now();
331
+ const canUpdateStatus = !state.latestStatusAt || now - state.latestStatusAt >= config.discordMirrorMinUpdateMs;
332
+ if (!state.statusMessageId) {
333
+ const sent = await runtime.sendMessage(context, statusMessage);
334
+ state.statusMessageId = sent.messageId;
335
+ state.latestStatusAt = now;
336
+ }
337
+ else if (state.latestStatus !== status.plain && canUpdateStatus) {
338
+ await runtime.editMessage(context, state.statusMessageId, statusMessage);
339
+ state.latestStatusAt = now;
340
+ }
341
+ state.latestStatus = status.plain;
342
+ if (mirrorMode === "full") {
343
+ const newEvents = snapshot.events
344
+ .filter((event) => event.lineNumber > (state.latestMirroredEventLine ?? state.lastLine))
345
+ .filter((event) => event.kind === "tool" || event.kind === "task")
346
+ .slice(-4);
347
+ for (const event of newEvents) {
348
+ const rendered = renderExternalMirrorEvent(event);
349
+ if (!rendered) {
350
+ continue;
351
+ }
352
+ await deliverChannelAction(runtime, context, rendered);
353
+ state.latestMirroredEventLine = event.lineNumber;
354
+ }
355
+ }
356
+ const info = session.getInfo();
357
+ const loggedStartLines = new Set(state.activityToolStartLines ?? []);
358
+ const loggedEndLines = new Set(state.activityToolEndLines ?? []);
359
+ for (const event of snapshot.events.filter((event) => event.lineNumber > state.lastLine && event.kind === "tool")) {
360
+ if (event.status === "started" && !loggedStartLines.has(event.lineNumber)) {
361
+ activityStore.append({
362
+ source: "cli",
363
+ status: "running",
364
+ type: "cli_tool_started",
365
+ contextKey,
366
+ threadId: snapshot.threadId,
367
+ workspace: info.workspace,
368
+ agentId: info.agentId,
369
+ actor: { channel: "cli", label: `${snapshot.agentLabel} CLI` },
370
+ prompt: snapshot.latestUserMessage ?? undefined,
371
+ detail: event.toolName ?? "tool",
372
+ });
373
+ loggedStartLines.add(event.lineNumber);
374
+ }
375
+ if ((event.status === "finished" || event.status === "failed") && !loggedEndLines.has(event.lineNumber)) {
376
+ activityStore.append({
377
+ source: "cli",
378
+ status: event.status === "failed" ? "failed" : "completed",
379
+ type: event.status === "failed" ? "cli_tool_failed" : "cli_tool_completed",
380
+ contextKey,
381
+ threadId: snapshot.threadId,
382
+ workspace: info.workspace,
383
+ agentId: info.agentId,
384
+ actor: { channel: "cli", label: `${snapshot.agentLabel} CLI` },
385
+ prompt: snapshot.latestUserMessage ?? undefined,
386
+ detail: event.toolName ?? "tool",
387
+ });
388
+ loggedEndLines.add(event.lineNumber);
389
+ }
390
+ }
391
+ state.activityToolStartLines = [...loggedStartLines].slice(-200);
392
+ state.activityToolEndLines = [...loggedEndLines].slice(-200);
393
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
394
+ return;
395
+ }
396
+ if (!previous) {
397
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
398
+ return;
399
+ }
400
+ const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
401
+ if (terminalEvent) {
402
+ const turnKey = terminalEvent.turnId ?? snapshot.activity.turnId ?? state.startedAt?.toString() ?? "unknown";
403
+ if (state.activityFinishedTurnKey !== turnKey) {
404
+ const info = session.getInfo();
405
+ const startedAt = state.startedAt instanceof Date ? state.startedAt : state.startedAt ? new Date(state.startedAt) : snapshot.activity.startedAt;
406
+ activityStore.append({
407
+ source: "cli",
408
+ status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
409
+ type: "cli_turn_finished",
410
+ contextKey,
411
+ threadId: snapshot.threadId,
412
+ workspace: info.workspace,
413
+ agentId: info.agentId,
414
+ actor: { channel: "cli", label: `${snapshot.agentLabel} CLI` },
415
+ prompt: snapshot.latestUserMessage ?? undefined,
416
+ detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
417
+ durationMs: startedAt && terminalEvent.timestamp ? Math.max(0, terminalEvent.timestamp.getTime() - startedAt.getTime()) : undefined,
418
+ });
419
+ state.activityFinishedTurnKey = turnKey;
420
+ }
421
+ if (mirrorMode !== "off") {
422
+ const doneText = `${snapshot.agentLabel} CLI task ${terminalEvent.status}.`;
423
+ if (state.statusMessageId) {
424
+ await runtime.editMessage(context, state.statusMessageId, { text: doneText, fallbackText: doneText });
425
+ }
426
+ else {
427
+ await runtime.sendMessage(context, { text: doneText, fallbackText: doneText });
428
+ }
429
+ }
430
+ const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
431
+ if (mirrorMode !== "off" && mirrorMode !== "status" && finalAgent?.text && finalAgent.lineNumber !== state.latestAgentLine) {
432
+ await runtime.sendMessage(context, {
433
+ text: `**${snapshot.agentLabel} CLI final answer:**`,
434
+ fallbackText: `${snapshot.agentLabel} CLI final answer:`,
435
+ });
436
+ for (const chunk of splitDiscordMessage(finalAgent.text)) {
437
+ await runtime.sendMessage(context, { text: chunk, fallbackText: chunk });
438
+ }
439
+ state.latestAgentLine = finalAgent.lineNumber;
440
+ }
441
+ await deliverCliGeneratedArtifacts(contextKey, context, session, state.startedAt, terminalEvent.turnId);
442
+ }
443
+ state.workingNoticeTurnKey = undefined;
444
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
445
+ };
446
+ const ensureActiveThread = async (request, session) => {
447
+ if (!session.hasActiveThread()) {
448
+ await session.newThread();
449
+ updateSession(request, session);
450
+ }
451
+ };
452
+ const checkAgentAuthStatus = async (info) => {
453
+ if (info.agentId === "pi")
454
+ return checkPiAuthStatus(info.model);
455
+ if (info.agentId === "hermes")
456
+ return checkHermesAuthStatus({ baseUrl: config.hermesApiBaseUrl, apiKey: config.hermesApiKey });
457
+ if (info.agentId === "openclaw")
458
+ return checkOpenClawAuthStatus({ gatewayUrl: config.openClawGatewayUrl, token: config.openClawGatewayToken, password: config.openClawGatewayPassword });
459
+ if (info.agentId === "claude-code")
460
+ return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
461
+ return checkAuthStatus(config.codexApiKey);
462
+ };
463
+ const checkLoginAuthStatus = async (info) => {
464
+ if (info.agentId === "hermes")
465
+ return checkHermesAuthStatus({ baseUrl: config.hermesApiBaseUrl, apiKey: config.hermesApiKey });
466
+ if (info.agentId === "claude-code")
467
+ return checkClaudeCodeAuthStatus(config.claudeCodeCliPath);
468
+ return checkAuthStatus(config.codexApiKey);
469
+ };
470
+ const startAgentLogin = (info) => {
471
+ if (info.agentId === "hermes")
472
+ return startHermesLogin(config.hermesCliPath);
473
+ if (info.agentId === "claude-code")
474
+ return startClaudeCodeLogin(config.claudeCodeCliPath);
475
+ if (info.agentId === "codex")
476
+ return startCodexLogin();
477
+ return Promise.resolve({
478
+ success: false,
479
+ message: `${info.agentLabel} login is not managed by NordRelay. Run the agent login flow on the host.`,
480
+ });
481
+ };
482
+ const startAgentLogout = (info) => {
483
+ if (info.agentId === "hermes")
484
+ return startHermesLogout(config.hermesCliPath);
485
+ if (info.agentId === "claude-code")
486
+ return startClaudeCodeLogout(config.claudeCodeCliPath);
487
+ if (info.agentId === "codex")
488
+ return startCodexLogout();
489
+ return Promise.resolve({
490
+ success: false,
491
+ message: `${info.agentLabel} logout is not managed by NordRelay. Run the agent logout flow on the host.`,
492
+ });
493
+ };
494
+ const hostLoginCommand = (info) => {
495
+ if (info.agentId === "hermes")
496
+ return `${config.hermesCliPath ?? "hermes"} login --no-browser`;
497
+ if (info.agentId === "claude-code")
498
+ return `${config.claudeCodeCliPath ?? "claude"} auth login`;
499
+ if (info.agentId === "pi")
500
+ return `${config.piCliPath ?? "pi"} auth login`;
501
+ if (info.agentId === "openclaw")
502
+ return `${config.openClawCliPath ?? "openclaw"} login`;
503
+ return "codex login --device-auth";
504
+ };
505
+ const hostLogoutCommand = (info) => {
506
+ if (info.agentId === "hermes")
507
+ return `${config.hermesCliPath ?? "hermes"} logout`;
508
+ if (info.agentId === "claude-code")
509
+ return `${config.claudeCodeCliPath ?? "claude"} auth logout`;
510
+ if (info.agentId === "pi")
511
+ return `${config.piCliPath ?? "pi"} auth logout`;
512
+ if (info.agentId === "openclaw")
513
+ return `${config.openClawCliPath ?? "openclaw"} logout`;
514
+ return "codex logout";
515
+ };
516
+ const denyIfLocked = async (request) => {
517
+ const lock = lockStore.get(request.contextKey);
518
+ const isAdmin = request.authUser?.groups.some((group) => group.id === ADMIN_GROUP_ID) ?? false;
519
+ if (canWriteWithLock(lock, request.authUser?.user.id, isAdmin)) {
520
+ return false;
521
+ }
522
+ await reply(request, `Session is locked by ${lock?.ownerLabel || lock?.ownerUserId || "another user"}.`);
523
+ return true;
524
+ };
525
+ const handlePrompt = async (request, input, artifactOutDir, options = {}) => {
526
+ const session = await getSession(request);
527
+ const envelope = toPromptEnvelope(input, artifactOutDir);
528
+ envelope.activityActor = actorFor(request);
529
+ if (!options.fromQueue && await denyIfLocked(request)) {
530
+ return;
531
+ }
532
+ const busy = getBusyReason(request.contextKey);
533
+ if (busy.busy) {
534
+ const item = options.fromQueue && isQueuedPrompt(envelope)
535
+ ? envelope
536
+ : promptStore.enqueue(request.contextKey, envelope);
537
+ const position = promptStore.list(request.contextKey).findIndex((queued) => queued.id === item.id) + 1;
538
+ const text = busy.kind === "external"
539
+ ? `Queued prompt ${item.id} at position ${position}. The ${busy.agentLabel} session is still active and is processing a previous task.`
540
+ : `Queued prompt ${item.id} at position ${position}.`;
541
+ await reply(request, text, {
542
+ buttons: [[{ label: "Cancel queued message", action: `discord_queue_cancel:${request.contextKey}:${item.id}` }]],
543
+ });
544
+ appendActivity(request, {
545
+ status: "queued",
546
+ type: "prompt_queued",
547
+ prompt: item.description,
548
+ detail: text,
549
+ });
550
+ audit(request, {
551
+ action: "prompt_queued",
552
+ status: "ok",
553
+ promptId: item.id,
554
+ description: item.description,
555
+ });
556
+ return;
557
+ }
558
+ const busyState = getBusyState(request.contextKey);
559
+ busyState.processing = true;
560
+ const typing = setInterval(() => {
561
+ void runtime.sendTyping(request.context).catch(() => { });
562
+ }, TYPING_INTERVAL_MS);
563
+ void runtime.sendTyping(request.context).catch(() => { });
564
+ let accumulatedText = "";
565
+ let responseMessageId;
566
+ let planMessageId;
567
+ let flushTimer;
568
+ let lastEditAt = 0;
569
+ let running = true;
570
+ let finalized = false;
571
+ const toolCounts = new Map();
572
+ const toolVerbosity = config.toolVerbosity;
573
+ const startedAt = Date.now();
574
+ const turnId = randomUUID().slice(0, 12);
575
+ const progress = {
576
+ status: "running",
577
+ promptDescription: envelope.description,
578
+ startedAt,
579
+ updatedAt: startedAt,
580
+ toolCounts,
581
+ textCharacters: 0,
582
+ };
583
+ turnProgress.set(request.contextKey, progress);
584
+ const scheduleFlush = () => {
585
+ if (flushTimer || !running) {
586
+ return;
587
+ }
588
+ const delay = Math.max(0, EDIT_DEBOUNCE_MS - (Date.now() - lastEditAt));
589
+ flushTimer = setTimeout(() => {
590
+ flushTimer = undefined;
591
+ void flushResponse().catch((error) => console.error("Failed to edit Discord response:", error));
592
+ }, delay);
593
+ };
594
+ const ensureResponse = async () => {
595
+ if (responseMessageId)
596
+ return;
597
+ const preview = trimDiscordMessage(accumulatedText || "Working...");
598
+ const sent = await runtime.sendMessage(request.context, {
599
+ text: preview,
600
+ fallbackText: preview,
601
+ buttons: [[{ label: "Abort", action: `discord_abort:${request.contextKey}` }]],
602
+ });
603
+ responseMessageId = sent.messageId;
604
+ responseOwners.set(responseMessageId, request.contextKey);
605
+ lastEditAt = Date.now();
606
+ };
607
+ const flushResponse = async (force = false) => {
608
+ if (!accumulatedText.trim())
609
+ return;
610
+ await ensureResponse();
611
+ if (!responseMessageId)
612
+ return;
613
+ const now = Date.now();
614
+ if (!force && now - lastEditAt < EDIT_DEBOUNCE_MS)
615
+ return;
616
+ await runtime.editMessage(request.context, responseMessageId, {
617
+ text: trimDiscordMessage(accumulatedText),
618
+ fallbackText: trimDiscordMessage(accumulatedText),
619
+ buttons: [[{ label: "Abort", action: `discord_abort:${request.contextKey}` }]],
620
+ });
621
+ lastEditAt = Date.now();
622
+ };
623
+ const finalize = async () => {
624
+ if (finalized) {
625
+ return;
626
+ }
627
+ finalized = true;
628
+ running = false;
629
+ clearInterval(typing);
630
+ if (flushTimer) {
631
+ clearTimeout(flushTimer);
632
+ flushTimer = undefined;
633
+ }
634
+ const finalText = accumulatedText.trim() || "Done.";
635
+ const chunks = splitDiscordMessage(finalText);
636
+ if (responseMessageId) {
637
+ const [first, ...rest] = chunks;
638
+ await runtime.editMessage(request.context, responseMessageId, { text: first ?? "Done.", fallbackText: first ?? "Done." });
639
+ for (const chunk of rest) {
640
+ await runtime.sendMessage(request.context, { text: chunk, fallbackText: chunk });
641
+ }
642
+ }
643
+ else {
644
+ for (const chunk of chunks) {
645
+ await runtime.sendMessage(request.context, { text: chunk, fallbackText: chunk });
646
+ }
647
+ }
648
+ };
649
+ const callbacks = {
650
+ onTextDelta: (delta) => {
651
+ accumulatedText += delta;
652
+ progress.textCharacters = accumulatedText.length;
653
+ progress.updatedAt = Date.now();
654
+ void ensureResponse().then(() => scheduleFlush()).catch((error) => console.error("Failed to send Discord response:", error));
655
+ },
656
+ onToolStart: (toolName) => {
657
+ toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1);
658
+ progress.currentTool = toolName;
659
+ progress.lastTool = toolName;
660
+ progress.updatedAt = Date.now();
661
+ appendActivity(request, {
662
+ status: "running",
663
+ type: "tool_started",
664
+ prompt: envelope.description,
665
+ detail: toolName,
666
+ threadId: session.getInfo().threadId,
667
+ workspace: session.getInfo().workspace,
668
+ agentId: session.getInfo().agentId,
669
+ });
670
+ if (toolVerbosity === "all") {
671
+ void runtime.sendMessage(request.context, { text: `Tool started: ${toolName}`, fallbackText: `Tool started: ${toolName}` }).catch(() => { });
672
+ }
673
+ },
674
+ onToolUpdate: () => { },
675
+ onToolEnd: (_toolCallId, isError) => {
676
+ progress.currentTool = undefined;
677
+ progress.updatedAt = Date.now();
678
+ appendActivity(request, {
679
+ status: isError ? "failed" : "completed",
680
+ type: isError ? "tool_failed" : "tool_completed",
681
+ prompt: envelope.description,
682
+ detail: "tool",
683
+ threadId: session.getInfo().threadId,
684
+ workspace: session.getInfo().workspace,
685
+ agentId: session.getInfo().agentId,
686
+ });
687
+ },
688
+ onTodoUpdate: (items) => {
689
+ progress.updatedAt = Date.now();
690
+ const text = [
691
+ "Plan:",
692
+ ...items.map((item) => `${item.completed ? "[x]" : "[ ]"} ${item.text}`),
693
+ ].join("\n");
694
+ if (!planMessageId) {
695
+ void runtime.sendMessage(request.context, { text, fallbackText: text }).then((result) => {
696
+ planMessageId = result.messageId;
697
+ }).catch(() => { });
698
+ }
699
+ else {
700
+ void runtime.editMessage(request.context, planMessageId, { text, fallbackText: text }).catch(() => { });
701
+ }
702
+ },
703
+ onTurnComplete: () => { },
704
+ onAgentEnd: () => {
705
+ progress.status = "completed";
706
+ progress.completedAt = Date.now();
707
+ progress.updatedAt = progress.completedAt;
708
+ void finalize().catch((error) => console.error("Failed to finalize Discord response:", error));
709
+ },
710
+ };
711
+ try {
712
+ const info = session.getInfo();
713
+ if ((info.capabilities ?? capabilitiesOf(info)).auth) {
714
+ const auth = await checkAgentAuthStatus(info);
715
+ if (!auth.authenticated) {
716
+ throw new Error(`${agentLabel(info.agentId)} is not authenticated: ${auth.detail}`);
717
+ }
718
+ }
719
+ await ensureActiveThread(request, session);
720
+ const currentInfo = session.getInfo();
721
+ const workspacePolicy = evaluateWorkspacePolicy(currentInfo.workspace, config);
722
+ if (!workspacePolicy.allowed) {
723
+ throw new Error(workspacePolicy.warning ?? "Current workspace is blocked by policy.");
724
+ }
725
+ promptStore.setLastPrompt(request.contextKey, envelope);
726
+ appendActivity(request, {
727
+ status: "running",
728
+ type: "prompt_started",
729
+ prompt: envelope.description,
730
+ threadId: currentInfo.threadId,
731
+ workspace: currentInfo.workspace,
732
+ agentId: currentInfo.agentId,
733
+ });
734
+ audit(request, {
735
+ action: "prompt_started",
736
+ status: "ok",
737
+ agentId: currentInfo.agentId,
738
+ threadId: currentInfo.threadId,
739
+ workspace: currentInfo.workspace,
740
+ description: envelope.description,
741
+ });
742
+ await session.prompt(envelope.input, callbacks);
743
+ updateSession(request, session);
744
+ progress.status = "completed";
745
+ progress.completedAt = Date.now();
746
+ progress.updatedAt = progress.completedAt;
747
+ await finalize();
748
+ await artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, new Date(startedAt));
749
+ if (config.discordAutoSendArtifacts) {
750
+ await sendRecentDiscordArtifacts(artifactDeps, request, session, new Date(startedAt), turnId);
751
+ }
752
+ appendActivity(request, {
753
+ status: "completed",
754
+ type: "prompt_completed",
755
+ prompt: envelope.description,
756
+ threadId: session.getInfo().threadId,
757
+ workspace: session.getInfo().workspace,
758
+ agentId: session.getInfo().agentId,
759
+ durationMs: Date.now() - startedAt,
760
+ });
761
+ audit(request, {
762
+ action: "prompt_completed",
763
+ status: "ok",
764
+ agentId: session.getInfo().agentId,
765
+ threadId: session.getInfo().threadId,
766
+ workspace: session.getInfo().workspace,
767
+ description: envelope.description,
768
+ });
769
+ }
770
+ catch (error) {
771
+ progress.status = "failed";
772
+ progress.completedAt = Date.now();
773
+ progress.updatedAt = progress.completedAt;
774
+ progress.error = friendlyErrorText(error);
775
+ const errorText = renderPromptFailure(accumulatedText, error);
776
+ if (responseMessageId) {
777
+ await runtime.editMessage(request.context, responseMessageId, { text: trimDiscordMessage(errorText), fallbackText: trimDiscordMessage(errorText) }).catch(() => { });
778
+ }
779
+ else {
780
+ await reply(request, errorText).catch(() => { });
781
+ }
782
+ appendActivity(request, {
783
+ status: "failed",
784
+ type: "prompt_failed",
785
+ prompt: envelope.description,
786
+ detail: friendlyErrorText(error),
787
+ threadId: session.getInfo().threadId,
788
+ workspace: session.getInfo().workspace,
789
+ agentId: session.getInfo().agentId,
790
+ durationMs: Date.now() - startedAt,
791
+ });
792
+ audit(request, {
793
+ action: "prompt_failed",
794
+ status: "failed",
795
+ agentId: session.getInfo().agentId,
796
+ threadId: session.getInfo().threadId,
797
+ workspace: session.getInfo().workspace,
798
+ description: envelope.description,
799
+ detail: friendlyErrorText(error),
800
+ });
801
+ }
802
+ finally {
803
+ running = false;
804
+ clearInterval(typing);
805
+ busyState.processing = false;
806
+ await drainQueue(request).catch((error) => console.error("Failed to drain Discord queue:", error));
807
+ }
808
+ };
809
+ const drainQueue = async (request) => {
810
+ if (draining.has(request.contextKey))
811
+ return;
812
+ draining.add(request.contextKey);
813
+ try {
814
+ while (true) {
815
+ const session = await getSession(request, { deferThreadStart: true });
816
+ if (session.isProcessing() || getBusyReason(request.contextKey).busy)
817
+ return;
818
+ const next = promptStore.dequeue(request.contextKey);
819
+ if (!next)
820
+ return;
821
+ await reply(request, `Processing queued prompt ${next.id}: ${next.description}`);
822
+ await handlePrompt(request, next.input, next.artifactOutDir, { fromQueue: true });
823
+ }
824
+ }
825
+ finally {
826
+ draining.delete(request.contextKey);
827
+ }
828
+ };
829
+ const deliverCliGeneratedArtifacts = async (contextKey, context, session, startedAt, turnId) => {
830
+ if (!startedAt || !turnId) {
831
+ return;
832
+ }
833
+ const state = externalMirrors.get(contextKey);
834
+ if (state?.artifactsDeliveredForTurnId === turnId) {
835
+ return;
836
+ }
837
+ const workspace = session.getInfo().workspace;
838
+ const report = await collectRecentWorkspaceArtifacts(workspace, {
839
+ since: startedAt,
840
+ until: new Date(),
841
+ maxFileSize: config.maxFileSize,
842
+ limit: 5,
843
+ ignoreDirs: config.artifactIgnoreDirs,
844
+ ignoreGlobs: config.artifactIgnoreGlobs,
845
+ });
846
+ if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
847
+ if (state)
848
+ state.artifactsDeliveredForTurnId = turnId;
849
+ return;
850
+ }
851
+ const persisted = await persistWorkspaceArtifactReport(workspace, turnId, report).catch((error) => {
852
+ console.error("Failed to persist Discord CLI artifact report:", error);
853
+ return null;
854
+ });
855
+ const summary = formatArtifactSummary(report.artifacts, report.skippedCount, report.omittedCount);
856
+ if (summary) {
857
+ await runtime.sendMessage(context, { text: summary, fallbackText: summary });
858
+ }
859
+ if (config.discordAutoSendArtifacts) {
860
+ for (const artifact of (persisted?.artifacts ?? report.artifacts).slice(0, 5)) {
861
+ await runtime.sendFile(context, { localPath: artifact.localPath, name: artifact.name }).catch((error) => {
862
+ console.error(`Failed to send Discord CLI artifact ${artifact.name}:`, error);
863
+ });
864
+ }
865
+ }
866
+ const info = session.getInfo();
867
+ activityStore.append({
868
+ source: "cli",
869
+ status: "info",
870
+ type: config.discordAutoSendArtifacts ? "artifacts_sent" : "artifacts_detected",
871
+ contextKey,
872
+ threadId: info.threadId,
873
+ workspace: info.workspace,
874
+ agentId: info.agentId,
875
+ actor: { channel: "cli", label: `${info.agentLabel} CLI` },
876
+ detail: summary,
877
+ });
878
+ if (state)
879
+ state.artifactsDeliveredForTurnId = turnId;
880
+ };
881
+ const handleCommand = async (request, command, argument) => {
882
+ const normalized = command.toLowerCase();
883
+ const permission = requiredPermissionForDiscordCommand(normalized, argument);
884
+ if (!await authenticate(request, permission, normalized)) {
885
+ return;
886
+ }
887
+ audit(request, { action: "command", status: "ok", description: `/${normalized} ${argument}`.trim() });
888
+ switch (normalized) {
889
+ case "start":
890
+ case "help":
891
+ await commandHelp(request);
892
+ return;
893
+ case "channels":
894
+ await deliverChannelAction(runtime, request.context, commandService.renderChannels());
895
+ return;
896
+ case "agents":
897
+ await deliverChannelAction(runtime, request.context, commandService.renderAgents());
898
+ return;
899
+ case "agent":
900
+ await commandAgent(request, argument);
901
+ return;
902
+ case "auth":
903
+ await commandAuth(request);
904
+ return;
905
+ case "login":
906
+ await commandLogin(request);
907
+ return;
908
+ case "logout":
909
+ await commandLogout(request);
910
+ return;
911
+ case "session":
912
+ await commandSession(request);
913
+ return;
914
+ case "sessions":
915
+ await commandSessions(request, argument);
916
+ return;
917
+ case "new":
918
+ await commandNew(request, argument);
919
+ return;
920
+ case "switch":
921
+ case "attach":
922
+ await commandSwitch(request, argument);
923
+ return;
924
+ case "model":
925
+ await commandModel(request, argument);
926
+ return;
927
+ case "reasoning":
928
+ case "effort":
929
+ await commandReasoning(request, argument);
930
+ return;
931
+ case "fast":
932
+ await commandFast(request, argument);
933
+ return;
934
+ case "launch":
935
+ case "launch_profiles":
936
+ case "launch-profiles":
937
+ await commandLaunch(request, argument);
938
+ return;
939
+ case "queue":
940
+ await commandQueue(request, argument);
941
+ return;
942
+ case "clearqueue":
943
+ promptStore.clear(request.contextKey);
944
+ await reply(request, "Queue cleared.");
945
+ return;
946
+ case "cancel":
947
+ await commandQueue(request, `cancel ${argument}`);
948
+ return;
949
+ case "abort":
950
+ case "stop":
951
+ await commandAbort(request);
952
+ return;
953
+ case "retry":
954
+ await commandRetry(request);
955
+ return;
956
+ case "sync":
957
+ await commandSync(request);
958
+ return;
959
+ case "tasks":
960
+ case "progress":
961
+ await commandProgress(request);
962
+ return;
963
+ case "activity":
964
+ await commandActivity(request, argument);
965
+ return;
966
+ case "audit":
967
+ await commandAudit(request, argument);
968
+ return;
969
+ case "artifacts":
970
+ await commandArtifacts(request, argument);
971
+ return;
972
+ case "logs":
973
+ await commandLogs(request, argument);
974
+ return;
975
+ case "version":
976
+ case "health":
977
+ case "status":
978
+ await commandVersion(request);
979
+ return;
980
+ case "diagnostics":
981
+ await commandDiagnostics(request);
982
+ return;
983
+ case "support":
984
+ await commandDiagnostics(request);
985
+ return;
986
+ case "restart":
987
+ await commandRestart(request);
988
+ return;
989
+ case "update":
990
+ await commandUpdate(request, argument);
991
+ return;
992
+ case "lock":
993
+ await commandLock(request);
994
+ return;
995
+ case "unlock":
996
+ lockStore.clear(request.contextKey);
997
+ await reply(request, "Session unlocked.");
998
+ return;
999
+ case "locks":
1000
+ await reply(request, lockStore.list().map((lock) => `${lock.contextKey}: ${lock.ownerLabel || lock.ownerUserId}`).join("\n") || "No active locks.");
1001
+ return;
1002
+ case "mirror":
1003
+ await commandMirror(request, argument);
1004
+ return;
1005
+ case "notify":
1006
+ await commandNotify(request, argument);
1007
+ return;
1008
+ case "voice":
1009
+ await commandVoice(request, argument);
1010
+ return;
1011
+ case "workspaces":
1012
+ await commandWorkspaces(request);
1013
+ return;
1014
+ case "pin":
1015
+ await commandPin(request, argument);
1016
+ return;
1017
+ case "unpin":
1018
+ await commandUnpin(request, argument);
1019
+ return;
1020
+ case "pinned":
1021
+ await commandPinned(request);
1022
+ return;
1023
+ case "handback":
1024
+ await commandHandback(request);
1025
+ return;
1026
+ case "register_channel":
1027
+ await commandRegisterChannel(request);
1028
+ return;
1029
+ case "link":
1030
+ await commandLink(request, argument);
1031
+ return;
1032
+ case "whoami":
1033
+ await reply(request, request.authUser ? `${request.authUser.user.displayName} <${request.authUser.user.email}>\nGroups: ${request.authUser.groups.map((group) => group.name).join(", ")}` : "Not linked.");
1034
+ return;
1035
+ case "prompt":
1036
+ await handlePrompt(request, argument);
1037
+ return;
1038
+ default:
1039
+ await reply(request, `Unknown command: /${normalized}`);
1040
+ }
1041
+ };
1042
+ const commandHelp = async (request) => {
1043
+ const session = await getSession(request, { deferThreadStart: true });
1044
+ await reply(request, [
1045
+ "NordRelay Discord adapter is ready.",
1046
+ "",
1047
+ "Send a message to prompt the selected agent, or use slash commands.",
1048
+ "",
1049
+ "Core commands: `/agent`, `/agents`, `/auth`, `/login`, `/logout`, `/session`, `/sessions`, `/new`, `/switch`, `/attach`, `/handback`, `/workspaces`, `/pin`, `/unpin`, `/pinned`, `/model`, `/reasoning`, `/fast`, `/launch`, `/launch_profiles`, `/queue`, `/clearqueue`, `/cancel`, `/stop`, `/retry`, `/sync`, `/progress`, `/activity`, `/audit`, `/artifacts`, `/logs`, `/version`, `/diagnostics`, `/support`, `/restart`, `/update`, `/lock`, `/unlock`, `/locks`, `/mirror`, `/notify`, `/voice`, `/channels`, `/whoami`, `/link`, `/register_channel`.",
1050
+ "",
1051
+ renderSessionInfoPlain(session.getInfo()),
1052
+ ].join("\n"));
1053
+ };
1054
+ const commandAgent = async (request, argument) => {
1055
+ const choices = enabledAgents(config);
1056
+ const requested = argument.trim();
1057
+ if (requested && choices.includes(requested)) {
1058
+ const state = getBusyState(request.contextKey);
1059
+ if (getBusyReason(request.contextKey).busy) {
1060
+ await reply(request, "Cannot switch agent while this context is busy.");
1061
+ return;
1062
+ }
1063
+ state.switching = true;
1064
+ try {
1065
+ const session = await registry.switchAgent(request.contextKey, requested);
1066
+ updateSession(request, session);
1067
+ appendActivity(request, { status: "info", type: "agent_switch", agentId: requested, detail: `Switched to ${agentLabel(requested)}.` });
1068
+ await reply(request, `Switched agent to ${agentLabel(requested)}.\n\n${renderSessionInfoPlain(session.getInfo())}`);
1069
+ }
1070
+ finally {
1071
+ state.switching = false;
1072
+ }
1073
+ return;
1074
+ }
1075
+ const pickId = createPick("agent", choices);
1076
+ await reply(request, "Select agent:", {
1077
+ buttons: choices.map((id, index) => [{ label: agentLabel(id), action: `discord_pick:${pickId}:${index}` }]),
1078
+ });
1079
+ };
1080
+ const commandAuth = async (request) => {
1081
+ const session = await getSession(request, { deferThreadStart: true });
1082
+ const info = session.getInfo();
1083
+ if (!capabilitiesOf(info).auth) {
1084
+ await deliverChannelAction(runtime, request.context, commandService.renderHostAuthInstruction(info.agentLabel, hostLoginCommand(info), "login"));
1085
+ return;
1086
+ }
1087
+ const status = await checkAgentAuthStatus(info);
1088
+ await deliverChannelAction(runtime, request.context, commandService.renderAuthStatus({
1089
+ label: info.agentLabel,
1090
+ authenticated: status.authenticated,
1091
+ method: status.method,
1092
+ detail: status.detail,
1093
+ }));
1094
+ };
1095
+ const commandLogin = async (request) => {
1096
+ const session = await getSession(request, { deferThreadStart: true });
1097
+ const info = session.getInfo();
1098
+ if (!capabilitiesOf(info).login) {
1099
+ await deliverChannelAction(runtime, request.context, commandService.renderHostAuthInstruction(info.agentLabel, hostLoginCommand(info), "login"));
1100
+ return;
1101
+ }
1102
+ const auth = await checkLoginAuthStatus(info);
1103
+ if (info.agentId !== "hermes" && auth.authenticated) {
1104
+ await reply(request, `${info.agentLabel} is already authenticated via ${auth.method ?? "unknown"}.`);
1105
+ return;
1106
+ }
1107
+ if (!config.enableTelegramLogin) {
1108
+ await reply(request, `Remote login is disabled. Run this on the host: ${hostLoginCommand(info)}`);
1109
+ return;
1110
+ }
1111
+ const result = await startAgentLogin(info);
1112
+ appendActivity(request, {
1113
+ status: result.success ? "info" : "failed",
1114
+ type: result.success ? "login_started" : "login_failed",
1115
+ threadId: info.threadId,
1116
+ workspace: info.workspace,
1117
+ agentId: info.agentId,
1118
+ detail: redactText(result.message),
1119
+ });
1120
+ await deliverChannelAction(runtime, request.context, commandService.renderAuthActionResult("login", {
1121
+ ...result,
1122
+ message: redactText(result.message),
1123
+ }));
1124
+ };
1125
+ const commandLogout = async (request) => {
1126
+ const session = await getSession(request, { deferThreadStart: true });
1127
+ const info = session.getInfo();
1128
+ if (!capabilitiesOf(info).logout) {
1129
+ await deliverChannelAction(runtime, request.context, commandService.renderHostAuthInstruction(info.agentLabel, hostLogoutCommand(info), "logout"));
1130
+ return;
1131
+ }
1132
+ const auth = await checkLoginAuthStatus(info);
1133
+ if (auth.method === "api-key") {
1134
+ await reply(request, `Cannot logout ${info.agentLabel} while API-key authentication is active. Remove the API key from .env to use CLI auth.`);
1135
+ return;
1136
+ }
1137
+ if (!config.enableTelegramLogin) {
1138
+ await reply(request, `Remote auth management is disabled. Run this on the host: ${hostLogoutCommand(info)}`);
1139
+ return;
1140
+ }
1141
+ if (info.agentId !== "hermes" && !auth.authenticated) {
1142
+ await reply(request, `${info.agentLabel} is not currently authenticated.`);
1143
+ return;
1144
+ }
1145
+ const result = await startAgentLogout(info);
1146
+ appendActivity(request, {
1147
+ status: result.success ? "info" : "failed",
1148
+ type: result.success ? "logout_completed" : "logout_failed",
1149
+ threadId: info.threadId,
1150
+ workspace: info.workspace,
1151
+ agentId: info.agentId,
1152
+ detail: redactText(result.message),
1153
+ });
1154
+ await deliverChannelAction(runtime, request.context, commandService.renderAuthActionResult("logout", {
1155
+ ...result,
1156
+ message: redactText(result.message),
1157
+ }));
1158
+ };
1159
+ const commandSession = async (request) => {
1160
+ const session = await getSession(request, { deferThreadStart: true });
1161
+ await reply(request, `Discord session:\n${renderSessionInfoPlain(session.getInfo())}`);
1162
+ };
1163
+ const commandSessions = async (request, query) => {
1164
+ const session = await getSession(request, { deferThreadStart: true });
1165
+ const records = session.listAllSessions(50).filter((record) => !query.trim() || [record.id, record.title, record.cwd, record.firstUserMessage].some((value) => value?.toLowerCase().includes(query.toLowerCase()))).slice(0, 10);
1166
+ if (records.length === 0) {
1167
+ await reply(request, "No sessions found.");
1168
+ return;
1169
+ }
1170
+ const pickId = createPick("session", records.map((record) => record.id));
1171
+ await reply(request, [
1172
+ "Sessions:",
1173
+ ...records.map((record, index) => `${index + 1}. ${record.title || record.id}\n ${record.id}\n ${record.cwd || "-"}`),
1174
+ ].join("\n"), {
1175
+ buttons: records.map((record, index) => [{ label: trimLine(record.title || record.id, 70), action: `discord_pick:${pickId}:${index}` }]),
1176
+ });
1177
+ };
1178
+ const commandNew = async (request, workspace) => {
1179
+ const session = await getSession(request, { deferThreadStart: true });
1180
+ if (getBusyReason(request.contextKey).busy) {
1181
+ await reply(request, "Cannot create a new thread while this context is busy.");
1182
+ return;
1183
+ }
1184
+ const workspaceValue = workspace.trim() || undefined;
1185
+ if (workspaceValue && !filterAllowedWorkspaces(session.listWorkspaces(), config).includes(workspaceValue)) {
1186
+ await reply(request, "Workspace is not allowed.");
1187
+ return;
1188
+ }
1189
+ const info = await session.newThread(workspaceValue);
1190
+ updateSession(request, session);
1191
+ appendActivity(request, { status: "info", type: "session_new", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId, detail: info.workspace });
1192
+ await reply(request, `New thread created.\n\n${renderSessionInfoPlain(info)}`);
1193
+ };
1194
+ const commandSwitch = async (request, threadId) => {
1195
+ if (!threadId.trim()) {
1196
+ await reply(request, "Usage: `/switch <thread-id>`");
1197
+ return;
1198
+ }
1199
+ const session = await getSession(request, { deferThreadStart: true });
1200
+ const info = await session.switchSession(threadId.trim());
1201
+ updateSession(request, session);
1202
+ appendActivity(request, { status: "info", type: "session_switch", threadId: info.threadId, workspace: info.workspace, agentId: info.agentId });
1203
+ await reply(request, `Switched session.\n\n${renderSessionInfoPlain(info)}`);
1204
+ };
1205
+ const commandModel = async (request, argument) => {
1206
+ const session = await getSession(request, { deferThreadStart: true });
1207
+ const info = session.getInfo();
1208
+ if (!capabilitiesOf(info).modelSelection) {
1209
+ await reply(request, `Model selection is not supported for ${info.agentLabel}.`);
1210
+ return;
1211
+ }
1212
+ if (argument.trim()) {
1213
+ await session.setModelForCurrentSession(argument.trim());
1214
+ updateSession(request, session);
1215
+ await reply(request, `Model set to ${argument.trim()}.\n\n${renderSessionInfoPlain(session.getInfo())}`);
1216
+ return;
1217
+ }
1218
+ await session.refreshModels({ force: true }).catch(() => { });
1219
+ const models = session.listModels().map((model) => model.slug).slice(0, MAX_SLASH_CHOICES);
1220
+ const pickId = createPick("model", models);
1221
+ await reply(request, "Select model:", {
1222
+ buttons: models.map((model, index) => [{ label: trimLine(model, 80), action: `discord_pick:${pickId}:${index}` }]),
1223
+ });
1224
+ };
1225
+ const commandReasoning = async (request, argument) => {
1226
+ const session = await getSession(request, { deferThreadStart: true });
1227
+ const options = agentReasoningOptions(session.getInfo().agentId);
1228
+ if (!options.length) {
1229
+ await reply(request, `${agentReasoningLabel(session.getInfo().agentId)} is not supported for ${session.getInfo().agentLabel}.`);
1230
+ return;
1231
+ }
1232
+ const requested = argument.trim();
1233
+ if (requested) {
1234
+ if (!options.includes(requested)) {
1235
+ await reply(request, `Invalid ${agentReasoningLabel(session.getInfo().agentId)}. Options: ${options.join(", ")}`);
1236
+ return;
1237
+ }
1238
+ await session.setReasoningEffortForCurrentSession(requested);
1239
+ updateSession(request, session);
1240
+ await reply(request, `${agentReasoningLabel(session.getInfo().agentId)} set to ${requested}.`);
1241
+ return;
1242
+ }
1243
+ const pickId = createPick("reasoning", options);
1244
+ await reply(request, `Select ${agentReasoningLabel(session.getInfo().agentId)}:`, {
1245
+ buttons: options.map((value, index) => [{ label: value, action: `discord_pick:${pickId}:${index}` }]),
1246
+ });
1247
+ };
1248
+ const commandFast = async (request, argument) => {
1249
+ const session = await getSession(request, { deferThreadStart: true });
1250
+ if (!capabilitiesOf(session.getInfo()).fastMode) {
1251
+ await reply(request, `Fast mode is not supported for ${session.getInfo().agentLabel}.`);
1252
+ return;
1253
+ }
1254
+ const normalized = argument.trim().toLowerCase();
1255
+ const enabled = normalized ? ["on", "true", "yes", "1"].includes(normalized) : !session.getInfo().fastMode;
1256
+ session.setFastMode(enabled);
1257
+ updateSession(request, session);
1258
+ await reply(request, `Fast mode ${enabled ? "on" : "off"}.`);
1259
+ };
1260
+ const commandLaunch = async (request, argument) => {
1261
+ const session = await getSession(request, { deferThreadStart: true });
1262
+ if (!capabilitiesOf(session.getInfo()).launchProfiles) {
1263
+ await reply(request, `Launch profiles are not supported for ${session.getInfo().agentLabel}.`);
1264
+ return;
1265
+ }
1266
+ const parts = argument.trim().split(/\s+/).filter(Boolean);
1267
+ const requested = parts[0] ?? "";
1268
+ const confirmed = parts.slice(1).some((part) => part.toLowerCase() === "confirm");
1269
+ if (requested) {
1270
+ const profile = session.listLaunchProfiles().find((candidate) => candidate.id === requested);
1271
+ if (!profile) {
1272
+ await reply(request, `Unknown launch profile: ${requested}`);
1273
+ return;
1274
+ }
1275
+ if (profile.unsafe && !confirmed) {
1276
+ await reply(request, [
1277
+ `Confirm launch profile: ${profile.label}`,
1278
+ `Behavior: ${profile.behavior}`,
1279
+ "",
1280
+ "WARNING: This profile uses danger-full-access.",
1281
+ `Run \`/launch ${profile.id} confirm\` to enable it for new or reattached threads in this Discord context.`,
1282
+ ].join("\n"));
1283
+ return;
1284
+ }
1285
+ session.setLaunchProfile(profile.id);
1286
+ updateSession(request, session);
1287
+ await reply(request, `Launch profile set to ${profile.label}.\nBehavior: ${profile.behavior}`);
1288
+ return;
1289
+ }
1290
+ const profiles = session.listLaunchProfiles();
1291
+ const pickId = createPick("launch", profiles.map((profile) => profile.id));
1292
+ await reply(request, "Select launch profile:", {
1293
+ buttons: profiles.map((profile, index) => [{ label: trimLine(profile.label || profile.id, 80), action: `discord_pick:${pickId}:${index}` }]),
1294
+ });
1295
+ };
1296
+ const commandQueue = async (request, argument) => {
1297
+ const [action, id] = argument.trim().split(/\s+/, 2);
1298
+ if (!action) {
1299
+ const queue = promptStore.list(request.contextKey);
1300
+ if (queue.length === 0) {
1301
+ await reply(request, promptStore.isPaused(request.contextKey) ? "Queue is paused and empty." : "Queue is empty.");
1302
+ return;
1303
+ }
1304
+ await deliverChannelAction(runtime, request.context, {
1305
+ ...renderQueueListAction(queue, promptStore.isPaused(request.contextKey)),
1306
+ buttons: queue.slice(0, 5).map((item) => [
1307
+ { label: `Run ${item.id}`, action: `discord_queue_run:${request.contextKey}:${item.id}` },
1308
+ { label: "Top", action: `discord_queue_top:${request.contextKey}:${item.id}` },
1309
+ { label: "Up", action: `discord_queue_up:${request.contextKey}:${item.id}` },
1310
+ { label: "Down", action: `discord_queue_down:${request.contextKey}:${item.id}` },
1311
+ { label: `Cancel ${item.id}`, action: `discord_queue_cancel:${request.contextKey}:${item.id}` },
1312
+ ]),
1313
+ });
1314
+ return;
1315
+ }
1316
+ if (action === "pause")
1317
+ promptStore.pause(request.contextKey);
1318
+ else if (action === "resume") {
1319
+ promptStore.resume(request.contextKey);
1320
+ await drainQueue(request);
1321
+ }
1322
+ else if (action === "clear")
1323
+ promptStore.clear(request.contextKey);
1324
+ else if (action === "cancel" && id)
1325
+ promptStore.remove(request.contextKey, id);
1326
+ else if (action === "top" && id)
1327
+ promptStore.moveToTop(request.contextKey, id);
1328
+ else if (action === "up" && id)
1329
+ promptStore.moveUp(request.contextKey, id);
1330
+ else if (action === "down" && id)
1331
+ promptStore.moveDown(request.contextKey, id);
1332
+ else if (action === "run" && id) {
1333
+ const item = promptStore.remove(request.contextKey, id);
1334
+ if (item) {
1335
+ await handlePrompt(request, item.input, item.artifactOutDir);
1336
+ return;
1337
+ }
1338
+ }
1339
+ else {
1340
+ await reply(request, "Usage: `/queue [pause|resume|clear|run <id>|cancel <id>|top <id>|up <id>|down <id>]`");
1341
+ return;
1342
+ }
1343
+ await reply(request, "Queue updated.");
1344
+ };
1345
+ const commandAbort = async (request) => {
1346
+ const session = await getSession(request, { deferThreadStart: true });
1347
+ const external = getExternalSnapshotForSession(session, config, { maxEvents: 0 });
1348
+ if (external?.activity.active && !session.isProcessing()) {
1349
+ await reply(request, `Cannot abort the external ${external.agentLabel} CLI task from NordRelay. Stop it in the terminal where it is running.`);
1350
+ return;
1351
+ }
1352
+ await session.abort();
1353
+ appendActivity(request, { status: "aborted", type: "prompt_aborted", threadId: session.getInfo().threadId, workspace: session.getInfo().workspace, agentId: session.getInfo().agentId });
1354
+ await reply(request, "Aborted current operation.");
1355
+ };
1356
+ const commandRetry = async (request) => {
1357
+ const cached = promptStore.getLastPrompt(request.contextKey);
1358
+ if (!cached) {
1359
+ await reply(request, "Nothing to retry. Send a message first.");
1360
+ return;
1361
+ }
1362
+ await handlePrompt(request, cached.input, cached.artifactOutDir);
1363
+ };
1364
+ const commandSync = async (request) => {
1365
+ const session = await getSession(request, { deferThreadStart: true });
1366
+ if (!capabilitiesOf(session.getInfo()).externalActivity) {
1367
+ await reply(request, `${session.getInfo().agentLabel} has no external state watcher.`);
1368
+ return;
1369
+ }
1370
+ const result = session.syncFromAgentState({ reattach: true });
1371
+ if (result.changed)
1372
+ updateSession(request, session);
1373
+ await reply(request, `Sync complete: ${result.changedFields.join(", ") || "already current"}.`);
1374
+ };
1375
+ const commandProgress = async (request) => {
1376
+ const session = await getSession(request, { deferThreadStart: true });
1377
+ const external = getExternalSnapshotForSession(session, config, { maxEvents: 0 });
1378
+ const state = getBusyState(request.contextKey);
1379
+ await deliverChannelAction(runtime, request.context, commandService.renderProgress(turnProgress.get(request.contextKey), promptStore.list(request.contextKey).length, {
1380
+ processing: state.processing || session.isProcessing(),
1381
+ switching: state.switching,
1382
+ transcribing: false,
1383
+ approving: false,
1384
+ external: Boolean(external?.activity.active),
1385
+ }, session.getInfo()));
1386
+ };
1387
+ const commandActivity = async (request, argument) => {
1388
+ const session = await getSession(request, { deferThreadStart: true });
1389
+ const info = session.getInfo();
1390
+ if (!capabilitiesOf(info).activityLog) {
1391
+ await reply(request, `${info.agentLabel} activity timelines are not available yet.`);
1392
+ return;
1393
+ }
1394
+ const threadId = session.getActiveThreadId();
1395
+ if (!threadId) {
1396
+ await reply(request, "No active thread yet.");
1397
+ return;
1398
+ }
1399
+ const options = parseActivityOptions(argument);
1400
+ const events = filterActivityEvents(getAgentActivityLog(session, config, options.exportFile ? 200 : options.limit), options);
1401
+ await deliverChannelAction(runtime, request.context, commandService.renderActivity(threadId, events, options));
1402
+ };
1403
+ const commandAudit = async (request, argument) => {
1404
+ const limit = Math.max(1, Math.min(100, Number.parseInt(argument, 10) || 20));
1405
+ await deliverChannelAction(runtime, request.context, commandService.renderAudit(auditLog.list(limit)));
1406
+ };
1407
+ const commandLogs = async (request, argument) => {
1408
+ await deliverChannelAction(runtime, request.context, await commandService.renderLogs(argument));
1409
+ };
1410
+ const commandVersion = async (request) => {
1411
+ await deliverChannelAction(runtime, request.context, await commandService.renderVersion());
1412
+ };
1413
+ const commandDiagnostics = async (request) => {
1414
+ const session = await getSession(request, { deferThreadStart: true });
1415
+ const external = getExternalSnapshotForSession(session, config, { maxEvents: 3 });
1416
+ const rateLimit = getDiscordRateLimitMetrics();
1417
+ await reply(request, [
1418
+ "Diagnostics:",
1419
+ `Context: ${request.contextKey}`,
1420
+ `Channel: ${request.guildId || "DM"} / ${request.channelId}`,
1421
+ `Agent: ${session.getInfo().agentLabel}`,
1422
+ `Thread: ${session.getInfo().threadId || "-"}`,
1423
+ `Workspace: ${session.getInfo().workspace}`,
1424
+ `Queue: ${promptStore.list(request.contextKey).length}${promptStore.isPaused(request.contextKey) ? " paused" : ""}`,
1425
+ `External: ${external?.activity.active ? "active" : "idle"}`,
1426
+ `Discord rate limit: queued ${rateLimit.queued}, running ${rateLimit.running}, retries ${rateLimit.retries}`,
1427
+ ].join("\n"));
1428
+ };
1429
+ const commandUpdate = async (request, argument) => {
1430
+ const tokens = argument.trim().split(/\s+/).filter(Boolean);
1431
+ const [target, second] = tokens;
1432
+ if (!target) {
1433
+ const update = spawnSelfUpdate();
1434
+ await reply(request, `NordRelay update started with ${update.method}. Log: ${update.logPath}`);
1435
+ return;
1436
+ }
1437
+ if (target === "agents" || target === "agent") {
1438
+ await deliverChannelAction(runtime, request.context, renderAgentUpdatePickerAction(listAgentAdapterDescriptors()));
1439
+ return;
1440
+ }
1441
+ if (target === "jobs") {
1442
+ await deliverChannelAction(runtime, request.context, renderAgentUpdateJobsAction(agentUpdates.list()));
1443
+ return;
1444
+ }
1445
+ if (target === "log" && second) {
1446
+ await deliverChannelAction(runtime, request.context, renderAgentUpdateLogAction(agentUpdates.readLog(second)));
1447
+ return;
1448
+ }
1449
+ if (target === "cancel" && second) {
1450
+ await deliverChannelAction(runtime, request.context, renderAgentUpdateJobAction(agentUpdates.cancel(second)));
1451
+ appendActivity(request, {
1452
+ status: "info",
1453
+ type: "agent_update_cancel_requested",
1454
+ workspace: config.workspace,
1455
+ detail: second,
1456
+ });
1457
+ return;
1458
+ }
1459
+ if (target === "input" && second) {
1460
+ const input = tokens.slice(2).join(" ");
1461
+ if (!input.trim()) {
1462
+ await reply(request, "Usage: `/update input <job-id> <text>`");
1463
+ return;
1464
+ }
1465
+ await deliverChannelAction(runtime, request.context, renderAgentUpdateJobAction(agentUpdates.sendInput(second, input)));
1466
+ appendActivity(request, {
1467
+ status: "info",
1468
+ type: "agent_update_input_sent",
1469
+ workspace: config.workspace,
1470
+ detail: second,
1471
+ });
1472
+ return;
1473
+ }
1474
+ const operation = target === "install" ? "install" : "update";
1475
+ const agentId = (operation === "install" ? second : target);
1476
+ if (!enabledAgents(config).includes(agentId) && !listAgentAdapterDescriptors().some((descriptor) => descriptor.id === agentId)) {
1477
+ await reply(request, "Unknown agent.");
1478
+ return;
1479
+ }
1480
+ const job = agentUpdates.start(agentId, {
1481
+ piCliPath: config.piCliPath,
1482
+ hermesCliPath: config.hermesCliPath,
1483
+ openClawCliPath: config.openClawCliPath,
1484
+ claudeCodeCliPath: config.claudeCodeCliPath,
1485
+ }, operation);
1486
+ await deliverChannelAction(runtime, request.context, renderAgentUpdateJobAction(job));
1487
+ };
1488
+ const commandLock = async (request) => {
1489
+ const actor = actorFor(request);
1490
+ lockStore.set(request.contextKey, {
1491
+ userId: request.authUser?.user.id ?? request.user.id,
1492
+ label: actor.label,
1493
+ channel: "discord",
1494
+ channelUserId: request.user.id,
1495
+ }, config.sessionLockTtlMs);
1496
+ await reply(request, "Session locked to you.");
1497
+ };
1498
+ const commandRestart = async (request) => {
1499
+ spawnConnectorRestart();
1500
+ appendActivity(request, {
1501
+ status: "info",
1502
+ type: "connector_restart_requested",
1503
+ workspace: config.workspace,
1504
+ detail: "Discord restart command",
1505
+ });
1506
+ await reply(request, "Restarting connector. Discord may disconnect briefly.");
1507
+ };
1508
+ const commandWorkspaces = async (request) => {
1509
+ const session = await getSession(request, { deferThreadStart: true });
1510
+ const info = session.getInfo();
1511
+ if (!capabilitiesOf(info).workspaces) {
1512
+ await reply(request, `${info.agentLabel} workspace listing is not supported.`);
1513
+ return;
1514
+ }
1515
+ await deliverChannelAction(runtime, request.context, commandService.renderWorkspaces(info, filterAllowedWorkspaces(session.listWorkspaces(), config)));
1516
+ };
1517
+ const commandPin = async (request, argument) => {
1518
+ const session = await getSession(request, { deferThreadStart: true });
1519
+ const threadId = argument.trim() || session.getActiveThreadId();
1520
+ if (!threadId) {
1521
+ await reply(request, "No active thread to pin. Use `/pin <thread-id>`.");
1522
+ return;
1523
+ }
1524
+ if (!session.getSessionRecord(threadId)) {
1525
+ await reply(request, `Unknown ${session.getInfo().agentLabel} session: ${threadId}`);
1526
+ return;
1527
+ }
1528
+ const pinned = registry.pinThread(request.contextKey, threadId);
1529
+ appendActivity(request, {
1530
+ status: "info",
1531
+ type: "session_pinned",
1532
+ threadId,
1533
+ workspace: session.getSessionRecord(threadId)?.cwd ?? session.getInfo().workspace,
1534
+ agentId: session.getInfo().agentId,
1535
+ detail: threadId,
1536
+ });
1537
+ await reply(request, `Pinned thread: ${threadId}\nTotal pinned: ${pinned.length}`);
1538
+ };
1539
+ const commandUnpin = async (request, argument) => {
1540
+ const session = await getSession(request, { deferThreadStart: true });
1541
+ const threadId = argument.trim() || session.getActiveThreadId();
1542
+ if (!threadId) {
1543
+ await reply(request, "No active thread to unpin. Use `/unpin <thread-id>`.");
1544
+ return;
1545
+ }
1546
+ const pinned = registry.unpinThread(request.contextKey, threadId);
1547
+ appendActivity(request, {
1548
+ status: "info",
1549
+ type: "session_unpinned",
1550
+ threadId,
1551
+ workspace: session.getInfo().workspace,
1552
+ agentId: session.getInfo().agentId,
1553
+ detail: threadId,
1554
+ });
1555
+ await reply(request, `Unpinned thread: ${threadId}\nTotal pinned: ${pinned.length}`);
1556
+ };
1557
+ const commandPinned = async (request) => {
1558
+ const session = await getSession(request, { deferThreadStart: true });
1559
+ const pinned = registry.listPinnedThreadIds(request.contextKey);
1560
+ const records = pinned
1561
+ .map((threadId) => session.getSessionRecord(threadId))
1562
+ .filter((record) => Boolean(record));
1563
+ if (records.length === 0) {
1564
+ await reply(request, "No pinned threads.");
1565
+ return;
1566
+ }
1567
+ const pickId = createPick("session", records.map((record) => record.id));
1568
+ await reply(request, [
1569
+ `Pinned threads (${records.length}):`,
1570
+ ...records.map((record, index) => `${index + 1}. ${record.title || record.id}\n ${record.id}\n ${record.cwd || "-"}`),
1571
+ ].join("\n"), {
1572
+ buttons: records.map((record, index) => [{ label: trimLine(record.title || record.id, 70), action: `discord_pick:${pickId}:${index}` }]),
1573
+ });
1574
+ };
1575
+ const commandHandback = async (request) => {
1576
+ const session = await getSession(request, { deferThreadStart: true });
1577
+ if (getBusyReason(request.contextKey).busy) {
1578
+ await reply(request, "Cannot hand back while a prompt is running. Use `/stop` first.");
1579
+ return;
1580
+ }
1581
+ if (!session.hasActiveThread()) {
1582
+ await reply(request, "No active thread to hand back.");
1583
+ return;
1584
+ }
1585
+ const result = session.handback();
1586
+ updateSession(request, session);
1587
+ appendActivity(request, {
1588
+ status: "info",
1589
+ type: "handback",
1590
+ threadId: result.threadId,
1591
+ workspace: result.workspace,
1592
+ agentId: session.getInfo().agentId,
1593
+ detail: result.command ?? result.threadId ?? "handback",
1594
+ });
1595
+ await deliverChannelAction(runtime, request.context, commandService.renderHandback(result));
1596
+ };
1597
+ const commandMirror = async (request, argument) => {
1598
+ const mode = parseMirrorMode(argument, preferencesStore.get(request.contextKey).mirrorMode ?? config.discordMirrorMode);
1599
+ preferencesStore.update(request.contextKey, { mirrorMode: mode });
1600
+ await reply(request, `CLI mirror mode: ${mode}`);
1601
+ };
1602
+ const commandNotify = async (request, argument) => {
1603
+ const mode = parseNotifyMode(argument, preferencesStore.get(request.contextKey).notifyMode ?? config.discordNotifyMode);
1604
+ preferencesStore.update(request.contextKey, { notifyMode: mode });
1605
+ await reply(request, `Notify mode: ${mode}`);
1606
+ };
1607
+ const commandVoice = async (request, argument) => {
1608
+ const normalized = argument.trim().toLowerCase();
1609
+ const parts = normalized.split(/\s+/).filter(Boolean);
1610
+ if (parts[0] === "backend" && parts[1]) {
1611
+ preferencesStore.update(request.contextKey, { voiceBackend: parseVoiceBackendPreference(parts[1]) });
1612
+ }
1613
+ else if (parts[0] === "language" && parts[1]) {
1614
+ preferencesStore.update(request.contextKey, { voiceLanguage: parts[1] === "auto" ? null : parts[1] });
1615
+ }
1616
+ else if ((parts[0] === "transcribe-only" || parts[0] === "transcribe_only") && parts[1]) {
1617
+ preferencesStore.update(request.contextKey, { voiceTranscribeOnly: ["on", "true", "yes", "1"].includes(parts[1]) });
1618
+ }
1619
+ else if (argument.trim()) {
1620
+ await reply(request, "Usage: `/voice`, `/voice backend auto|parakeet|faster-whisper|openai`, `/voice language auto|<code>`, or `/voice transcribe_only on|off`.");
1621
+ return;
1622
+ }
1623
+ const prefs = preferencesStore.get(request.contextKey);
1624
+ await reply(request, `Voice backend: ${prefs.voiceBackend ?? config.voicePreferredBackend}\nLanguage: ${prefs.voiceLanguage ?? config.voiceDefaultLanguage ?? "auto"}\nTranscribe only: ${prefs.voiceTranscribeOnly ?? config.voiceTranscribeOnly}`);
1625
+ };
1626
+ const commandRegisterChannel = async (request) => {
1627
+ const channel = userStore.registerDiscordChannel({
1628
+ guildId: request.guildId,
1629
+ channelId: request.channelId,
1630
+ title: request.channelName,
1631
+ type: request.isDirectMessage ? "dm" : "guild",
1632
+ enabled: true,
1633
+ });
1634
+ audit(request, { action: "discord_channel_updated", status: "ok", description: channel.channelId });
1635
+ await reply(request, `Discord channel registered: ${channel.title || channel.channelId}`);
1636
+ };
1637
+ const commandLink = async (request, code) => {
1638
+ if (!userStore.hasAdminUser()) {
1639
+ await reply(request, "NordRelay has no admin user yet. Run `nordrelay user create-admin` on the host.", { ephemeral: true });
1640
+ return;
1641
+ }
1642
+ try {
1643
+ const linked = userStore.consumeDiscordLinkCode(code, {
1644
+ discordUserId: request.user.id,
1645
+ username: request.user.username,
1646
+ globalName: request.user.globalName ?? undefined,
1647
+ });
1648
+ request.authUser = linked;
1649
+ audit(request, { action: "discord_linked", status: "ok", description: request.user.id });
1650
+ await reply(request, `Linked Discord account to ${linked.user.email}.`, { ephemeral: true });
1651
+ }
1652
+ catch (error) {
1653
+ await reply(request, `Link failed: ${friendlyErrorText(error)}`, { ephemeral: true });
1654
+ }
1655
+ };
1656
+ const handleAttachments = async (request, message, text) => {
1657
+ const session = await getSession(request);
1658
+ const workspace = session.getInfo().workspace;
1659
+ const turnId = randomUUID().slice(0, 12);
1660
+ const outDir = outboxPath(workspace, turnId);
1661
+ await ensureOutDir(outDir);
1662
+ const stagedFiles = [];
1663
+ const imagePaths = [];
1664
+ const transcripts = [];
1665
+ for (const attachment of message.attachments.values()) {
1666
+ if (attachment.size > Math.min(config.maxFileSize, MAX_ATTACHMENT_DOWNLOAD)) {
1667
+ await reply(request, `Skipped ${attachment.name || attachment.id}: file is too large.`);
1668
+ continue;
1669
+ }
1670
+ const response = await fetch(attachment.url);
1671
+ if (!response.ok) {
1672
+ throw new Error(`Failed to download ${attachment.name || attachment.id}: ${response.status}`);
1673
+ }
1674
+ const buffer = Buffer.from(await response.arrayBuffer());
1675
+ const mimeType = attachment.contentType || inferMimeType(attachment.name || "attachment");
1676
+ const staged = await stageFile(buffer, attachment.name || `discord-${attachment.id}`, mimeType, {
1677
+ workspace,
1678
+ turnId,
1679
+ maxFileSize: config.maxFileSize,
1680
+ });
1681
+ stagedFiles.push(staged);
1682
+ if (mimeType.startsWith("image/"))
1683
+ imagePaths.push(staged.localPath);
1684
+ if (mimeType.startsWith("audio/")) {
1685
+ const result = await transcribeAudio(staged.localPath, {
1686
+ preferredBackend: config.voicePreferredBackend === "auto" ? undefined : config.voicePreferredBackend,
1687
+ language: config.voiceDefaultLanguage,
1688
+ });
1689
+ if (result.text.trim())
1690
+ transcripts.push(`Audio transcript (${staged.safeName}, via ${result.backend}):\n${result.text.trim()}`);
1691
+ }
1692
+ }
1693
+ const audioOnly = stagedFiles.length > 0 && stagedFiles.every((file) => file.mimeType.startsWith("audio/"));
1694
+ if ((preferencesStore.get(request.contextKey).voiceTranscribeOnly ?? config.voiceTranscribeOnly) && audioOnly && !text.trim()) {
1695
+ await reply(request, transcripts.join("\n\n") || "No transcript produced.");
1696
+ return;
1697
+ }
1698
+ const prompt = {};
1699
+ const textParts = [text.trim(), ...transcripts].filter(Boolean);
1700
+ if (textParts.length)
1701
+ prompt.text = textParts.join("\n\n");
1702
+ if (imagePaths.length)
1703
+ prompt.imagePaths = imagePaths;
1704
+ if (stagedFiles.length)
1705
+ prompt.stagedFileInstructions = buildFileInstructions(stagedFiles, outDir);
1706
+ await handlePrompt(request, prompt, outDir);
1707
+ };
1708
+ const handleMessage = async (message) => {
1709
+ if (message.author.bot)
1710
+ return;
1711
+ const request = requestFromMessage(message);
1712
+ const text = message.content.trim();
1713
+ const parsed = parseDiscordMessageCommand(text);
1714
+ if (parsed) {
1715
+ if (config.discordCommandMode === "slash")
1716
+ return;
1717
+ await handleCommand(request, parsed.command, parsed.argument);
1718
+ return;
1719
+ }
1720
+ if (!config.discordMessageContentEnabled && message.attachments.size === 0) {
1721
+ return;
1722
+ }
1723
+ const permission = message.attachments.size > 0 ? "files.write" : "prompt.send";
1724
+ if (!await authenticate(request, permission))
1725
+ return;
1726
+ if (message.attachments.size > 0) {
1727
+ await handleAttachments(request, message, text);
1728
+ return;
1729
+ }
1730
+ if (text) {
1731
+ await handlePrompt(request, text);
1732
+ }
1733
+ };
1734
+ const handleInteraction = async (interaction) => {
1735
+ if (interaction.isChatInputCommand()) {
1736
+ if (config.discordCommandMode === "message")
1737
+ return;
1738
+ const request = requestFromInteraction(interaction);
1739
+ const argument = argumentFromDiscordInteraction(interaction);
1740
+ await handleCommand(request, interaction.commandName, argument);
1741
+ return;
1742
+ }
1743
+ if (!interaction.isButton()) {
1744
+ return;
1745
+ }
1746
+ const action = actionFromDiscordCustomId(interaction.customId);
1747
+ if (!action)
1748
+ return;
1749
+ const request = requestFromInteraction(interaction);
1750
+ if (!await authenticate(request, permissionForDiscordAction(action)))
1751
+ return;
1752
+ await handleButtonAction(request, action);
1753
+ };
1754
+ const handleButtonAction = async (request, action) => {
1755
+ if (request.interaction?.isButton()) {
1756
+ await request.interaction.deferUpdate().catch(() => { });
1757
+ }
1758
+ const pickMatch = action.match(/^discord_pick:([^:]+):(\d+)$/);
1759
+ if (pickMatch?.[1]) {
1760
+ const pick = picks.get(pickMatch[1]);
1761
+ const index = Number.parseInt(pickMatch[2] ?? "", 10);
1762
+ const value = pick?.values[index];
1763
+ if (!pick || !value) {
1764
+ await reply(request, "Selection expired.", { ephemeral: true });
1765
+ return;
1766
+ }
1767
+ if (pick.kind === "agent")
1768
+ await commandAgent(request, value);
1769
+ else if (pick.kind === "session")
1770
+ await commandSwitch(request, value);
1771
+ else if (pick.kind === "model")
1772
+ await commandModel(request, value);
1773
+ else if (pick.kind === "reasoning")
1774
+ await commandReasoning(request, value);
1775
+ else if (pick.kind === "launch")
1776
+ await commandLaunch(request, value);
1777
+ return;
1778
+ }
1779
+ const queueMatch = action.match(/^discord_queue_(run|cancel|top|up|down):(.+):([^:]+)$/);
1780
+ if (queueMatch?.[1] && queueMatch[2] === request.contextKey) {
1781
+ await commandQueue(request, `${queueMatch[1]} ${queueMatch[3]}`);
1782
+ return;
1783
+ }
1784
+ const artifactMatch = action.match(/^discord_artifact_(send|zip|delete):(.+):([^:]+)$/);
1785
+ if (artifactMatch?.[1] && artifactMatch[2] === request.contextKey) {
1786
+ await commandArtifacts(request, `${artifactMatch[1]} ${artifactMatch[3]}`);
1787
+ return;
1788
+ }
1789
+ const updateMatch = action.match(/^agent-update:(start|log|cancel):(.+)$/);
1790
+ if (updateMatch?.[1]) {
1791
+ const updateAction = updateMatch[1];
1792
+ const value = updateMatch[2] ?? "";
1793
+ if (updateAction === "start")
1794
+ await commandUpdate(request, value);
1795
+ else
1796
+ await commandUpdate(request, `${updateAction} ${value}`);
1797
+ return;
1798
+ }
1799
+ if (action === "agent-update:jobs") {
1800
+ await commandUpdate(request, "jobs");
1801
+ return;
1802
+ }
1803
+ const abortMatch = action.match(/^discord_abort:(.+)$/);
1804
+ if (abortMatch?.[1] === request.contextKey) {
1805
+ await commandAbort(request);
1806
+ return;
1807
+ }
1808
+ };
1809
+ const createPick = (kind, values) => {
1810
+ const id = randomUUID().replace(/-/g, "").slice(0, 10);
1811
+ picks.set(id, { kind, values });
1812
+ setTimeout(() => picks.delete(id), 10 * 60 * 1000).unref?.();
1813
+ return id;
1814
+ };
1815
+ const monitorExternalContexts = async () => {
1816
+ const keys = new Set([
1817
+ ...registry.listContexts().map((context) => context.contextKey),
1818
+ ...promptStore.listContextKeys(),
1819
+ ].filter(isDiscordContextKey));
1820
+ for (const contextKey of keys) {
1821
+ const parsed = parseDiscordContextKey(contextKey);
1822
+ if (!parsed)
1823
+ continue;
1824
+ if (!canSendSystemMessagesToDiscordContext(userStore, contextKey)) {
1825
+ continue;
1826
+ }
1827
+ const guildId = parsed.guildId?.startsWith("dm-") ? undefined : parsed.guildId;
1828
+ if (!isDiscordGuildAllowed(guildId) || !isDiscordChannelAllowedByEnv(parsed.channelId)) {
1829
+ continue;
1830
+ }
1831
+ const session = await registry.getOrCreate(contextKey, { deferThreadStart: true }).catch(() => null);
1832
+ if (!session)
1833
+ continue;
1834
+ const context = {
1835
+ channelId: "discord",
1836
+ chatId: parsed.threadId ?? parsed.channelId,
1837
+ topicId: parsed.threadId,
1838
+ };
1839
+ const snapshot = getExternalSnapshotForSession(session, config, { maxEvents: 1 });
1840
+ const previous = externalMirrors.get(contextKey);
1841
+ const mirrorSnapshot = snapshot
1842
+ ? getExternalSnapshotForSession(session, config, {
1843
+ afterLine: previous?.lastLine ?? Number.MAX_SAFE_INTEGER,
1844
+ }) ?? snapshot
1845
+ : null;
1846
+ if (mirrorSnapshot && !session.isProcessing()) {
1847
+ await mirrorExternalSnapshot(contextKey, context, session, mirrorSnapshot);
1848
+ }
1849
+ if (mirrorSnapshot?.activity.active) {
1850
+ if (promptStore.list(contextKey).length > 0) {
1851
+ await updateQueueStatusMessage(contextKey, context, `Waiting for ${mirrorSnapshot.agentLabel} CLI task... ${promptStore.list(contextKey).length} queued${promptStore.isPaused(contextKey) ? " (paused)" : ""}.`).catch(() => { });
1852
+ }
1853
+ continue;
1854
+ }
1855
+ if (promptStore.list(contextKey).length > 0 && !promptStore.isPaused(contextKey) && !session.isProcessing()) {
1856
+ await updateQueueStatusMessage(contextKey, context, `CLI task finished, running queued prompt 1/${promptStore.list(contextKey).length}.`).catch(() => { });
1857
+ const systemRequest = {
1858
+ contextKey,
1859
+ context,
1860
+ user: { id: "system", username: "system", bot: true },
1861
+ channelId: parsed.threadId ?? parsed.channelId,
1862
+ guildId: parsed.guildId,
1863
+ isDirectMessage: !parsed.guildId || parsed.guildId.startsWith("dm-"),
1864
+ source: "message",
1865
+ };
1866
+ await drainQueue(systemRequest);
1867
+ }
1868
+ }
1869
+ };
1870
+ const registerSlashCommands = async () => {
1871
+ if (!config.discordClientId || !config.discordAutoRegisterCommands || config.discordCommandMode === "message" || !config.discordBotToken) {
1872
+ return;
1873
+ }
1874
+ const rest = new REST({ version: "10" }).setToken(config.discordBotToken);
1875
+ const commands = discordCommands();
1876
+ if (config.discordGuildIds.length > 0) {
1877
+ for (const guildId of config.discordGuildIds) {
1878
+ await rest.put(Routes.applicationGuildCommands(config.discordClientId, guildId), { body: commands });
1879
+ }
1880
+ console.log(`Discord slash commands registered for ${config.discordGuildIds.length} guild(s).`);
1881
+ return;
1882
+ }
1883
+ await rest.put(Routes.applicationCommands(config.discordClientId), { body: commands });
1884
+ console.log("Discord global slash commands registered.");
1885
+ };
1886
+ client.on(Events.MessageCreate, (message) => {
1887
+ void handleMessage(message).catch((error) => {
1888
+ console.error("Discord message handling failed:", error);
1889
+ });
1890
+ });
1891
+ client.on(Events.InteractionCreate, (interaction) => {
1892
+ void handleInteraction(interaction).catch((error) => {
1893
+ console.error("Discord interaction handling failed:", error);
1894
+ });
1895
+ });
1896
+ client.once(Events.ClientReady, (readyClient) => {
1897
+ console.log(`Discord bot ready as ${readyClient.user.tag}`);
1898
+ void registerSlashCommands().catch((error) => {
1899
+ console.error("Failed to register Discord slash commands:", error);
1900
+ });
1901
+ });
1902
+ return {
1903
+ client,
1904
+ async start() {
1905
+ await client.login(config.discordBotToken);
1906
+ externalMonitor = setInterval(() => {
1907
+ void monitorExternalContexts().catch((error) => console.error("Failed to monitor Discord external activity:", error));
1908
+ }, config.codexExternalBusyCheckMs);
1909
+ externalMonitor.unref?.();
1910
+ },
1911
+ async stop() {
1912
+ if (externalMonitor)
1913
+ clearInterval(externalMonitor);
1914
+ agentUpdates.cancelAll();
1915
+ await client.destroy();
1916
+ },
1917
+ };
1918
+ function requestFromMessage(message) {
1919
+ const threadId = message.channel.isThread() ? message.channel.id : undefined;
1920
+ const parentId = message.channel.isThread() ? message.channel.parentId ?? message.channel.id : message.channel.id;
1921
+ const channelName = "name" in message.channel && typeof message.channel.name === "string" ? message.channel.name : undefined;
1922
+ const guildKey = message.guildId ?? `dm-${message.author.id}`;
1923
+ return {
1924
+ contextKey: discordContextKey({ guildId: guildKey, channelId: parentId, threadId }),
1925
+ context: {
1926
+ channelId: "discord",
1927
+ chatId: threadId ?? parentId,
1928
+ ...(threadId ? { topicId: threadId } : {}),
1929
+ userId: message.author.id,
1930
+ username: message.author.username,
1931
+ },
1932
+ user: message.author,
1933
+ username: message.author.username,
1934
+ guildId: message.guildId ?? undefined,
1935
+ channelId: parentId,
1936
+ channelName,
1937
+ isDirectMessage: !message.guildId,
1938
+ source: "message",
1939
+ message,
1940
+ };
1941
+ }
1942
+ function requestFromInteraction(interaction) {
1943
+ const channel = interaction.channel;
1944
+ const threadId = channel?.isThread() ? channel.id : undefined;
1945
+ const parentId = channel?.isThread() ? channel.parentId ?? channel.id : interaction.channelId;
1946
+ const channelName = channel && "name" in channel && typeof channel.name === "string" ? channel.name : undefined;
1947
+ const guildKey = interaction.guildId ?? `dm-${interaction.user.id}`;
1948
+ return {
1949
+ contextKey: discordContextKey({ guildId: guildKey, channelId: parentId, threadId }),
1950
+ context: {
1951
+ channelId: "discord",
1952
+ chatId: threadId ?? parentId,
1953
+ ...(threadId ? { topicId: threadId } : {}),
1954
+ userId: interaction.user.id,
1955
+ username: interaction.user.username,
1956
+ },
1957
+ user: interaction.user,
1958
+ username: interaction.user.username,
1959
+ guildId: interaction.guildId ?? undefined,
1960
+ channelId: parentId,
1961
+ channelName,
1962
+ isDirectMessage: !interaction.guildId,
1963
+ source: "interaction",
1964
+ interaction,
1965
+ };
1966
+ }
1967
+ function isDiscordGuildAllowed(guildId) {
1968
+ return !guildId || config.discordAllowedGuildIds.length === 0 || config.discordAllowedGuildIds.includes(guildId);
1969
+ }
1970
+ function isDiscordChannelAllowedByEnv(channelId) {
1971
+ return config.discordAllowedChannelIds.length === 0 || config.discordAllowedChannelIds.includes(channelId);
1972
+ }
1973
+ }
1974
+ export function canSendSystemMessagesToDiscordContext(userStore, contextKey) {
1975
+ if (!userStore.hasAdminUser()) {
1976
+ return false;
1977
+ }
1978
+ const parsed = parseDiscordContextKey(contextKey);
1979
+ if (!parsed) {
1980
+ return false;
1981
+ }
1982
+ if (!parsed.guildId || parsed.guildId.startsWith("dm-")) {
1983
+ const userId = parsed.guildId?.startsWith("dm-") ? parsed.guildId.slice(3) : undefined;
1984
+ return Boolean(userId && userStore.resolveDiscordUser(userId));
1985
+ }
1986
+ return userStore.snapshot().discordChannels.some((channel) => channel.enabled &&
1987
+ channel.channelId === parsed.channelId &&
1988
+ (channel.guildId ?? "") === (parsed.guildId ?? ""));
1989
+ }
1990
+ function inferMimeType(name) {
1991
+ const lower = name.toLowerCase();
1992
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
1993
+ return "image/jpeg";
1994
+ if (lower.endsWith(".png"))
1995
+ return "image/png";
1996
+ if (lower.endsWith(".gif"))
1997
+ return "image/gif";
1998
+ if (lower.endsWith(".webp"))
1999
+ return "image/webp";
2000
+ if (lower.endsWith(".mp3"))
2001
+ return "audio/mpeg";
2002
+ if (lower.endsWith(".wav"))
2003
+ return "audio/wav";
2004
+ if (lower.endsWith(".ogg") || lower.endsWith(".oga"))
2005
+ return "audio/ogg";
2006
+ if (lower.endsWith(".m4a"))
2007
+ return "audio/mp4";
2008
+ if (lower.endsWith(".webm"))
2009
+ return "audio/webm";
2010
+ return "application/octet-stream";
2011
+ }
2012
+ function isQueuedPrompt(value) {
2013
+ return Boolean(value && typeof value === "object" && "id" in value && "contextKey" in value);
2014
+ }