@nordbyte/nordrelay 0.6.0 → 0.8.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 (62) hide show
  1. package/.env.example +52 -0
  2. package/README.md +171 -50
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/adapter-conformance.js +61 -0
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot.js +95 -37
  8. package/dist/channel-adapter.js +44 -11
  9. package/dist/channel-command-catalog.js +94 -0
  10. package/dist/channel-command-core.js +60 -0
  11. package/dist/channel-command-service.js +230 -1
  12. package/dist/channel-mirror-registry.js +84 -0
  13. package/dist/channel-peer-prompt.js +95 -0
  14. package/dist/channel-prompt-engine.js +177 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-lifecycle.js +73 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +82 -8
  19. package/dist/config.js +79 -7
  20. package/dist/context-key.js +42 -0
  21. package/dist/discord-bot.js +173 -342
  22. package/dist/discord-command-surface.js +11 -73
  23. package/dist/index.js +29 -0
  24. package/dist/metrics.js +48 -0
  25. package/dist/peer-auth.js +85 -0
  26. package/dist/peer-client.js +288 -0
  27. package/dist/peer-context.js +21 -0
  28. package/dist/peer-identity.js +127 -0
  29. package/dist/peer-readiness.js +77 -0
  30. package/dist/peer-runtime-service.js +658 -0
  31. package/dist/peer-server.js +220 -0
  32. package/dist/peer-store.js +307 -0
  33. package/dist/peer-types.js +52 -0
  34. package/dist/relay-runtime-helpers.js +210 -0
  35. package/dist/relay-runtime.js +79 -274
  36. package/dist/remote-prompt.js +98 -0
  37. package/dist/settings-wizard-test.js +216 -0
  38. package/dist/slack-artifacts.js +165 -0
  39. package/dist/slack-bot.js +1461 -0
  40. package/dist/slack-channel-runtime.js +147 -0
  41. package/dist/slack-command-surface.js +46 -0
  42. package/dist/slack-diagnostics.js +116 -0
  43. package/dist/slack-rate-limit.js +139 -0
  44. package/dist/telegram-command-menu.js +3 -53
  45. package/dist/telegram-general-commands.js +14 -0
  46. package/dist/telegram-preference-commands.js +23 -127
  47. package/dist/user-management-crypto.js +38 -0
  48. package/dist/user-management-normalize.js +188 -0
  49. package/dist/user-management-types.js +1 -0
  50. package/dist/user-management.js +193 -196
  51. package/dist/web-api-contract.js +16 -0
  52. package/dist/web-dashboard-access-routes.js +62 -0
  53. package/dist/web-dashboard-assets.js +1 -0
  54. package/dist/web-dashboard-pages.js +26 -4
  55. package/dist/web-dashboard-peer-routes.js +225 -0
  56. package/dist/web-dashboard-ui.js +1 -0
  57. package/dist/web-dashboard.js +46 -0
  58. package/dist/web-state.js +2 -2
  59. package/dist/webui-assets/dashboard.css +193 -0
  60. package/dist/webui-assets/dashboard.js +870 -57
  61. package/package.json +5 -2
  62. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
