@nordbyte/nordrelay 0.4.1 → 0.5.1

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 +155 -64
  2. package/README.md +81 -65
  3. package/dist/access-control.js +126 -115
  4. package/dist/agent-updates.js +62 -9
  5. package/dist/bot-rendering.js +838 -0
  6. package/dist/bot-ui.js +1 -0
  7. package/dist/bot.js +342 -2498
  8. package/dist/channel-actions.js +8 -8
  9. package/dist/channel-runtime.js +89 -0
  10. package/dist/config-metadata.js +238 -0
  11. package/dist/config.js +0 -58
  12. package/dist/index.js +8 -0
  13. package/dist/operations.js +63 -9
  14. package/dist/relay-artifact-service.js +126 -0
  15. package/dist/relay-external-activity-monitor.js +216 -0
  16. package/dist/relay-queue-service.js +66 -0
  17. package/dist/relay-runtime-types.js +1 -0
  18. package/dist/relay-runtime.js +96 -354
  19. package/dist/settings-service.js +2 -117
  20. package/dist/support-bundle.js +205 -0
  21. package/dist/telegram-access-commands.js +123 -0
  22. package/dist/telegram-access-middleware.js +129 -0
  23. package/dist/telegram-agent-commands.js +212 -0
  24. package/dist/telegram-artifact-commands.js +139 -0
  25. package/dist/telegram-channel-runtime.js +132 -0
  26. package/dist/telegram-command-menu.js +55 -0
  27. package/dist/telegram-command-types.js +1 -0
  28. package/dist/telegram-diagnostics-command.js +102 -0
  29. package/dist/telegram-general-commands.js +52 -0
  30. package/dist/telegram-operational-commands.js +153 -0
  31. package/dist/telegram-output.js +216 -0
  32. package/dist/telegram-preference-commands.js +198 -0
  33. package/dist/telegram-queue-commands.js +278 -0
  34. package/dist/telegram-support-command.js +53 -0
  35. package/dist/telegram-update-commands.js +93 -0
  36. package/dist/user-management.js +708 -0
  37. package/dist/web-api-contract.js +104 -0
  38. package/dist/web-api-types.js +1 -0
  39. package/dist/web-dashboard-access-routes.js +163 -0
  40. package/dist/web-dashboard-artifact-routes.js +65 -0
  41. package/dist/web-dashboard-assets.js +35 -2
  42. package/dist/web-dashboard-http.js +143 -0
  43. package/dist/web-dashboard-pages.js +257 -0
  44. package/dist/web-dashboard-runtime-routes.js +92 -0
  45. package/dist/web-dashboard-session-routes.js +209 -0
  46. package/dist/web-dashboard-ui.js +14 -14
  47. package/dist/web-dashboard.js +330 -707
  48. package/dist/webui-assets/dashboard.css +989 -0
  49. package/dist/webui-assets/dashboard.js +1750 -0
  50. package/dist/zip-writer.js +83 -0
  51. package/package.json +13 -4
  52. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  53. package/plugins/nordrelay/commands/remote.md +1 -1
  54. package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
  55. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
  56. package/dist/web-dashboard-client.js +0 -275
  57. package/dist/web-dashboard-style.js +0 -9
