@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,73 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export function createChannelTurnLifecycle(promptDescription) {
3
+ const startedAt = Date.now();
4
+ const turnId = randomUUID().slice(0, 12);
5
+ const progress = {
6
+ status: "running",
7
+ promptDescription,
8
+ startedAt,
9
+ updatedAt: startedAt,
10
+ toolCounts: new Map(),
11
+ textCharacters: 0,
12
+ };
13
+ const touch = () => {
14
+ progress.updatedAt = Date.now();
15
+ };
16
+ return {
17
+ turnId,
18
+ startedAt,
19
+ progress,
20
+ touch,
21
+ recordTextDelta: (characters) => {
22
+ progress.textCharacters += Math.max(0, characters);
23
+ touch();
24
+ },
25
+ recordToolStart: (toolName) => {
26
+ progress.currentTool = toolName;
27
+ progress.lastTool = toolName;
28
+ progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
29
+ touch();
30
+ },
31
+ recordToolUpdate: touch,
32
+ recordToolEnd: () => {
33
+ progress.currentTool = undefined;
34
+ touch();
35
+ },
36
+ recordCompleted: () => {
37
+ progress.status = "completed";
38
+ progress.completedAt = Date.now();
39
+ progress.updatedAt = progress.completedAt;
40
+ },
41
+ recordFailed: (error) => {
42
+ progress.status = "failed";
43
+ progress.error = error;
44
+ progress.completedAt = Date.now();
45
+ progress.updatedAt = progress.completedAt;
46
+ },
47
+ };
48
+ }
49
+ export function createChannelTypingLoop(options) {
50
+ let timer;
51
+ let running = false;
52
+ const sendTyping = () => {
53
+ void options.sendTyping().catch(() => { });
54
+ };
55
+ return {
56
+ start: () => {
57
+ if (running) {
58
+ return;
59
+ }
60
+ running = true;
61
+ timer = setInterval(sendTyping, options.intervalMs);
62
+ timer.unref?.();
63
+ sendTyping();
64
+ },
65
+ stop: () => {
66
+ running = false;
67
+ if (timer) {
68
+ clearInterval(timer);
69
+ timer = undefined;
70
+ }
71
+ },
72
+ };
73
+ }
@@ -1,6 +1,8 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isCodexApprovalPolicy, isCodexSandboxMode, } from "./codex-launch.js";
4
+ const ROLLOUT_CACHE_MAX_EVENTS = 200;
5
+ const rolloutSnapshotCache = new Map();
4
6
  export const FALLBACK_MODELS = [
5
7
  { slug: "gpt-5.5", displayName: "GPT-5.5" },
6
8
  { slug: "gpt-5.4", displayName: "GPT-5.4" },
@@ -74,31 +76,7 @@ export function getThreadUsage(id) {
74
76
  }
75
77
  }
76
78
  export function getThreadActivity(id, options = {}) {
77
- const rolloutPath = getThreadRolloutPath(id);
78
- if (!rolloutPath || !existsSync(rolloutPath)) {
79
- return null;
80
- }
81
- try {
82
- const parsed = parseActivityFromRollout(id, rolloutPath, readFileSync(rolloutPath, "utf8"));
83
- const fileModifiedAtMs = statSync(rolloutPath).mtimeMs;
84
- const updatedAtMs = Math.max(parsed.updatedAt?.getTime() ?? 0, fileModifiedAtMs);
85
- const updatedAt = updatedAtMs > 0 ? new Date(updatedAtMs) : parsed.updatedAt;
86
- const staleAfterMs = options.staleAfterMs ?? 5 * 60 * 1000;
87
- const nowMs = options.nowMs ?? Date.now();
88
- const stale = Boolean(parsed.active &&
89
- updatedAt &&
90
- staleAfterMs > 0 &&
91
- nowMs - updatedAt.getTime() > staleAfterMs);
92
- return {
93
- ...parsed,
94
- updatedAt,
95
- stale,
96
- active: parsed.active && !stale,
97
- };
98
- }
99
- catch {
100
- return null;
101
- }
79
+ return getThreadRolloutSnapshot(id, { ...options, maxEvents: 0 })?.activity ?? null;
102
80
  }