@@ -0,0 +1,61 @@
1
+ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
2
+ import { agentFeatureStates } from "./agent-feature-matrix.js";
3
+ import { listChannelDescriptors, } from "./channel-adapter.js";
4
+ import { channelCatalogCommandNames, } from "./channel-command-core.js";
5
+ export const CHANNEL_FEATURES = [
6
+ { key: "text", label: "Text", description: "Send and receive plain text prompts and replies." },
7
+ { key: "streaming-edits", label: "Streaming edits", description: "Update an in-flight answer instead of sending only a final message." },
8
+ { key: "typing", label: "Typing/status", description: "Show activity while an agent turn is still running." },
9
+ { key: "inline-buttons", label: "Buttons", description: "Expose interactive choices for sessions, queue items, updates, artifacts, and aborts." },
10
+ { key: "files", label: "Files", description: "Receive or send generic files." },
11
+ { key: "photos", label: "Photos", description: "Receive image inputs for multimodal-capable agents." },
12
+ { key: "voice", label: "Voice", description: "Receive audio and run transcription before prompting." },
13
+ { key: "topics", label: "Threads/topics", description: "Keep independent contexts per topic, thread, forum topic, or equivalent channel scope." },
14
+ { key: "webhooks", label: "Webhooks", description: "Support inbound HTTP webhook/event delivery where the platform provides it." },
15
+ ];
16
+ export function channelFeatureStates(capabilities) {
17
+ const supported = new Set(capabilities);
18
+ return CHANNEL_FEATURES.map((feature) => ({
19
+ ...feature,
20
+ supported: supported.has(feature.key),
21
+ }));
22
+ }
23
+ export function buildAdapterConformanceMatrix(input = {}) {
24
+ const agents = input.agents ?? listAgentAdapterDescriptors();
25
+ const channels = input.channels ?? listChannelDescriptors();
26
+ return {
27
+ generatedAt: new Date().toISOString(),
28
+ agents: agents.map((adapter) => {
29
+ const features = agentFeatureStates(adapter.capabilities);
30
+ return {
31
+ id: adapter.id,
32
+ label: adapter.label,
33
+ status: adapter.status,
34
+ features,
35
+ supported: features.filter((feature) => feature.supported).map((feature) => feature.key),
36
+ unsupported: features.filter((feature) => !feature.supported).map((feature) => feature.key),
37
+ notes: adapter.notes,
38
+ };
39
+ }),
40
+ channels: channels.map((adapter) => {
41
+ const features = channelFeatureStates(adapter.capabilities);
42
+ return {
43
+ id: adapter.id,
44
+ label: adapter.label,
45
+ status: adapter.status,
46
+ enabled: adapter.enabled,
47
+ features,
48
+ supported: features.filter((feature) => feature.supported).map((feature) => feature.key),
49
+ unsupported: features.filter((feature) => !feature.supported).map((feature) => feature.key),
50
+ commands: commandNamesForChannel(adapter.id),
51
+ notes: adapter.notes,
52
+ };
53
+ }),
54
+ };
55
+ }
56
+ function commandNamesForChannel(id) {
57
+ if (id === "telegram" || id === "discord" || id === "slack") {
58
+ return channelCatalogCommandNames(id);
59
+ }
60
+ return [];
61
+ }
@@ -117,6 +117,7 @@ function normalizePreferences(value) {
117
117
  voiceBackend: isVoiceBackendPreference(candidate.voiceBackend) ? candidate.voiceBackend : undefined,
118
118
  voiceLanguage: typeof candidate.voiceLanguage === "string" ? candidate.voiceLanguage : candidate.voiceLanguage === null ? null : undefined,
119
119
  voiceTranscribeOnly: typeof candidate.voiceTranscribeOnly === "boolean" ? candidate.voiceTranscribeOnly : undefined,
120
+ targetPeerId: typeof candidate.targetPeerId === "string" ? candidate.targetPeerId : candidate.targetPeerId === null ? null : undefined,
120
121
  });
121
122
  }