@@ -1,119 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- const SECRET_KEYS = new Set([
4
- "TELEGRAM_BOT_TOKEN",
5
- "CODEX_API_KEY",
6
- "HERMES_API_KEY",
7
- "OPENCLAW_GATEWAY_TOKEN",
8
- "OPENCLAW_GATEWAY_PASSWORD",
9
- "OPENAI_API_KEY",
10
- "TELEGRAM_WEBHOOK_SECRET",
11
- "NORDRELAY_DASHBOARD_TOKEN",
12
- "NORDRELAY_DASHBOARD_PASSWORD",
13
- ]);
14
- export const SETTING_DEFINITIONS = [
15
- setting("TELEGRAM_BOT_TOKEN", "Telegram bot token", "Telegram", "secret", "BotFather token.", true),
16
- setting("TELEGRAM_ADMIN_USER_IDS", "Telegram admin user IDs", "Telegram", "list", "Comma-separated Telegram users allowed to administer and use the bot.", true),
17
- setting("TELEGRAM_ALLOWED_USER_IDS", "Allowed operator user IDs", "Telegram", "list", "Optional non-admin operators.", true),
18
- setting("TELEGRAM_READONLY_USER_IDS", "Readonly user IDs", "Telegram", "list", "Users allowed to inspect but not mutate.", true),
19
- setting("TELEGRAM_ALLOWED_CHAT_IDS", "Allowed chat IDs", "Telegram", "list", "Optional chat allowlist.", true),
20
- setting("TELEGRAM_ALLOW_ANY_CHAT", "Allow any Telegram chat", "Telegram", "boolean", "Unsafe override; keep off for normal use.", true),
21
- setting("TELEGRAM_ROLE_POLICIES_JSON", "Role policy JSON", "Telegram", "json", "Granular Telegram permission policy.", true),
22
- setting("TELEGRAM_TRANSPORT", "Telegram transport", "Telegram", "string", "polling or webhook.", true, ["polling", "webhook"]),
23
- setting("TELEGRAM_WEBHOOK_URL", "Webhook public URL", "Telegram", "string", "Public base URL for webhook mode.", true),
24
- setting("TELEGRAM_WEBHOOK_HOST", "Webhook bind host", "Telegram", "string", "Local webhook bind host.", true),
25
- setting("TELEGRAM_WEBHOOK_PORT", "Webhook bind port", "Telegram", "number", "Local webhook bind port.", true),
26
- setting("TELEGRAM_WEBHOOK_PATH", "Webhook path", "Telegram", "string", "Webhook request path.", true),
27
- setting("TELEGRAM_WEBHOOK_SECRET", "Webhook secret", "Telegram", "secret", "Optional Telegram webhook secret token.", true),
28
- setting("NORDRELAY_CODEX_ENABLED", "Enable Codex", "Agents", "boolean", "Allow Codex sessions.", true),
29
- setting("NORDRELAY_PI_ENABLED", "Enable Pi", "Agents", "boolean", "Allow Pi sessions.", true),
30
- setting("NORDRELAY_HERMES_ENABLED", "Enable Hermes", "Agents", "boolean", "Allow Hermes sessions through the Hermes API Server.", true),
31
- setting("NORDRELAY_OPENCLAW_ENABLED", "Enable OpenClaw", "Agents", "boolean", "Allow OpenClaw sessions through the OpenClaw Gateway.", true),
32
- setting("NORDRELAY_CLAUDE_CODE_ENABLED", "Enable Claude Code", "Agents", "boolean", "Allow Claude Code sessions through the Claude Agent SDK.", true),
33
- setting("NORDRELAY_DEFAULT_AGENT", "Default agent", "Agents", "string", "codex, pi, hermes, openclaw, or claude-code.", true, ["codex", "pi", "hermes", "openclaw", "claude-code"]),
34
- setting("CODEX_API_KEY", "Codex API key", "Codex", "secret", "Optional Codex SDK API key.", true),
35
- setting("CODEX_CLI_PATH", "Codex CLI path", "Codex", "string", "Optional explicit Codex executable path.", true),
36
- setting("CODEX_USE_BUNDLED_CLI", "Use bundled Codex CLI", "Codex", "boolean", "Force SDK-bundled CLI instead of host CLI.", true),
37
- setting("CODEX_MODEL", "Default Codex model", "Codex", "string", "Default model for new Codex threads.", false),
38
- setting("CODEX_SYNC_INTERVAL_MS", "Codex sync interval", "Codex", "number", "Local state sync interval.", true),
39
- setting("CODEX_EXTERNAL_BUSY_CHECK_MS", "External busy check", "Codex", "number", "External CLI busy polling interval.", true),
40
- setting("CODEX_EXTERNAL_BUSY_STALE_MS", "External busy stale timeout", "Codex", "number", "External CLI stale timeout.", true),
41
- setting("CODEX_SANDBOX_MODE", "Codex sandbox mode", "Codex", "string", "read-only, workspace-write, or danger-full-access.", true, ["read-only", "workspace-write", "danger-full-access"]),
42
- setting("CODEX_APPROVAL_POLICY", "Codex approval policy", "Codex", "string", "never, on-request, on-failure, or untrusted.", true, ["never", "on-request", "on-failure", "untrusted"]),
43
- setting("CODEX_LAUNCH_PROFILES_JSON", "Launch profiles JSON", "Codex", "json", "Additional launch profile definitions.", true),
44
- setting("CODEX_DEFAULT_LAUNCH_PROFILE", "Default launch profile", "Codex", "string", "Launch profile ID used by default.", true),
45
- setting("ENABLE_UNSAFE_LAUNCH_PROFILES", "Enable unsafe profiles", "Codex", "boolean", "Expose danger-full-access profiles.", true),
46
- setting("PI_CLI_PATH", "Pi CLI path", "Pi", "string", "Optional Pi executable path.", true),
47
- setting("PI_SESSION_DIR", "Pi session dir", "Pi", "string", "Optional Pi session directory.", true),
48
- setting("PI_DEFAULT_MODEL", "Default Pi model", "Pi", "string", "Default Pi model slug.", false),
49
- setting("PI_DEFAULT_THINKING", "Default Pi thinking", "Pi", "string", "off, minimal, low, medium, high, or xhigh.", false, ["off", "minimal", "low", "medium", "high", "xhigh"]),
50
- setting("PI_DEFAULT_PROFILE", "Default Pi profile", "Pi", "string", "default, readonly, no-tools, offline, or safe-offline.", true, ["default", "readonly", "no-tools", "offline", "safe-offline"]),
51
- setting("HERMES_CLI_PATH", "Hermes CLI path", "Hermes", "string", "Optional Hermes executable path.", true),
52
- setting("HERMES_HOME", "Hermes home", "Hermes", "string", "Optional Hermes home directory. Defaults to ~/.hermes.", true),
53
- setting("HERMES_STATE_DB_PATH", "Hermes state DB path", "Hermes", "string", "Optional explicit Hermes state.db path.", true),
54
- setting("HERMES_API_BASE_URL", "Hermes API base URL", "Hermes", "string", "Hermes API Server base URL.", true),
55
- setting("HERMES_API_KEY", "Hermes API key", "Hermes", "secret", "Bearer token for the Hermes API Server.", true),
56
- setting("HERMES_DEFAULT_MODEL", "Default Hermes model", "Hermes", "string", "Default model label sent to Hermes API runs.", false),
57
- setting("HERMES_DEFAULT_REASONING", "Default Hermes reasoning", "Hermes", "string", "none, minimal, low, medium, high, or xhigh.", false, ["none", "minimal", "low", "medium", "high", "xhigh"]),
58
- setting("HERMES_DEFAULT_PROFILE", "Default Hermes profile", "Hermes", "string", "default, safe, readonly, or yolo.", true, ["default", "safe", "readonly", "yolo"]),
59
- setting("OPENCLAW_CLI_PATH", "OpenClaw CLI path", "OpenClaw", "string", "Optional OpenClaw executable path.", true),
60
- setting("OPENCLAW_GATEWAY_URL", "OpenClaw Gateway URL", "OpenClaw", "string", "OpenClaw Gateway WebSocket URL.", true),
61
- setting("OPENCLAW_GATEWAY_TOKEN", "OpenClaw Gateway token", "OpenClaw", "secret", "Shared-secret token for the OpenClaw Gateway.", true),
62
- setting("OPENCLAW_GATEWAY_PASSWORD", "OpenClaw Gateway password", "OpenClaw", "secret", "Shared-secret password for the OpenClaw Gateway.", true),
63
- setting("OPENCLAW_AGENT_ID", "OpenClaw agent ID", "OpenClaw", "string", "Configured OpenClaw agent id, for example main or work.", false),
64
- setting("OPENCLAW_HOME", "OpenClaw home", "OpenClaw", "string", "Optional OpenClaw home directory. Defaults to ~/.openclaw.", true),
65
- setting("OPENCLAW_STATE_DIR", "OpenClaw state dir", "OpenClaw", "string", "Optional OpenClaw state directory.", true),
66
- setting("OPENCLAW_DEFAULT_MODEL", "Default OpenClaw model", "OpenClaw", "string", "Default OpenClaw model id.", false),
67
- setting("OPENCLAW_DEFAULT_THINKING", "Default OpenClaw thinking", "OpenClaw", "string", "off, minimal, low, medium, high, or xhigh.", false, ["off", "minimal", "low", "medium", "high", "xhigh"]),
68
- setting("OPENCLAW_DEFAULT_PROFILE", "Default OpenClaw profile", "OpenClaw", "string", "default, safe, readonly, local, or deliver.", true, ["default", "safe", "readonly", "local", "deliver"]),
69
- setting("CLAUDE_CODE_CLI_PATH", "Claude Code CLI path", "Claude Code", "string", "Optional Claude Code executable path. Defaults to claude on PATH or the SDK bundled runtime.", true),
70
- setting("CLAUDE_CONFIG_DIR", "Claude config dir", "Claude Code", "string", "Optional Claude config directory. Defaults to ~/.claude.", true),
71
- setting("CLAUDE_CODE_DEFAULT_MODEL", "Default Claude Code model", "Claude Code", "string", "Default Claude Code model alias or model id.", false),
72
- setting("CLAUDE_CODE_DEFAULT_EFFORT", "Default Claude Code effort", "Claude Code", "string", "off, low, medium, high, or xhigh.", false, ["off", "low", "medium", "high", "xhigh"]),
73
- setting("CLAUDE_CODE_DEFAULT_PROFILE", "Default Claude Code profile", "Claude Code", "string", "default, accept-edits, plan, readonly, no-tools, or bypass-permissions.", true, ["default", "accept-edits", "plan", "readonly", "no-tools", "bypass-permissions"]),
74
- setting("CLAUDE_CODE_MAX_TURNS", "Claude Code max turns", "Claude Code", "number", "Maximum agentic turns for each Claude Code prompt.", false),
75
- setting("CONNECTOR_LOG_FORMAT", "Log format", "Operations", "string", "text or json.", true, ["text", "json"]),
76
- setting("TOOL_VERBOSITY", "Tool verbosity", "Operations", "string", "all, summary, errors-only, or none.", false, ["all", "summary", "errors-only", "none"]),
77
- setting("SHOW_TURN_TOKEN_USAGE", "Show turn token usage", "Operations", "boolean", "Append per-turn token usage.", false),
78
- setting("ENABLE_TELEGRAM_LOGIN", "Enable Telegram login", "Operations", "boolean", "Allow /login and /logout.", true),
79
- setting("ENABLE_TELEGRAM_REACTIONS", "Enable Telegram reactions", "Operations", "boolean", "Send Telegram reactions.", true),
80
- setting("TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS", "Telegram send interval", "Operations", "number", "Minimum send interval.", true),
81
- setting("TELEGRAM_EDIT_MIN_INTERVAL_MS", "Telegram edit interval", "Operations", "number", "Minimum edit interval.", true),
82
- setting("TELEGRAM_CLI_MIRROR_MODE", "CLI mirror mode", "Operations", "string", "off, status, final, or full.", false, ["off", "status", "final", "full"]),
83
- setting("TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS", "CLI mirror update interval", "Operations", "number", "Minimum mirrored edit interval.", true),
84
- setting("TELEGRAM_NOTIFY_MODE", "Notify mode", "Operations", "string", "off, minimal, or all.", false, ["off", "minimal", "all"]),
85
- setting("TELEGRAM_QUIET_HOURS", "Quiet hours", "Operations", "string", "HH-HH or blank.", false),
86
- setting("TELEGRAM_REDACT_PATTERNS", "Redaction patterns", "Operations", "list", "Additional comma-separated regex patterns.", true),
87
- setting("NORDRELAY_UPDATE_METHOD", "Update method", "Operations", "string", "auto, npm, or git.", true, ["auto", "npm", "git"]),
88
- setting("MAX_FILE_SIZE", "Max file size", "Artifacts", "number", "Max inbound/outbound file size.", true),
89
- setting("ARTIFACT_RETENTION_DAYS", "Artifact retention days", "Artifacts", "number", "Days before pruning.", true),
90
- setting("ARTIFACT_MAX_TURNS", "Max artifact turns", "Artifacts", "number", "Maximum artifact turns retained.", true),
91
- setting("ARTIFACT_MAX_INBOX_DIRS", "Max inbox dirs", "Artifacts", "number", "Maximum inbox dirs retained.", true),
92
- setting("ARTIFACT_IGNORE_DIRS", "Artifact ignore dirs", "Artifacts", "list", "Extra ignored dirs or relative paths.", true),
93
- setting("ARTIFACT_IGNORE_GLOBS", "Artifact ignore globs", "Artifacts", "list", "Extra ignored glob patterns.", true),
94
- setting("TELEGRAM_AUTO_SEND_ARTIFACTS", "Auto-send artifacts", "Artifacts", "boolean", "Automatically send artifact files.", false),
95
- setting("WORKSPACE_ALLOWED_ROOTS", "Workspace allowed roots", "Workspace", "list", "Restrict selectable workspaces.", true),
96
- setting("WORKSPACE_WARN_ROOTS", "Workspace warn roots", "Workspace", "list", "Warn for broad workspace roots.", true),
97
- setting("NORDRELAY_STATE_BACKEND", "State backend", "Workspace", "string", "json or sqlite.", true, ["json", "sqlite"]),
98
- setting("NORDRELAY_AUDIT_MAX_EVENTS", "Audit max events", "Workspace", "number", "Retained audit events.", true),
99
- setting("NORDRELAY_SESSION_LOCK_TTL_MS", "Session lock TTL", "Workspace", "number", "Write-lock TTL.", true),
100
- setting("NORDRELAY_VERSION_CACHE_TTL_MS", "Version cache TTL", "Workspace", "number", "NPM version cache TTL.", true),
101
- setting("OPENAI_API_KEY", "OpenAI API key", "Voice", "secret", "Whisper fallback API key.", true),
102
- setting("VOICE_PREFERRED_BACKEND", "Voice backend", "Voice", "string", "auto, parakeet, faster-whisper, or openai.", false, ["auto", "parakeet", "faster-whisper", "openai"]),
103
- setting("VOICE_DEFAULT_LANGUAGE", "Voice language", "Voice", "string", "Default transcription language.", false),
104
- setting("VOICE_TRANSCRIBE_ONLY", "Voice transcribe only", "Voice", "boolean", "Do not send voice transcripts as prompts.", false),
105
- setting("FASTER_WHISPER_PYTHON", "faster-whisper Python", "Voice", "string", "Python executable.", true),
106
- setting("FASTER_WHISPER_MODEL", "faster-whisper model", "Voice", "string", "Model name.", true),
107
- setting("FASTER_WHISPER_DEVICE", "faster-whisper device", "Voice", "string", "cpu, cuda, etc.", true),
108
- setting("FASTER_WHISPER_COMPUTE_TYPE", "faster-whisper compute type", "Voice", "string", "int8, float16, etc.", true),
109
- setting("FASTER_WHISPER_LANGUAGE", "faster-whisper language", "Voice", "string", "Fixed transcription language.", true),
110
- setting("FASTER_WHISPER_TIMEOUT_MS", "faster-whisper timeout", "Voice", "number", "Transcription timeout.", true),
111
- setting("NORDRELAY_DASHBOARD_TOKEN", "Dashboard token", "Dashboard", "secret", "Bearer/login token for WebUI.", true),
112
- setting("NORDRELAY_DASHBOARD_USER", "Dashboard user", "Dashboard", "string", "Optional Basic Auth user.", true),
113
- setting("NORDRELAY_DASHBOARD_PASSWORD", "Dashboard password", "Dashboard", "secret", "Optional Basic Auth password.", true),
114
- setting("NORDRELAY_DASHBOARD_HOST", "Dashboard host", "Dashboard", "string", "WebUI bind host.", true),
115
- setting("NORDRELAY_DASHBOARD_PORT", "Dashboard port", "Dashboard", "number", "WebUI bind port.", true),
116
- ];
3
+ import { SECRET_KEYS, SETTING_DEFINITIONS } from "./config-metadata.js";
4
+ export { SETTING_DEFINITIONS } from "./config-metadata.js";
117
5
  export class SettingsService {
118
6
  envPath;
119
7
  constructor(envPath) {
@@ -193,9 +81,6 @@ export function maskSecret(value) {
193
81
  }
194
82
  return `${value.slice(0, 4)}...${value.slice(-4)}`;
195
83
  }
196
- function setting(key, label, group, kind, description, restartRequired, options) {
197
- return { key, label, group, kind, description, restartRequired, options };
198
- }
199
84
  function validateSettingValue(definition, value) {
200
85
  if (definition.kind === "number" && !Number.isFinite(Number(value))) {
201
86
  return "Must be a number.";
@@ -0,0 +1,205 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdir, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { getAgentUpdateLogPath, getConnectorHealth, getConnectorHome, getConnectorLogPath, getConnectorStatePath, getSourceRoot, getUpdateLogPath, getVersionChecks, readFormattedLogTail, resolveNpmSpawnCommand, } from "./operations.js";
6
+ import { redactText } from "./redaction.js";
7
+ import { createZipBuffer } from "./zip-writer.js";
8
+ export async function createSupportBundle(options) {
9
+ const createdAt = new Date();
10
+ const health = options.health ?? await getConnectorHealth(cliPathOptions(options.config));
11
+ const versionChecks = options.versionChecks ?? await getVersionChecks(cliPathOptions(options.config));
12
+ const entries = [];
13
+ addJson(entries, "manifest.json", {
14
+ createdAt: createdAt.toISOString(),
15
+ source: options.source ?? "web",
16
+ package: "@nordbyte/nordrelay",
17
+ includedFiles: [],
18
+ });
19
+ addJson(entries, "config/redacted-config.json", redactValue(options.config));
20
+ addJson(entries, "config/relevant-env.json", redactValue(relevantEnvironment()));
21
+ addJson(entries, "runtime/health.json", redactValue(health));
22
+ addJson(entries, "runtime/version-checks.json", redactValue(versionChecks));
23
+ addJson(entries, "runtime/state-backend.json", {
24
+ stateBackend: options.config.stateBackend,
25
+ stateFile: getConnectorStatePath(),
26
+ connectorHome: getConnectorHome(),
27
+ sourceRoot: getSourceRoot(),
28
+ workspace: options.config.workspace,
29
+ databasePath: health.databasePath,
30
+ });
31
+ addJson(entries, "runtime/agent-paths.json", agentPaths(health));
32
+ addJson(entries, "system/info.json", systemInfo());
33
+ if (options.diagnostics) {
34
+ addJson(entries, "runtime/diagnostics.json", redactValue(options.diagnostics));
35
+ }
36
+ if (options.adapterHealth) {
37
+ addJson(entries, "runtime/adapter-health.json", redactValue(options.adapterHealth));
38
+ }
39
+ if (options.auditEvents) {
40
+ addJson(entries, "audit/recent-events.json", redactValue(options.auditEvents));
41
+ }
42
+ if (options.agentUpdateJobs) {
43
+ addJson(entries, "updates/jobs.json", redactValue(options.agentUpdateJobs));
44
+ }
45
+ await addLog(entries, "logs/connector.log", getConnectorLogPath());
46
+ await addLog(entries, "logs/nordrelay-update.log", getUpdateLogPath());
47
+ await addLog(entries, "logs/agent-updates.log", getAgentUpdateLogPath());
48
+ const includedFiles = entries.map((entry) => entry.name);
49
+ entries[0] = {
50
+ name: "manifest.json",
51
+ data: jsonText({
52
+ createdAt: createdAt.toISOString(),
53
+ source: options.source ?? "web",
54
+ package: "@nordbyte/nordrelay",
55
+ includedFiles,
56
+ }),
57
+ date: createdAt,
58
+ };
59
+ const buffer = createZipBuffer(entries);
60
+ const name = `nordrelay-diagnostics-${formatTimestamp(createdAt)}.zip`;
61
+ const supportDir = path.join(getConnectorHome(), "support");
62
+ await mkdir(supportDir, { recursive: true });
63
+ const bundlePath = path.join(supportDir, name);
64
+ await writeFile(bundlePath, buffer);
65
+ const stats = await stat(bundlePath);
66
+ return {
67
+ path: bundlePath,
68
+ name,
69
+ sizeBytes: stats.size,
70
+ createdAt: createdAt.toISOString(),
71
+ includedFiles,
72
+ };
73
+ }
74
+ function cliPathOptions(config) {
75
+ return {
76
+ piCliPath: config.piCliPath,
77
+ hermesCliPath: config.hermesCliPath,
78
+ openClawCliPath: config.openClawCliPath,
79
+ claudeCodeCliPath: config.claudeCodeCliPath,
80
+ };
81
+ }
82
+ function addJson(entries, name, value) {
83
+ entries.push({ name, data: jsonText(value) });
84
+ }
85
+ async function addLog(entries, name, filePath) {
86
+ const tail = await readFormattedLogTail(300, filePath);
87
+ entries.push({
88
+ name,
89
+ data: [
90
+ `File: ${tail.filePath}`,
91
+ `Updated: ${tail.updatedAt ? tail.updatedAt.toISOString() : "-"}`,
92
+ `Lines: ${tail.lineCount}/${tail.requestedLines}`,
93
+ "",
94
+ tail.plain || "(empty)",
95
+ ].join("\n"),
96
+ });
97
+ }
98
+ function jsonText(value) {
99
+ return `${JSON.stringify(value, null, 2)}\n`;
100
+ }
101
+ function redactValue(value) {
102
+ if (Array.isArray(value)) {
103
+ return value.map((item) => redactValue(item));
104
+ }
105
+ if (!value || typeof value !== "object") {
106
+ return typeof value === "string" ? redactText(value) : value;
107
+ }
108
+ const output = {};
109
+ for (const [key, rawValue] of Object.entries(value)) {
110
+ output[key] = isSecretKey(key) ? "[REDACTED]" : redactValue(rawValue);
111
+ }
112
+ return output;
113
+ }
114
+ function isSecretKey(key) {
115
+ return /(token|secret|password|authorization|api[_-]?key|apikey|botToken|webhookSecret|gatewayPassword)/i.test(key);
116
+ }
117
+ function relevantEnvironment() {
118
+ const prefixes = [
119
+ "NORDRELAY_",
120
+ "TELEGRAM_",
121
+ "CODEX_",
122
+ "PI_",
123
+ "HERMES_",
124
+ "OPENCLAW_",
125
+ "CLAUDE_",
126
+ "WORKSPACE_",
127
+ "ARTIFACT_",
128
+ "VOICE_",
129
+ "FASTER_WHISPER_",
130
+ ];
131
+ const exact = new Set(["MAX_FILE_SIZE", "TOOL_VERBOSITY", "CONNECTOR_LOG_FORMAT", "NODE_ENV"]);
132
+ return Object.fromEntries(Object.entries(process.env)
133
+ .filter(([key, value]) => value !== undefined && (exact.has(key) || prefixes.some((prefix) => key.startsWith(prefix))))
134
+ .sort(([left], [right]) => left.localeCompare(right))
135
+ .map(([key, value]) => [key, value ?? ""]));
136
+ }
137
+ function agentPaths(health) {
138
+ return {
139
+ codex: { label: health.codexCli, path: health.codexCliPath, version: health.codexCliVersion },
140
+ pi: { label: health.piCli, path: health.piCliPath, version: health.piCliVersion },
141
+ hermes: { label: health.hermesCli, path: health.hermesCliPath, version: health.hermesCliVersion },
142
+ openclaw: { label: health.openClawCli, path: health.openClawCliPath, version: health.openClawCliVersion },
143
+ "claude-code": { label: health.claudeCodeCli, path: health.claudeCodeCliPath, version: health.claudeCodeCliVersion },
144
+ };
145
+ }
146
+ function systemInfo() {
147
+ const npm = resolveNpmSpawnCommand();
148
+ return {
149
+ os: {
150
+ platform: os.platform(),
151
+ release: os.release(),
152
+ arch: os.arch(),
153
+ type: os.type(),
154
+ homedir: os.homedir(),
155
+ tmpdir: os.tmpdir(),
156
+ cpus: os.cpus().length,
157
+ totalMemoryBytes: os.totalmem(),
158
+ freeMemoryBytes: os.freemem(),
159
+ uptimeSeconds: os.uptime(),
160
+ },
161
+ node: {
162
+ executable: process.execPath,
163
+ version: process.version,
164
+ versions: process.versions,
165
+ argv: process.argv,
166
+ cwd: process.cwd(),
167
+ pid: process.pid,
168
+ uptimeSeconds: process.uptime(),
169
+ },
170
+ npm: npm ? {
171
+ command: npm.display,
172
+ version: detectNpmVersion(npm),
173
+ } : {
174
+ command: null,
175
+ version: null,
176
+ error: "npm not found",
177
+ },
178
+ };
179
+ }
180
+ function detectNpmVersion(npm) {
181
+ const result = spawnSync(npm.command, [...npm.argsPrefix, "--version"], {
182
+ encoding: "utf8",
183
+ shell: npm.shell,
184
+ timeout: 3000,
185
+ windowsHide: true,
186
+ });
187
+ if (result.error || result.status !== 0) {
188
+ return null;
189
+ }
190
+ return String(result.stdout || "").trim() || null;
191
+ }
192
+ function formatTimestamp(date) {
193
+ return [
194
+ date.getFullYear(),
195
+ pad2(date.getMonth() + 1),
196
+ pad2(date.getDate()),
197
+ "-",
198
+ pad2(date.getHours()),
199
+ pad2(date.getMinutes()),
200
+ pad2(date.getSeconds()),
201
+ ].join("");
202
+ }
203
+ function pad2(value) {
204
+ return String(value).padStart(2, "0");
205
+ }
@@ -0,0 +1,123 @@
1
+ import { consumeRateLimit, resetRateLimit } from "./bot-rendering.js";
2
+ import { friendlyErrorText } from "./error-messages.js";
3
+ import { escapeHTML } from "./format.js";
4
+ import { safeReply } from "./telegram-output.js";
5
+ export function registerTelegramAccessCommands(deps) {
6
+ const { bot, userStore, contextUsers, linkAttempts, audit, getUserRole } = deps;
7
+ bot.command("link", async (ctx) => {
8
+ if (ctx.chat?.type !== "private") {
9
+ await safeReply(ctx, escapeHTML("Use /link in a private chat with the bot."), {
10
+ fallbackText: "Use /link in a private chat with the bot.",
11
+ });
12
+ return;
13
+ }
14
+ const code = (ctx.message?.text ?? "").replace(/^\/link(?:@\w+)?\s*/i, "").trim();
15
+ if (!code) {
16
+ await safeReply(ctx, escapeHTML("Send /link <code> after creating a Telegram link code in the WebUI or CLI."), {
17
+ fallbackText: "Send /link <code> after creating a Telegram link code in the WebUI or CLI.",
18
+ });
19
+ return;
20
+ }
21
+ if (!ctx.from?.id) {
22
+ return;
23
+ }
24
+ const limitKey = String(ctx.from.id);
25
+ const limited = consumeRateLimit(linkAttempts, limitKey, 5, 15 * 60 * 1000, 15 * 60 * 1000);
26
+ if (limited.limited) {
27
+ const seconds = Math.ceil((limited.retryAfterMs ?? 0) / 1000);
28
+ audit({
29
+ action: "auth_login_failed",
30
+ status: "denied",
31
+ contextKey: String(ctx.chat.id),
32
+ actorId: ctx.from.id,
33
+ description: "Telegram link rate limited",
34
+ detail: `${seconds}s retry-after`,
35
+ });
36
+ await safeReply(ctx, escapeHTML(`Too many link attempts. Try again in ${seconds}s.`), {
37
+ fallbackText: `Too many link attempts. Try again in ${seconds}s.`,
38
+ });
39
+ return;
40
+ }
41
+ try {
42
+ const linked = userStore.consumeTelegramLinkCode(code, {
43
+ telegramUserId: ctx.from.id,
44
+ username: ctx.from.username,
45
+ firstName: ctx.from.first_name,
46
+ lastName: ctx.from.last_name,
47
+ });
48
+ resetRateLimit(linkAttempts, limitKey);
49
+ contextUsers.set(ctx, linked);
50
+ audit({
51
+ action: "telegram_linked",
52
+ status: "ok",
53
+ contextKey: String(ctx.chat.id),
54
+ actorId: ctx.from.id,
55
+ actorRole: linked.groups.map((group) => group.name).join(", "),
56
+ description: `Linked ${linked.user.email}`,
57
+ });
58
+ await safeReply(ctx, escapeHTML(`Linked Telegram account to ${linked.user.email}.`), {
59
+ fallbackText: `Linked Telegram account to ${linked.user.email}.`,
60
+ });
61
+ }
62
+ catch (error) {
63
+ const message = friendlyErrorText(error);
64
+ audit({
65
+ action: "auth_login_failed",
66
+ status: "failed",
67
+ contextKey: String(ctx.chat.id),
68
+ actorId: ctx.from.id,
69
+ description: "Telegram link failed",
70
+ detail: message,
71
+ });
72
+ await safeReply(ctx, `<b>Link failed:</b> ${escapeHTML(message)}`, { fallbackText: `Link failed: ${message}` });
73
+ }
74
+ });
75
+ bot.command("whoami", async (ctx) => {
76
+ const authUser = contextUsers.get(ctx);
77
+ if (!authUser) {
78
+ await safeReply(ctx, escapeHTML("Not linked."), { fallbackText: "Not linked." });
79
+ return;
80
+ }
81
+ const text = [
82
+ `User: ${authUser.user.displayName} <${authUser.user.email}>`,
83
+ `Groups: ${authUser.groups.map((group) => group.name).join(", ") || "-"}`,
84
+ `Permissions: ${authUser.permissions.join(", ") || "-"}`,
85
+ ].join("\n");
86
+ await safeReply(ctx, `<b>User:</b> ${escapeHTML(authUser.user.displayName)}\n<b>Email:</b> <code>${escapeHTML(authUser.user.email)}</code>\n<b>Groups:</b> <code>${escapeHTML(authUser.groups.map((group) => group.name).join(", ") || "-")}</code>`, {
87
+ fallbackText: text,
88
+ });
89
+ });
90
+ bot.command("register_chat", async (ctx) => {
91
+ const authUser = contextUsers.get(ctx);
92
+ if (!authUser || !userStore.hasPermission(authUser, "users.write")) {
93
+ await safeReply(ctx, escapeHTML("Access denied: users.write permission required."), {
94
+ fallbackText: "Access denied: users.write permission required.",
95
+ });
96
+ return;
97
+ }
98
+ if (!ctx.chat?.id || ctx.chat.type === "private") {
99
+ await safeReply(ctx, escapeHTML("Run /register_chat inside a Telegram group or supergroup."), {
100
+ fallbackText: "Run /register_chat inside a Telegram group or supergroup.",
101
+ });
102
+ return;
103
+ }
104
+ const chat = userStore.registerTelegramChat({
105
+ chatId: ctx.chat.id,
106
+ title: "title" in ctx.chat ? ctx.chat.title : undefined,
107
+ type: ctx.chat.type,
108
+ enabled: true,
109
+ allowedGroupIds: [],
110
+ });
111
+ audit({
112
+ action: "telegram_chat_updated",
113
+ status: "ok",
114
+ contextKey: String(ctx.chat.id),
115
+ actorId: ctx.from?.id,
116
+ actorRole: getUserRole(ctx),
117
+ description: `Registered Telegram chat ${chat.chatId}`,
118
+ });
119
+ await safeReply(ctx, escapeHTML(`Telegram chat enabled for NordRelay.\nChat ID: ${chat.chatId}`), {
120
+ fallbackText: `Telegram chat enabled for NordRelay.\nChat ID: ${chat.chatId}`,
121
+ });
122
+ });
123
+ }
@@ -0,0 +1,129 @@
1
+ import { permissionForCallbackData, permissionForCommand } from "./access-control.js";
2
+ import { extractCommandName } from "./bot-rendering.js";
3
+ import { escapeHTML } from "./format.js";
4
+ import { safeReply } from "./telegram-output.js";
5
+ import { UserStore } from "./user-management.js";
6
+ export function createTelegramAccessMiddleware(options) {
7
+ const { userStore, contextUsers, audit } = options;
8
+ return async (ctx, next) => {
9
+ const fromId = ctx.from?.id;
10
+ const chatId = ctx.chat?.id;
11
+ const chatType = ctx.chat?.type;
12
+ const commandName = ctx.message?.text?.startsWith("/") ? extractCommandName(ctx.message.text) : undefined;
13
+ if (commandName === "link") {
14
+ await next();
15
+ return;
16
+ }
17
+ if (!userStore.hasAdminUser()) {
18
+ const message = "NordRelay has no admin user yet. Run `nordrelay user create-admin` on the host.";
19
+ if (ctx.callbackQuery) {
20
+ await ctx.answerCallbackQuery({ text: "No admin user configured" }).catch(() => { });
21
+ }
22
+ else if (ctx.chat) {
23
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
24
+ }
25
+ return;
26
+ }
27
+ const authUser = userStore.resolveTelegramUser(fromId);
28
+ if (!authUser) {
29
+ const message = "Unauthorized. Link this Telegram account to a NordRelay user first.";
30
+ audit({
31
+ action: "permission_denied",
32
+ status: "denied",
33
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
34
+ actorId: fromId,
35
+ description: "Telegram account is not linked",
36
+ });
37
+ if (ctx.callbackQuery) {
38
+ await ctx.answerCallbackQuery({ text: "Unauthorized" }).catch(() => { });
39
+ }
40
+ else if (ctx.chat?.type === "private") {
41
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
42
+ }
43
+ return;
44
+ }
45
+ contextUsers.set(ctx, authUser);
46
+ const chatAllowed = userStore.isTelegramChatAllowed(typeof chatId === "number" ? chatId : undefined, chatType, authUser);
47
+ if (!chatAllowed && commandName !== "register_chat") {
48
+ const message = "This Telegram chat is not enabled for NordRelay. An admin can run /register_chat in this chat.";
49
+ audit({
50
+ action: "permission_denied",
51
+ status: "denied",
52
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
53
+ actorId: fromId,
54
+ actorRole: getUserRole(contextUsers, ctx),
55
+ description: "Telegram chat is not enabled or outside user scope",
56
+ });
57
+ if (ctx.callbackQuery) {
58
+ await ctx.answerCallbackQuery({ text: "Chat not enabled" }).catch(() => { });
59
+ }
60
+ else if (ctx.chat?.type === "private") {
61
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
62
+ }
63
+ return;
64
+ }
65
+ const permission = getRequiredPermission(ctx);
66
+ if (!permission) {
67
+ const message = "Unsupported command or action.";
68
+ audit({
69
+ action: "permission_denied",
70
+ status: "denied",
71
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
72
+ actorId: fromId,
73
+ actorRole: getUserRole(contextUsers, ctx),
74
+ description: commandName ? `Unsupported command /${commandName}` : "Unsupported callback",
75
+ });
76
+ if (ctx.callbackQuery) {
77
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
78
+ }
79
+ else {
80
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
81
+ }
82
+ return;
83
+ }
84
+ if (!userStore.hasPermission(authUser, permission)) {
85
+ const message = `Access denied: ${permission} permission required.`;
86
+ audit({
87
+ action: "permission_denied",
88
+ status: "denied",
89
+ contextKey: typeof chatId === "number" ? String(chatId) : "telegram",
90
+ actorId: fromId,
91
+ actorRole: getUserRole(contextUsers, ctx),
92
+ description: `${permission} required`,
93
+ });
94
+ if (ctx.callbackQuery) {
95
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
96
+ }
97
+ else {
98
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
99
+ }
100
+ return;
101
+ }
102
+ await next();
103
+ };
104
+ }
105
+ function getUserRole(contextUsers, ctx) {
106
+ const authUser = contextUsers.get(ctx);
107
+ return authUser?.groups.map((group) => group.name).join(", ") || "unauthenticated";
108
+ }
109
+ function getRequiredPermission(ctx) {
110
+ if (ctx.callbackQuery?.data) {
111
+ return permissionForCallbackData(ctx.callbackQuery.data);
112
+ }
113
+ if (ctx.message?.voice || ctx.message?.audio || ctx.message?.photo || ctx.message?.document) {
114
+ return "files.write";
115
+ }
116
+ const text = ctx.message?.text?.trim();
117
+ if (!text) {
118
+ return "inspect";
119
+ }
120
+ if (!text.startsWith("/")) {
121
+ return "prompt.send";
122
+ }
123
+ const command = extractCommandName(text);
124
+ if (command === "queue") {
125
+ const argument = text.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
126
+ return argument ? "queue.write" : "queue.read";
127
+ }
128
+ return permissionForCommand(command);
129
+ }