103
81
  export function getThreadRolloutSnapshot(id, options = {}) {
104
82
  const rolloutPath = getThreadRolloutPath(id);
@@ -106,8 +84,9 @@ export function getThreadRolloutSnapshot(id, options = {}) {
106
84
  return null;
107
85
  }
108
86
  try {
109
- const parsed = parseRolloutSnapshot(id, rolloutPath, readFileSync(rolloutPath, "utf8"));
110
- return finalizeRolloutSnapshot(parsed, statSync(rolloutPath).mtimeMs, options);
87
+ const fileModifiedAtMs = statSync(rolloutPath).mtimeMs;
88
+ const parsed = readCachedRolloutSnapshot(id, rolloutPath);
89
+ return finalizeRolloutSnapshot(parsed, fileModifiedAtMs, options);
111
90
  }
112
91
  catch {
113
92
  return null;
@@ -167,13 +146,7 @@ export function getThreadRolloutPath(id) {
167
146
  }) ?? null);
168
147
  }
169
148
  function parseUsageFromRollout(contents) {
170
- let contextWindow = null;
171
- let contextUsedPercent = null;
172
- let lastTokenUsage = null;
173
- let totalTokenUsage = null;
174
- let rateLimits = null;
175
- let updatedAt = null;
176
- for (const line of contents.split(/\r?\n/)) {
149
+ for (const line of iterateLinesReverse(contents)) {
177
150
  if (!line.includes('"token_count"')) {
178
151
  continue;
179
152
  }
@@ -188,6 +161,7 @@ function parseUsageFromRollout(contents) {
188
161
  if (payload?.type !== "token_count") {
189
162
  continue;
190
163
  }
164
+ let updatedAt = null;
191
165
  const timestamp = readString(readObject(event)?.timestamp);
192
166
  if (timestamp) {
193
167
  const parsedTimestamp = new Date(timestamp);
@@ -196,54 +170,116 @@ function parseUsageFromRollout(contents) {
196
170
  }
197
171
  }
198
172
  const info = readObject(payload.info);
199
- const parsedTotal = parseTokenUsage(readObject(info?.total_token_usage));
200
- const parsedLast = parseTokenUsage(readObject(info?.last_token_usage));
173
+ const totalTokenUsage = parseTokenUsage(readObject(info?.total_token_usage));
174
+ const lastTokenUsage = parseTokenUsage(readObject(info?.last_token_usage));
201
175
  const parsedContextWindow = readNumber(info?.model_context_window);
202
- if (parsedTotal) {
203
- totalTokenUsage = parsedTotal;
176
+ const contextWindow = parsedContextWindow !== null && parsedContextWindow > 0
177
+ ? parsedContextWindow
178
+ : null;
179
+ const contextUsedPercent = lastTokenUsage && contextWindow
180
+ ? Math.min(100, (lastTokenUsage.totalTokens / contextWindow) * 100)
181
+ : null;
182
+ const rateLimits = parseRateLimits(readObject(payload.rate_limits));
183
+ if (!lastTokenUsage && !totalTokenUsage && !rateLimits) {
184
+ continue;
204
185
  }
205
- if (parsedLast) {
206
- lastTokenUsage = parsedLast;
186
+ return {
187
+ contextWindow,
188
+ contextUsedPercent,
189
+ lastTokenUsage,
190
+ totalTokenUsage,
191
+ rateLimits,
192
+ updatedAt,
193
+ };
194
+ }
195
+ return null;
196
+ }
197
+ function readCachedRolloutSnapshot(threadId, rolloutPath) {
198
+ const size = statSync(rolloutPath).size;
199
+ const cached = rolloutSnapshotCache.get(rolloutPath);
200
+ if (cached && size >= cached.byteOffset) {
201
+ const suffix = size > cached.byteOffset
202
+ ? readFileRangeUtf8(rolloutPath, cached.byteOffset, size - cached.byteOffset)
203
+ : "";
204
+ if (!suffix.trim()) {
205
+ return cached.parsed;
207
206
  }
208
- if (parsedContextWindow !== null && parsedContextWindow > 0) {
209
- contextWindow = parsedContextWindow;
207
+ const parsed = parseRolloutSnapshot(threadId, rolloutPath, suffix, {
208
+ base: cached.parsed,
209
+ maxEvents: ROLLOUT_CACHE_MAX_EVENTS,
210
+ });
211
+ rolloutSnapshotCache.set(rolloutPath, { byteOffset: size, parsed });
212
+ return parsed;
213
+ }
214
+ const contents = readFileSync(rolloutPath, "utf8");
215
+ const parsed = parseRolloutSnapshot(threadId, rolloutPath, contents, {
216
+ maxEvents: ROLLOUT_CACHE_MAX_EVENTS,
217
+ });
218
+ rolloutSnapshotCache.set(rolloutPath, {
219
+ byteOffset: Buffer.byteLength(contents),
220
+ parsed,
221
+ });
222
+ return parsed;
223
+ }
224
+ function readFileRangeUtf8(filePath, position, length) {
225
+ if (length <= 0) {
226
+ return "";
227
+ }
228
+ const fd = openSync(filePath, "r");
229
+ try {
230
+ const buffer = Buffer.allocUnsafe(length);
231
+ const bytesRead = readSync(fd, buffer, 0, length, position);
232
+ return buffer.subarray(0, bytesRead).toString("utf8");
233
+ }
234
+ finally {
235
+ closeSync(fd);
236
+ }
237
+ }
238
+ function* iterateLinesReverse(contents) {
239
+ let end = contents.length;
240
+ while (end > 0) {
241
+ let start = contents.lastIndexOf("\n", end - 1);
242
+ const lineStart = start === -1 ? 0 : start + 1;
243
+ let line = contents.slice(lineStart, end);
244
+ if (line.endsWith("\r")) {
245
+ line = line.slice(0, -1);
210
246
  }
211
- if (lastTokenUsage && contextWindow) {
212
- contextUsedPercent = Math.min(100, (lastTokenUsage.totalTokens / contextWindow) * 100);
247
+ if (line.trim()) {
248
+ yield line;
213
249
  }
214
- const parsedRateLimits = parseRateLimits(readObject(payload.rate_limits));
215
- if (parsedRateLimits) {
216
- rateLimits = parsedRateLimits;
250
+ if (start === -1) {
251
+ break;
217
252
  }
253
+ end = start;
218
254
  }
219
- if (!lastTokenUsage && !totalTokenUsage && !rateLimits) {
220
- return null;
221
- }
222
- return {
223
- contextWindow,
224
- contextUsedPercent,
225
- lastTokenUsage,
226
- totalTokenUsage,
227
- rateLimits,
228
- updatedAt,
229
- };
230
255
  }
231
- function parseActivityFromRollout(threadId, rolloutPath, contents) {
232
- return parseRolloutSnapshot(threadId, rolloutPath, contents).activity;
233
- }
234
- function parseRolloutSnapshot(threadId, rolloutPath, contents) {
235
- let activeTurnId = null;
236
- let startedAt = null;
237
- let updatedAt = null;
238
- let latestAgentMessage = null;
239
- let latestUserMessage = null;
240
- let latestToolName = null;
241
- const events = [];
256
+ function parseRolloutSnapshot(threadId, rolloutPath, contents, options = {}) {
257
+ let activeTurnId = options.base?.activity.active ? options.base.activity.turnId : null;
258
+ let startedAt = options.base?.activity.active ? options.base.activity.startedAt : null;
259
+ let updatedAt = options.base?.activity.updatedAt ?? null;
260
+ let latestAgentMessage = options.base?.latestAgentMessage ?? null;
261
+ let latestUserMessage = options.base?.latestUserMessage ?? null;
262
+ let latestToolName = options.base?.latestToolName ?? null;
263
+ const events = [...(options.base?.events ?? [])];
242
264
  const lines = contents.split(/\r?\n/);
265
+ const lineNumberOffset = options.base?.lineCount ?? 0;
266
+ let lineCount = lineNumberOffset;
267
+ const afterLine = options.afterLine ?? 0;
268
+ const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
269
+ const pushEvent = (event) => {
270
+ if (maxEvents <= 0 || event.lineNumber <= afterLine) {
271
+ return;
272
+ }
273
+ events.push(event);
274
+ if (Number.isFinite(maxEvents) && events.length > maxEvents) {
275
+ events.splice(0, events.length - maxEvents);
276
+ }
277
+ };
243
278
  for (const [index, line] of lines.entries()) {
244
279
  if (!line.trim()) {
245
280
  continue;
246
281
  }
282
+ lineCount += 1;
247
283
  if (!line.includes('"task_') &&
248
284
  !line.includes('"turn_') &&
249
285
  !line.includes('"user_message"') &&
@@ -262,7 +298,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
262
298
  const eventObject = readObject(event);
263
299
  const payload = readObject(eventObject?.payload);
264
300
  const eventTimestamp = parseTimestamp(readString(eventObject?.timestamp));
265
- const lineNumber = index + 1;
301
+ const lineNumber = lineNumberOffset + index + 1;
266
302
  if (activeTurnId && eventTimestamp) {
267
303
  updatedAt = eventTimestamp;
268
304
  }
@@ -274,7 +310,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
274
310
  activeTurnId = readString(payload?.turn_id);
275
311
  startedAt = parseUnixSeconds(readNumber(payload?.started_at)) ?? eventTimestamp;
276
312
  updatedAt = eventTimestamp ?? startedAt;
277
- events.push({
313
+ pushEvent({
278
314
  lineNumber,
279
315
  kind: "task",
280
316
  timestamp: eventTimestamp,
@@ -289,7 +325,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
289
325
  }
290
326
  if (isTaskTerminalEvent(type)) {
291
327
  const turnId = readString(payload?.turn_id);
292
- events.push({
328
+ pushEvent({
293
329
  lineNumber,
294
330
  kind: "task",
295
331
  timestamp: eventTimestamp,
@@ -309,7 +345,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
309
345
  }
310
346
  if (type === "user_message") {
311
347
  latestUserMessage = readString(payload?.message);
312
- events.push({
348
+ pushEvent({
313
349
  lineNumber,
314
350
  kind: "user",
315
351
  timestamp: eventTimestamp,
@@ -324,7 +360,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
324
360
  }
325
361
  if (type === "agent_message") {
326
362
  latestAgentMessage = readString(payload?.message);
327
- events.push({
363
+ pushEvent({
328
364
  lineNumber,
329
365
  kind: "agent",
330
366
  timestamp: eventTimestamp,
@@ -339,7 +375,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
339
375
  }
340
376
  if (type === "function_call") {
341
377
  latestToolName = readString(payload?.name);
342
- events.push({
378
+ pushEvent({
343
379
  lineNumber,
344
380
  kind: "tool",
345
381
  timestamp: eventTimestamp,
@@ -353,7 +389,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
353
389
  continue;
354
390
  }
355
391
  if (type === "function_call_output") {
356
- events.push({
392
+ pushEvent({
357
393
  lineNumber,
358
394
  kind: "tool",
359
395
  timestamp: eventTimestamp,
@@ -369,7 +405,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
369
405
  return {
370
406
  threadId,
371
407
  rolloutPath,
372
- lineCount: lines.filter((line) => line.trim()).length,
408
+ lineCount,
373
409
  activity: {
374
410
  threadId,
375
411
  rolloutPath,
@@ -1,6 +1,9 @@
1
1
  export const SECRET_KEYS = new Set([
2
2
  "TELEGRAM_BOT_TOKEN",
3
3
  "DISCORD_BOT_TOKEN",
4
+ "SLACK_BOT_TOKEN",
5
+ "SLACK_APP_TOKEN",
6
+ "SLACK_SIGNING_SECRET",
4
7
  "CODEX_API_KEY",
5
8
  "HERMES_API_KEY",
6
9
  "OPENCLAW_GATEWAY_TOKEN",
@@ -24,15 +27,34 @@ const DISCORD_SETTING_HELP = {
24
27
  DISCORD_QUIET_HOURS: "Use a local-time range like 22-7, off, or blank to inherit the channel-neutral quiet-hours setting.",
25
28
  DISCORD_AUTO_SEND_ARTIFACTS: "Overrides automatic artifact upload behavior for Discord only. Leave blank to use NORDRELAY_AUTO_SEND_ARTIFACTS.",
26
29
  };
30
+ const SLACK_SETTING_HELP = {
31
+ SLACK_ENABLED: "Create a Slack app, install it into the workspace, then set bot/app tokens before enabling Slack.",
32
+ SLACK_BOT_TOKEN: "Slack app OAuth & Permissions: copy the bot token that starts with xoxb-.",
33
+ SLACK_APP_TOKEN: "Slack app Basic Information: create an app-level token with connections:write. Required for Socket Mode.",
34
+ SLACK_SIGNING_SECRET: "Slack app Basic Information: copy Signing Secret. Required only when Socket Mode is disabled.",
35
+ SLACK_ALLOWED_TEAM_IDS: "Optional workspace allow-list. Copy Team IDs from Slack event payloads or app diagnostics.",
36
+ SLACK_ALLOWED_CHANNEL_IDS: "Optional channel allow-list before NordRelay user/group checks. Copy channel IDs from Slack channel details.",
37
+ SLACK_COMMAND: "Slash command configured in the Slack app. Defaults to /nordrelay.",
38
+ };
39
+ const TELEGRAM_SETTING_HELP = {
40
+ TELEGRAM_ENABLED: "Enable this only after the BotFather token is configured and NordRelay users/chats are allowed through the user management system.",
41
+ TELEGRAM_BOT_TOKEN: "Telegram BotFather: open @BotFather, create a bot with /newbot, then paste only the token value.",
42
+ TELEGRAM_TRANSPORT: "Use polling for the simplest setup. Use webhook only when this NordRelay instance is reachable from Telegram through public HTTPS.",
43
+ TELEGRAM_WEBHOOK_URL: "Public HTTPS base URL for Telegram webhook delivery, for example https://relay.example.com.",
44
+ TELEGRAM_WEBHOOK_HOST: "Local interface where NordRelay binds the webhook listener. Use 127.0.0.1 behind a reverse proxy or 0.0.0.0 only when the endpoint is protected.",
45
+ TELEGRAM_WEBHOOK_PORT: "Local port for the Telegram webhook listener.",
46
+ TELEGRAM_WEBHOOK_PATH: "Webhook request path registered with Telegram. It must start with /.",
47
+ TELEGRAM_WEBHOOK_SECRET: "Optional secret token Telegram sends in X-Telegram-Bot-Api-Secret-Token. Use a random value for webhook mode.",
48
+ };
27
49
  export const SETTING_DEFINITIONS = [
28
- setting("TELEGRAM_ENABLED", "Enable Telegram", "Telegram", "boolean", "Start the Telegram bot adapter.", true),
29
- setting("TELEGRAM_BOT_TOKEN", "Telegram bot token", "Telegram", "secret", "BotFather token.", true),
30
- setting("TELEGRAM_TRANSPORT", "Telegram transport", "Telegram", "string", "polling or webhook.", true, ["polling", "webhook"]),
31
- setting("TELEGRAM_WEBHOOK_URL", "Webhook public URL", "Telegram", "string", "Public base URL for webhook mode.", true),
32
- setting("TELEGRAM_WEBHOOK_HOST", "Webhook bind host", "Telegram", "string", "Local webhook bind host.", true),
33
- setting("TELEGRAM_WEBHOOK_PORT", "Webhook bind port", "Telegram", "number", "Local webhook bind port.", true),
34
- setting("TELEGRAM_WEBHOOK_PATH", "Webhook path", "Telegram", "string", "Webhook request path.", true),
35
- setting("TELEGRAM_WEBHOOK_SECRET", "Webhook secret", "Telegram", "secret", "Optional Telegram webhook secret token.", true),
50
+ telegramSetting("TELEGRAM_ENABLED", "Enable Telegram", "boolean", "Start the Telegram bot adapter.", true),
51
+ telegramSetting("TELEGRAM_BOT_TOKEN", "Telegram bot token", "secret", "BotFather token.", true),
52
+ telegramSetting("TELEGRAM_TRANSPORT", "Telegram transport", "string", "polling or webhook.", true, ["polling", "webhook"]),
53
+ telegramSetting("TELEGRAM_WEBHOOK_URL", "Webhook public URL", "string", "Public base URL for webhook mode.", true),
54
+ telegramSetting("TELEGRAM_WEBHOOK_HOST", "Webhook bind host", "string", "Local webhook bind host.", true),
55
+ telegramSetting("TELEGRAM_WEBHOOK_PORT", "Webhook bind port", "number", "Local webhook bind port.", true),
56
+ telegramSetting("TELEGRAM_WEBHOOK_PATH", "Webhook path", "string", "Webhook request path.", true),
57
+ telegramSetting("TELEGRAM_WEBHOOK_SECRET", "Webhook secret", "secret", "Optional Telegram webhook secret token.", true),
36
58
  discordSetting("DISCORD_ENABLED", "Enable Discord", "boolean", "Start the Discord bot adapter.", true),
37
59
  discordSetting("DISCORD_BOT_TOKEN", "Discord bot token", "secret", "Discord bot token.", true),
38
60
  discordSetting("DISCORD_CLIENT_ID", "Discord client ID", "string", "Discord application/client id used for slash command registration.", true),
@@ -47,6 +69,21 @@ export const SETTING_DEFINITIONS = [
47
69
  discordSetting("DISCORD_NOTIFY_MODE", "Discord notify override", "string", "Optional Discord override for completion notifications.", false, ["off", "minimal", "all"]),
48
70
  discordSetting("DISCORD_QUIET_HOURS", "Discord quiet hours override", "string", "Optional Discord quiet hours override. Use HH-HH, off, or leave blank for default.", false),
49
71
  discordSetting("DISCORD_AUTO_SEND_ARTIFACTS", "Discord auto-send artifacts override", "boolean", "Optional Discord override for automatic artifact summaries/uploads.", false),
72
+ slackSetting("SLACK_ENABLED", "Enable Slack", "boolean", "Start the Slack bot adapter.", true),
73
+ slackSetting("SLACK_BOT_TOKEN", "Slack bot token", "secret", "Slack bot token.", true),
74
+ slackSetting("SLACK_APP_TOKEN", "Slack app token", "secret", "Slack app-level token for Socket Mode.", true),
75
+ slackSetting("SLACK_SIGNING_SECRET", "Slack signing secret", "secret", "Slack signing secret for HTTP Events mode.", true),
76
+ slackSetting("SLACK_SOCKET_MODE", "Slack Socket Mode", "boolean", "Use Slack Socket Mode instead of an HTTP events receiver.", true),
77
+ slackSetting("SLACK_PORT", "Slack HTTP port", "number", "HTTP port used when Slack Socket Mode is disabled.", true),
78
+ slackSetting("SLACK_ALLOWED_TEAM_IDS", "Allowed Slack teams", "list", "Optional comma-separated Slack team/workspace allow-list.", true),
79
+ slackSetting("SLACK_ALLOWED_CHANNEL_IDS", "Allowed Slack channels", "list", "Optional comma-separated Slack channel allow-list before user/group checks.", true),
80
+ slackSetting("SLACK_MESSAGE_CONTENT_ENABLED", "Slack message content", "boolean", "Read regular Slack text messages as prompts.", true),
81
+ slackSetting("SLACK_COMMAND", "Slack Slash command", "string", "Slash command configured in Slack.", true),
82
+ slackSetting("SLACK_CLI_MIRROR_MODE", "Slack mirror override", "string", "Optional Slack override for CLI mirror mode. Uses the NordRelay default when unset.", false, ["off", "status", "final", "full"]),
83
+ slackSetting("SLACK_CLI_MIRROR_MIN_UPDATE_MS", "Slack mirror update override", "number", "Optional Slack override for mirrored edit interval.", true),
84
+ slackSetting("SLACK_NOTIFY_MODE", "Slack notify override", "string", "Optional Slack override for completion notifications.", false, ["off", "minimal", "all"]),
85
+ slackSetting("SLACK_QUIET_HOURS", "Slack quiet hours override", "string", "Optional Slack quiet hours override. Use HH-HH, off, or leave blank for default.", false),
86
+ slackSetting("SLACK_AUTO_SEND_ARTIFACTS", "Slack auto-send artifacts override", "boolean", "Optional Slack override for automatic artifact summaries/uploads.", false),
50
87
  setting("NORDRELAY_CODEX_ENABLED", "Enable Codex", "Agents", "boolean", "Allow Codex sessions.", true),
51
88
  setting("NORDRELAY_PI_ENABLED", "Enable Pi", "Agents", "boolean", "Allow Pi sessions.", true),
52
89
  setting("NORDRELAY_HERMES_ENABLED", "Enable Hermes", "Agents", "boolean", "Allow Hermes sessions through the Hermes API Server.", true),
@@ -128,6 +165,13 @@ export const SETTING_DEFINITIONS = [
128
165
  setting("NORDRELAY_UNIFIED_JOB_MAX_ITEMS", "Unified job history", "Workspace", "number", "Maximum persisted unified jobs retained for the WebUI jobs view.", true),
129
166
  setting("NORDRELAY_VERSION_CACHE_TTL_MS", "Version cache TTL", "Workspace", "number", "NPM version cache TTL.", true),
130
167
  setting("NORDRELAY_CLI_VERSION_CACHE_TTL_MS", "CLI version cache TTL", "Workspace", "number", "Installed agent CLI version cache TTL.", true),
168
+ setting("NORDRELAY_PEER_ENABLED", "Enable peer server", "Peers", "boolean", "Expose the dedicated authenticated NordRelay peer API.", true),
169
+ setting("NORDRELAY_PEER_NAME", "Peer display name", "Peers", "string", "Human-readable name shown to paired NordRelay instances.", true),
170
+ setting("NORDRELAY_PEER_HOST", "Peer bind host", "Peers", "string", "Bind host for the peer API. Use 127.0.0.1 for local-only or a LAN/interface IP when explicitly exposing peers.", true),
171
+ setting("NORDRELAY_PEER_PORT", "Peer port", "Peers", "number", "Port for the peer API.", true),
172
+ setting("NORDRELAY_PEER_PUBLIC_URL", "Peer public URL", "Peers", "string", "Optional public URL other instances should use for this node.", true),
173
+ setting("NORDRELAY_PEER_TLS_ENABLED", "Peer TLS enabled", "Peers", "boolean", "Serve the peer API over HTTPS with an automatically generated local certificate.", true),
174
+ setting("NORDRELAY_PEER_REQUIRE_TLS", "Require peer TLS", "Peers", "boolean", "Reject plaintext peer serving on non-loopback hosts.", true),
131
175
  setting("OPENAI_API_KEY", "OpenAI API key", "Voice", "secret", "Whisper fallback API key.", true),
132
176
  setting("VOICE_PREFERRED_BACKEND", "Voice backend", "Voice", "string", "auto, parakeet, faster-whisper, or openai.", false, ["auto", "parakeet", "faster-whisper", "openai"]),
133
177
  setting("VOICE_DEFAULT_LANGUAGE", "Voice language", "Voice", "string", "Default transcription language.", false),
@@ -159,6 +203,21 @@ const EXAMPLE_VALUES = {
159
203
  "DISCORD_NOTIFY_MODE": "",
160
204
  "DISCORD_QUIET_HOURS": "",
161
205
  "DISCORD_AUTO_SEND_ARTIFACTS": "",
206
+ "SLACK_ENABLED": "false",
207
+ "SLACK_BOT_TOKEN": "",
208
+ "SLACK_APP_TOKEN": "",
209
+ "SLACK_SIGNING_SECRET": "",
210
+ "SLACK_SOCKET_MODE": "true",
211
+ "SLACK_PORT": "3000",
212
+ "SLACK_ALLOWED_TEAM_IDS": "",
213
+ "SLACK_ALLOWED_CHANNEL_IDS": "",
214
+ "SLACK_MESSAGE_CONTENT_ENABLED": "true",
215
+ "SLACK_COMMAND": "/nordrelay",
216
+ "SLACK_CLI_MIRROR_MODE": "",
217
+ "SLACK_CLI_MIRROR_MIN_UPDATE_MS": "",
218
+ "SLACK_NOTIFY_MODE": "",
219
+ "SLACK_QUIET_HOURS": "",
220
+ "SLACK_AUTO_SEND_ARTIFACTS": "",
162
221
  "NORDRELAY_CODEX_ENABLED": "true",
163
222
  "NORDRELAY_PI_ENABLED": "false",
164
223
  "NORDRELAY_HERMES_ENABLED": "false",
@@ -243,6 +302,13 @@ const EXAMPLE_VALUES = {
243
302
  "NORDRELAY_UNIFIED_JOB_MAX_ITEMS": "1000",
244
303
  "NORDRELAY_VERSION_CACHE_TTL_MS": "3600000",
245
304
  "NORDRELAY_CLI_VERSION_CACHE_TTL_MS": "60000",
305
+ "NORDRELAY_PEER_ENABLED": "false",
306
+ "NORDRELAY_PEER_NAME": "",
307
+ "NORDRELAY_PEER_HOST": "127.0.0.1",
308
+ "NORDRELAY_PEER_PORT": "31979",
309
+ "NORDRELAY_PEER_PUBLIC_URL": "",
310
+ "NORDRELAY_PEER_TLS_ENABLED": "true",
311
+ "NORDRELAY_PEER_REQUIRE_TLS": "true",
246
312
  "NORDRELAY_DASHBOARD_HOST": "127.0.0.1",
247
313
  "NORDRELAY_DASHBOARD_PORT": "31878",
248
314
  "NORDRELAY_ENV_FILE": "",
@@ -262,6 +328,7 @@ const EXAMPLE_VALUES = {
262
328
  const GROUP_INTROS = {
263
329
  Telegram: "Telegram bot and transport settings.",
264
330
  Discord: "Discord bot settings. Discord is opt-in and uses the same NordRelay users, groups, and permissions as Telegram.",
331
+ Slack: "Slack bot settings. Slack is opt-in and uses the same NordRelay users, groups, and permissions as Telegram and Discord.",
265
332
  Agents: "Agent access. Codex is enabled by default; Pi, Hermes, OpenClaw, and Claude Code are opt-in.",
266
333
  Codex: "Codex defaults for newly created or reattached sessions.",
267
334
  Pi: "Pi coding agent defaults.",
@@ -271,6 +338,7 @@ const GROUP_INTROS = {
271
338
  Operations: "Runtime output, logging, update, and Telegram behavior controls.",
272
339
  Artifacts: "File, artifact, and retention controls.",
273
340
  Workspace: "State and workspace guardrails.",
341
+ Peers: "Optional NordRelay-to-NordRelay federation. Pairing is explicit, authenticated, scoped, and TLS-protected.",
274
342
  Voice: "Optional voice transcription settings.",
275
343
  Dashboard: "Local WebUI dashboard. User login is required for every page, API route, SSE stream, artifact download, and health endpoint.",
276
344
  };
@@ -303,3 +371,9 @@ function setting(key, label, group, kind, description, restartRequired, options,
303
371
  function discordSetting(key, label, kind, description, restartRequired, options) {
304
372
  return setting(key, label, "Discord", kind, description, restartRequired, options, DISCORD_SETTING_HELP[key]);
305
373
  }
374
+ function telegramSetting(key, label, kind, description, restartRequired, options) {
375
+ return setting(key, label, "Telegram", kind, description, restartRequired, options, TELEGRAM_SETTING_HELP[key]);
376
+ }
377
+ function slackSetting(key, label, kind, description, restartRequired, options) {
378
+ return setting(key, label, "Slack", kind, description, restartRequired, options, SLACK_SETTING_HELP[key]);
379
+ }