122
123
  function pruneEmptyPreferences(preferences) {
package/dist/bot.js CHANGED
@@ -12,7 +12,9 @@ import { formatSessionLabel } from "./bot-ui.js";
12
12
  import { BotPreferencesStore, isQuietNow, } from "./bot-preferences.js";
13
13
  import { renderAgentUpdateJobAction } from "./channel-actions.js";
14
14
  import { ChannelCommandService } from "./channel-command-service.js";
15
+ import { runChannelPeerPrompt } from "./channel-peer-prompt.js";
15
16
  import { deliverChannelAction } from "./channel-runtime.js";
17
+ import { createChannelTurnLifecycle, createChannelTypingLoop } from "./channel-turn-lifecycle.js";
16
18
  import { agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
17
19
  import { getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
18
20
  import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
@@ -24,6 +26,7 @@ import { escapeHTML } from "./format.js";
24
26
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
25
27
  import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
26
28
  import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
29
+ import { RemoteRelayClient } from "./peer-client.js";
27
30
  import { checkPiAuthStatus } from "./pi-auth.js";
28
31
  import { configureRedaction, redactText } from "./redaction.js";
29
32
  import { canWriteWithLock, SessionLockStore } from "./session-locks.js";
@@ -984,6 +987,76 @@ export function createBot(config, registry) {
984
987
  ].join("\n");
985
988
  await safeReply(ctx, html, { fallbackText: plain, replyMarkup: keyboard });
986
989
  };
990
+ const remoteClient = new RemoteRelayClient();
991
+ const handleRemoteUserPrompt = async (ctx, contextKey, chatId, prompt) => {
992
+ const targetPeerId = preferencesStore.get(contextKey).targetPeerId ?? undefined;
993
+ const parsed = parseContextKey(contextKey);
994
+ const messageThreadId = parsed.messageThreadId;
995
+ return runChannelPeerPrompt({
996
+ targetPeerId,
997
+ contextKey,
998
+ prompt,
999
+ remoteClient,
1000
+ editMinIntervalMs: config.telegramEditMinIntervalMs,
1001
+ typingIntervalMs: TYPING_INTERVAL_MS,
1002
+ sendTyping: () => sendChatActionSafe(ctx.api, chatId, "typing", messageThreadId),
1003
+ sendResponse: async (text) => {
1004
+ const message = await sendTextMessage(ctx.api, chatId, escapeHTML(text), {
1005
+ fallbackText: text,
1006
+ messageThreadId,
1007
+ });
1008
+ return message.message_id;
1009
+ },
1010
+ editResponse: (messageId, text) => safeEditMessage(bot, chatId, messageId, escapeHTML(text), {
1011
+ fallbackText: text,
1012
+ }),
1013
+ sendTurnStart: (remotePrompt) => safeReply(ctx, `<b>Remote peer working on:</b>\n${escapeHTML(remotePrompt)}`, {
1014
+ fallbackText: `Remote peer working on:\n${remotePrompt}`,
1015
+ }),
1016
+ sendToolStart: (toolName) => safeReply(ctx, `<b>Remote tool:</b> <code>${escapeHTML(toolName)}</code>`, {
1017
+ fallbackText: `Remote tool: ${toolName}`,
1018
+ }),
1019
+ sendQueued: async (queueId) => {
1020
+ const keyboard = queueId ? new InlineKeyboard().text("Cancel queued message", `peer_queue_cancel:${targetPeerId}:${queueId}`) : undefined;
1021
+ await safeReply(ctx, escapeHTML(`Remote prompt queued${queueId ? `: ${queueId}` : ""}.`), {
1022
+ fallbackText: `Remote prompt queued${queueId ? `: ${queueId}` : ""}.`,
1023
+ replyMarkup: keyboard,
1024
+ });
1025
+ },
1026
+ sendCompleted: () => safeReply(ctx, escapeHTML("Remote turn completed."), { fallbackText: "Remote turn completed." }),
1027
+ sendFailure: (message) => safeReply(ctx, escapeHTML(`Remote peer failed: ${message}`), {
1028
+ fallbackText: `Remote peer failed: ${message}`,
1029
+ }),
1030
+ });
1031
+ };
1032
+ bot.callbackQuery(/^peer_queue_cancel:([^:]+):([a-z0-9]+)$/, async (ctx) => {
1033
+ const targetPeerId = ctx.match?.[1];
1034
+ const queueId = ctx.match?.[2];
1035
+ const contextKey = contextKeyFromCtx(ctx);
1036
+ if (!targetPeerId || !queueId || !contextKey) {
1037
+ await ctx.answerCallbackQuery();
1038
+ return;
1039
+ }
1040
+ try {
1041
+ await remoteClient.webProxy(targetPeerId, {
1042
+ method: "POST",
1043
+ path: "/api/queue",
1044
+ body: { action: "cancel", id: queueId },
1045
+ contextKey,
1046
+ }, telegramActivityActor(ctx), contextKey);
1047
+ await ctx.answerCallbackQuery({ text: `Cancelled remote queued prompt ${queueId}.` });
1048
+ const chatId = ctx.chat?.id;
1049
+ const messageId = ctx.callbackQuery.message?.message_id;
1050
+ if (chatId && messageId) {
1051
+ await safeEditMessage(bot, chatId, messageId, escapeHTML(`Cancelled remote queued prompt ${queueId}.`), {
1052
+ fallbackText: `Cancelled remote queued prompt ${queueId}.`,
1053
+ });
1054
+ }
1055
+ }
1056
+ catch (error) {
1057
+ await ctx.answerCallbackQuery({ text: friendlyErrorText(error), show_alert: true });
1058
+ }
1059
+ });
987
1060
  const handleUserPrompt = async (ctx, contextKey, chatId, session, prompt, options = {}) => {
988
1061
  if (!canSendSystemMessagesToContext(contextKey)) {
989
1062
  return;
@@ -995,6 +1068,9 @@ export function createBot(config, registry) {
995
1068
  ...rawEnvelope,
996
1069
  activityActor: rawEnvelope.activityActor ?? telegramActivityActor(ctx),
997
1070
  };
1071
+ if (!options.fromQueue && await handleRemoteUserPrompt(ctx, contextKey, chatId, envelope)) {
1072
+ return;
1073
+ }
998
1074
  if (!options.fromQueue && await denyIfLocked(ctx, contextKey, session)) {
999
1075
  return;
1000
1076
  }
@@ -1043,14 +1119,8 @@ export function createBot(config, registry) {
1043
1119
  }
1044
1120
  const busyState = getBusyState(contextKey);
1045
1121
  busyState.processing = true;
1046
- const progress = {
1047
- status: "running",
1048
- promptDescription: envelope.description,
1049
- startedAt: Date.now(),
1050
- updatedAt: Date.now(),
1051
- toolCounts: new Map(),
1052
- textCharacters: 0,
1053
- };
1122
+ const turnLifecycle = createChannelTurnLifecycle(envelope.description);
1123
+ const progress = turnLifecycle.progress;
1054
1124
  turnProgress.set(contextKey, progress);
1055
1125
  const abortKeyboard = new InlineKeyboard().text("⏹ Abort", `agent_abort:${contextKey}`);
1056
1126
  const toolVerbosity = config.toolVerbosity;
@@ -1072,12 +1142,13 @@ export function createBot(config, registry) {
1072
1142
  let promptStartedAt;
1073
1143
  const toolActivityNames = new Map();
1074
1144
  const toolActivityStartedAt = new Map();
1075
- const typingInterval = setInterval(() => {
1076
- void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
1077
- }, TYPING_INTERVAL_MS);
1078
- void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
1145
+ const typingLoop = createChannelTypingLoop({
1146
+ intervalMs: TYPING_INTERVAL_MS,
1147
+ sendTyping: () => sendChatActionSafe(bot.api, chatId, "typing", messageThreadId),
1148
+ });
1149
+ typingLoop.start();
1079
1150
  const stopTyping = () => {
1080
- clearInterval(typingInterval);
1151
+ typingLoop.stop();
1081
1152
  };
1082
1153
  const clearFlushTimer = () => {
1083
1154
  if (flushTimer) {
@@ -1228,6 +1299,7 @@ export function createBot(config, registry) {
1228
1299
  return;
1229
1300
  }
1230
1301
  finalized = true;
1302
+ turnLifecycle.recordCompleted();
1231
1303
  stopTyping();
1232
1304
  clearFlushTimer();
1233
1305
  if (responseMessagePromise) {
@@ -1256,8 +1328,7 @@ export function createBot(config, registry) {
1256
1328
  const callbacks = {
1257
1329
  onTextDelta: (delta) => {
1258
1330
  accumulatedText += delta;
1259
- progress.textCharacters += delta.length;
1260
- progress.updatedAt = Date.now();
1331
+ turnLifecycle.recordTextDelta(delta.length);
1261
1332
  if (!responseMessageId) {
1262
1333
  void ensureResponseMessage()
1263
1334
  .then(() => {
@@ -1271,10 +1342,7 @@ export function createBot(config, registry) {
1271
1342
  scheduleFlush();
1272
1343
  },
1273
1344
  onToolStart: (toolName, toolCallId) => {
1274
- progress.currentTool = toolName;
1275
- progress.lastTool = toolName;
1276
- progress.updatedAt = Date.now();
1277
- progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
1345
+ turnLifecycle.recordToolStart(toolName);
1278
1346
  toolActivityNames.set(toolCallId, toolName);
1279
1347
  toolActivityStartedAt.set(toolCallId, Date.now());
1280
1348
  appendTelegramActivity(ctx, contextKey, session, {
@@ -1318,7 +1386,7 @@ export function createBot(config, registry) {
1318
1386
  });
1319
1387
  },
1320
1388
  onToolUpdate: (toolCallId, partialResult) => {
1321
- progress.updatedAt = Date.now();
1389
+ turnLifecycle.recordToolUpdate();
1322
1390
  if (toolVerbosity === "none" || toolVerbosity === "summary") {
1323
1391
  return;
1324
1392
  }
@@ -1329,8 +1397,7 @@ export function createBot(config, registry) {
1329
1397
  state.partialResult = appendWithCap(state.partialResult, partialResult, TOOL_OUTPUT_PREVIEW_LIMIT);
1330
1398
  },
1331
1399
  onToolEnd: (toolCallId, isError) => {
1332
- progress.currentTool = undefined;
1333
- progress.updatedAt = Date.now();
1400
+ turnLifecycle.recordToolEnd();
1334
1401
  const activityToolName = toolActivityNames.get(toolCallId) ?? "tool";
1335
1402
  const activityStartedAt = toolActivityStartedAt.get(toolCallId);
1336
1403
  appendTelegramActivity(ctx, contextKey, session, {
@@ -1375,7 +1442,7 @@ export function createBot(config, registry) {
1375
1442
  });
1376
1443
  },
1377
1444
  onTodoUpdate: (items) => {
1378
- progress.updatedAt = Date.now();
1445
+ turnLifecycle.touch();
1379
1446
  if (toolVerbosity === "none") {
1380
1447
  return;
1381
1448
  }
@@ -1407,7 +1474,7 @@ export function createBot(config, registry) {
1407
1474
  },
1408
1475
  onTurnComplete: (usage) => {
1409
1476
  lastTurnUsage = usage;
1410
- progress.updatedAt = Date.now();
1477
+ turnLifecycle.touch();
1411
1478
  },
1412
1479
  onAgentEnd: () => {
1413
1480
  void finalizeResponse().catch((error) => {
@@ -1522,9 +1589,7 @@ export function createBot(config, registry) {
1522
1589
  await pruneArtifacts(session.getInfo().workspace);
1523
1590
  }
1524
1591
  }
1525
- progress.status = "completed";
1526
- progress.completedAt = Date.now();
1527
- progress.updatedAt = progress.completedAt;
1592
+ turnLifecycle.recordCompleted();
1528
1593
  auditContext(ctx, contextKey, session, {
1529
1594
  action: "prompt_completed",
1530
1595
  status: "ok",
@@ -1539,8 +1604,7 @@ export function createBot(config, registry) {
1539
1604
  });
1540
1605
  }
1541
1606
  catch (error) {
1542
- progress.status = "failed";
1543
- progress.error = friendlyErrorText(error);
1607
+ turnLifecycle.recordFailed(friendlyErrorText(error));
1544
1608
  auditContext(ctx, contextKey, session, {
1545
1609
  action: "prompt_failed",
1546
1610
  status: "failed",
@@ -1557,8 +1621,6 @@ export function createBot(config, registry) {
1557
1621
  durationMs: Date.now() - promptStartedAt,
1558
1622
  });
1559
1623
  }
1560
- progress.completedAt = Date.now();
1561
- progress.updatedAt = progress.completedAt;
1562
1624
  stopTyping();
1563
1625
  clearFlushTimer();
1564
1626
  if (responseMessagePromise) {
@@ -1887,6 +1949,7 @@ export function createBot(config, registry) {
1887
1949
  isTopicContext,
1888
1950
  replyChannelAction,
1889
1951
  commandService,
1952
+ preferencesStore,
1890
1953
  });
1891
1954
  registerTelegramAgentCommands({
1892
1955
  bot,
@@ -1914,14 +1977,9 @@ export function createBot(config, registry) {
1914
1977
  registerTelegramPreferenceCommands({
1915
1978
  bot,
1916
1979
  config,
1980
+ commandService,
1917
1981
  preferencesStore,
1918
1982
  getContextSession,
1919
- getEffectiveMirrorMode,
1920
- getEffectiveNotifyMode,
1921
- getEffectiveQuietHours,
1922
- getEffectiveVoiceBackend,
1923
- getEffectiveVoiceLanguage,
1924
- isVoiceTranscribeOnly,
1925
1983
  });
1926
1984
  registerTelegramDiagnosticsCommands({
1927
1985
  bot,
@@ -19,6 +19,17 @@ const DISCORD_CAPABILITIES = [
19
19
  "voice",
20
20
  "topics",
21
21
  ];
22
+ const SLACK_CAPABILITIES = [
23
+ "text",
24
+ "streaming-edits",
25
+ "typing",
26
+ "inline-buttons",
27
+ "files",
28
+ "photos",
29
+ "voice",
30
+ "topics",
31
+ "webhooks",
32
+ ];
22
33
  const PLANNED_CHANNELS = [
23
34
  {
24
35
  id: "whatsapp",
@@ -27,12 +38,6 @@ const PLANNED_CHANNELS = [
27
38
  status: "planned",
28
39
  notes: "Requires a WhatsApp Business provider integration.",
29
40
  },
30
- {
31
- id: "slack",
32
- label: "Slack",
33
- capabilities: ["text", "streaming-edits", "typing", "inline-buttons", "files"],
34
- status: "planned",
35
- },
36
41
  {
37
42
  id: "matrix",
38
43
  label: "Matrix",
@@ -45,7 +50,8 @@ export class TelegramChannelAdapter {
45
50
  label = "Telegram";
46
51
  capabilities = new Set(TELEGRAM_CAPABILITIES);
47
52
  describe() {
48
- const enabled = process.env.TELEGRAM_ENABLED !== "false";
53
+ const requested = process.env.TELEGRAM_ENABLED !== "false";
54
+ const enabled = requested && Boolean(process.env.TELEGRAM_BOT_TOKEN);
49
55
  return {
50
56
  id: this.id,
51
57
  label: this.label,
@@ -53,8 +59,10 @@ export class TelegramChannelAdapter {
53
59
  status: "available",
54
60
  enabled,
55
61
  notes: enabled
56
- ? "Telegram bot runtime is enabled by default."
57
- : "Telegram bot runtime is disabled.",
62
+ ? "Telegram bot runtime is enabled."
63
+ : requested
64
+ ? "Telegram bot runtime is disabled because TELEGRAM_BOT_TOKEN is missing."
65
+ : "Telegram bot runtime is disabled.",
58
66
  };
59
67
  }
60
68
  }
@@ -63,7 +71,8 @@ export class DiscordChannelAdapter {
63
71
  label = "Discord";
64
72
  capabilities = new Set(DISCORD_CAPABILITIES);
65
73
  describe() {
66
- const enabled = process.env.DISCORD_ENABLED === "true";
74
+ const requested = process.env.DISCORD_ENABLED === "true";
75
+ const enabled = requested && Boolean(process.env.DISCORD_BOT_TOKEN);
67
76
  return {
68
77
  id: this.id,
69
78
  label: this.label,
@@ -72,7 +81,30 @@ export class DiscordChannelAdapter {
72
81
  enabled,
73
82
  notes: enabled
74
83
  ? "Discord bot runtime is enabled."
75
- : "Enable with DISCORD_ENABLED=true and DISCORD_BOT_TOKEN.",
84
+ : requested
85
+ ? "Discord bot runtime is disabled because DISCORD_BOT_TOKEN is missing."
86
+ : "Enable with DISCORD_ENABLED=true and DISCORD_BOT_TOKEN.",
87
+ };
88
+ }
89
+ }
90
+ export class SlackChannelAdapter {
91
+ id = "slack";
92
+ label = "Slack";
93
+ capabilities = new Set(SLACK_CAPABILITIES);
94
+ describe() {
95
+ const requested = process.env.SLACK_ENABLED === "true";
96
+ const enabled = requested && Boolean(process.env.SLACK_BOT_TOKEN) && Boolean(process.env.SLACK_APP_TOKEN);
97
+ return {
98
+ id: this.id,
99
+ label: this.label,
100
+ capabilities: [...this.capabilities],
101
+ status: "available",
102
+ enabled,
103
+ notes: enabled
104
+ ? "Slack bot runtime is enabled."
105
+ : requested
106
+ ? "Slack bot runtime is disabled because SLACK_BOT_TOKEN or SLACK_APP_TOKEN is missing."
107
+ : "Enable with SLACK_ENABLED=true, SLACK_BOT_TOKEN, and SLACK_APP_TOKEN.",
76
108
  };
77
109
  }
78
110
  }
@@ -80,6 +112,7 @@ export function listChannelDescriptors() {
80
112
  return [
81
113
  new TelegramChannelAdapter().describe(),
82
114
  new DiscordChannelAdapter().describe(),
115
+ new SlackChannelAdapter().describe(),
83
116
  ...PLANNED_CHANNELS,
84
117
  ];
85
118
  }
@@ -0,0 +1,94 @@
1
+ const textOption = (name = "value", description = "Value", required = false) => ({
2
+ type: 3,
3
+ name,
4
+ description,
5
+ required,
6
+ });
7
+ export const CHANNEL_COMMANDS = [
8
+ { name: "start", description: "Welcome and status", discordDescription: "Start or inspect the current NordRelay context" },
9
+ { name: "help", description: "Command reference", discordDescription: "Show Discord adapter help" },
10
+ { name: "prompt", description: "Send a prompt to the selected agent", telegram: false, discordOptions: [textOption("text", "Prompt text", true)] },
11
+ { name: "link", description: "Link account to NordRelay user", telegramDescription: "Link Telegram to NordRelay user", discordDescription: "Link this Discord account with a NordRelay code", discordOptions: [textOption("value", "Link code", true)] },
12
+ { name: "whoami", description: "Show your NordRelay user", discordDescription: "Show linked NordRelay user" },
13
+ { name: "register_chat", description: "Admin: enable this group chat", discord: false },
14
+ { name: "register_channel", description: "Enable this Discord channel for NordRelay", telegram: false },
15
+ { name: "channels", description: "Messaging adapter status", discordDescription: "Show channel adapters" },
16
+ { name: "peers", description: "NordRelay peer status", discordDescription: "Show paired NordRelay instances" },
17
+ { name: "target", description: "Select local or peer target", discordDescription: "Select local or peer target", discordOptions: [textOption("value", "local or peer id")] },
18
+ { name: "agents", description: "Agent adapter status", discordDescription: "Show agent adapters" },
19
+ { name: "agent", description: "Select agent", discordDescription: "Select or show the active agent", discordOptions: [textOption("value", "Agent id")] },
20
+ { name: "new", description: "Start a new thread", discordDescription: "Create a new session", discordOptions: [textOption("value", "Workspace path")] },
21
+ { name: "session", description: "Current thread details", discordDescription: "Show the active session" },
22
+ { name: "sessions", description: "Browse and switch threads", discordDescription: "Browse recent sessions", discordOptions: [textOption("query", "Search query")] },
23
+ { name: "switch", description: "Switch to a thread by ID", discordDescription: "Switch to a session", discordOptions: [textOption("thread_id", "Thread id", true)] },
24
+ { name: "attach", description: "Bind a session to this topic", discordDescription: "Attach a session", discordOptions: [textOption("thread_id", "Thread id", true)] },
25
+ { name: "handback", description: "Hand session back to CLI", discordDescription: "Hand the active session back to the native CLI" },
26
+ { name: "sync", description: "Sync active session from CLI state", discordDescription: "Sync from local agent state" },
27
+ { name: "pinned", description: "Show pinned threads" },
28
+ { name: "pin", description: "Pin current or given thread", discordOptions: [textOption("value", "Thread id")] },
29
+ { name: "unpin", description: "Unpin current or given thread", discordOptions: [textOption("value", "Thread id")] },
30
+ { name: "retry", description: "Resend the last prompt", discordDescription: "Retry the last prompt" },
31
+ { name: "queue", description: "Show queued prompts", discordDescription: "Show or manage queue", discordOptions: [textOption("action", "pause/resume/clear/run/cancel/top/up/down"), textOption("id", "Queue id")] },
32
+ { name: "cancel", description: "Cancel a queued prompt", discordOptions: [textOption("value", "Queue id", true)] },
33
+ { name: "clearqueue", description: "Clear queued prompts", discordDescription: "Clear queue" },
34
+ { name: "artifacts", description: "List or resend generated files", discordDescription: "List or send artifacts", discordOptions: [textOption("value", "zip <turn-id>")] },
35
+ { name: "workspaces", description: "List allowed workspaces" },
36
+ { name: "abort", description: "Cancel current operation", discordDescription: "Abort the active task" },
37
+ { name: "stop", description: "Cancel current operation", discordDescription: "Abort the active task" },
38
+ { name: "launch", description: "Select launch profile", discordOptions: [textOption("value", "Launch profile id")] },
39
+ { name: "launch_profiles", description: "Select launch profile", discordOptions: [textOption("value", "Launch profile id")] },
40
+ { name: "fast", description: "Toggle fast mode", discordOptions: [textOption("value", "on/off")] },
41
+ { name: "model", description: "View and change model", discordDescription: "Select or show models", discordOptions: [textOption("value", "Model id")] },
42
+ { name: "effort", description: "Set reasoning effort", discordDescription: "Select reasoning effort", discordOptions: [textOption("value", "Reasoning value")] },
43
+ { name: "reasoning", description: "Set reasoning effort", discordDescription: "Select reasoning effort", discordOptions: [textOption("value", "Reasoning value")] },
44
+ { name: "mirror", description: "Control CLI mirroring", discordDescription: "Set mirror mode", discordOptions: [textOption("value", "off/status/final/full")] },
45
+ { name: "notify", description: "Control notifications", discordDescription: "Set notification mode", discordOptions: [textOption("value", "off/minimal/all")] },
46
+ { name: "auth", description: "Check auth status", discordDescription: "Show selected agent auth status" },
47
+ { name: "login", description: "Start authentication", discordDescription: "Start selected agent login" },
48
+ { name: "logout", description: "Sign out", discordDescription: "Sign out of the selected agent" },
49
+ { name: "voice", description: "Voice transcription status", discordDescription: "Show or change voice settings", discordOptions: [textOption("value", "transcribe-only on/off")] },
50
+ { name: "tasks", description: "Current turn progress", discordDescription: "Show recent tasks", discordOptions: [textOption("value", "Limit")] },
51
+ { name: "progress", description: "Current turn progress", discordDescription: "Show current turn progress" },
52
+ { name: "activity", description: "Thread activity timeline", discordDescription: "Show recent activity", discordOptions: [textOption("value", "Limit")] },
53
+ { name: "audit", description: "Admin: recent audit events", discordDescription: "Show recent audit events", discordOptions: [textOption("value", "Limit")] },
54
+ { name: "status", description: "Connector runtime status", discordDescription: "Show status" },
55
+ { name: "health", description: "Connector health report", discordDescription: "Show health" },
56
+ { name: "version", description: "Connector version", discordDescription: "Show versions" },
57
+ { name: "logs", description: "Admin: show connector logs", discordDescription: "Show logs", discordOptions: [textOption("value", "Target and line count")] },
58
+ { name: "diagnostics", description: "Admin: connector diagnostics", discordDescription: "Show diagnostics" },
59
+ { name: "support", description: "Admin: export diagnostics bundle", discordDescription: "Show support diagnostics" },
60
+ { name: "lock", description: "Lock session writes to you", discordDescription: "Lock this context" },
61
+ { name: "unlock", description: "Release session write lock", discordDescription: "Unlock this context" },
62
+ { name: "locks", description: "List session write locks", discordDescription: "List locks" },
63
+ { name: "restart", description: "Admin: restart connector", discordDescription: "Restart NordRelay" },
64
+ { name: "update", description: "Admin: update connector or agents", discordDescription: "Update NordRelay or agents", discordOptions: [textOption("target", "jobs, install, log, cancel, input, or agent id"), textOption("agent", "Agent id or job id"), textOption("input", "Text for update input")] },
65
+ ];
66
+ export function telegramCommandCatalog() {
67
+ return CHANNEL_COMMANDS
68
+ .filter((entry) => entry.telegram !== false)
69
+ .map((entry) => ({
70
+ command: entry.name,
71
+ description: entry.telegramDescription ?? entry.description,
72
+ }));
73
+ }
74
+ export function discordCommandCatalog() {
75
+ return CHANNEL_COMMANDS
76
+ .filter((entry) => entry.discord !== false)
77
+ .map((entry) => ({
78
+ name: entry.name,
79
+ description: entry.discordDescription ?? entry.description,
80
+ options: entry.discordOptions ?? [],
81
+ }));
82
+ }
83
+ export function discordHelpCommandList() {
84
+ return discordCommandCatalog()
85
+ .filter((entry) => !["start", "help", "prompt"].includes(entry.name))
86
+ .map((entry) => `/${entry.name}`)
87
+ .join(", ");
88
+ }
89
+ export function slackHelpCommandList() {
90
+ return CHANNEL_COMMANDS
91
+ .filter((entry) => entry.slack !== false && !["start", "help", "prompt"].includes(entry.name))
92
+ .map((entry) => `/${entry.name}`)
93
+ .join(", ");
94
+ }
@@ -0,0 +1,60 @@
1
+ import { CHANNEL_COMMANDS } from "./channel-command-catalog.js";
2
+ import { normalizeChannelCommandName } from "./channel-runtime.js";
3
+ export function createSharedChannelCommandDispatcher(input) {
4
+ const handlers = new Map();
5
+ for (const binding of input.bindings) {
6
+ for (const name of binding.names) {
7
+ const normalized = normalizeChannelCommandName(name);
8
+ if (!normalized) {
9
+ throw new Error("Channel command name is required.");
10
+ }
11
+ if (handlers.has(normalized)) {
12
+ throw new Error(`Duplicate ${input.transport} command binding: ${normalized}`);
13
+ }
14
+ handlers.set(normalized, binding.handler);
15
+ }
16
+ }
17
+ return {
18
+ transport: input.transport,
19
+ commandNames: [...handlers.keys()].sort(),
20
+ async dispatch(request, command, argument) {
21
+ const normalized = normalizeChannelCommandName(command);
22
+ const handler = handlers.get(normalized);
23
+ if (!handler) {
24
+ return { matched: false, command: normalized };
25
+ }
26
+ await handler(request, argument, normalized);
27
+ return { matched: true, command: normalized };
28
+ },
29
+ };
30
+ }
31
+ export function channelCatalogCommandNames(transport) {
32
+ return CHANNEL_COMMANDS
33
+ .filter((entry) => {
34
+ if (transport === "telegram")
35
+ return entry.telegram !== false;
36
+ if (transport === "discord")
37
+ return entry.discord !== false;
38
+ return entry.slack !== false;
39
+ })
40
+ .map((entry) => normalizeChannelCommandName(entry.name))
41
+ .sort();
42
+ }
43
+ export function channelCommandCoverage(input) {
44
+ const advertised = new Set(channelCatalogCommandNames(input.transport));
45
+ const implemented = new Set([...input.implemented].map(normalizeChannelCommandName));
46
+ for (const [canonical, aliases] of Object.entries(input.aliases ?? {})) {
47
+ if (!implemented.has(normalizeChannelCommandName(canonical)))
48
+ continue;
49
+ for (const alias of aliases) {
50
+ implemented.add(normalizeChannelCommandName(alias));
51
+ }
52
+ }
53
+ const aliasNames = new Set(Object.values(input.aliases ?? {}).flat().map(normalizeChannelCommandName));
54
+ return {
55
+ advertised: [...advertised].sort(),
56
+ implemented: [...implemented].sort(),
57
+ missing: [...advertised].filter((name) => !implemented.has(name)).sort(),
58
+ extra: [...implemented].filter((name) => !advertised.has(name) && !aliasNames.has(name)).sort(),
59
+ };
60
+ }