@nordbyte/nordrelay 0.7.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 (46) hide show
  1. package/.env.example +35 -0
  2. package/README.md +109 -49
  3. package/dist/activity-events.js +2 -2
  4. package/dist/adapter-conformance.js +61 -0
  5. package/dist/bot.js +18 -31
  6. package/dist/channel-adapter.js +33 -6
  7. package/dist/channel-command-catalog.js +6 -0
  8. package/dist/channel-command-core.js +60 -0
  9. package/dist/channel-command-service.js +20 -4
  10. package/dist/channel-mirror-registry.js +9 -2
  11. package/dist/channel-prompt-engine.js +177 -0
  12. package/dist/channel-turn-lifecycle.js +73 -0
  13. package/dist/config-metadata.js +67 -8
  14. package/dist/config.js +48 -1
  15. package/dist/context-key.js +32 -0
  16. package/dist/discord-bot.js +99 -327
  17. package/dist/index.js +9 -0
  18. package/dist/metrics.js +2 -0
  19. package/dist/peer-client.js +33 -1
  20. package/dist/peer-readiness.js +77 -0
  21. package/dist/peer-runtime-service.js +22 -0
  22. package/dist/peer-store.js +13 -0
  23. package/dist/relay-runtime-helpers.js +3 -1
  24. package/dist/relay-runtime.js +7 -0
  25. package/dist/settings-wizard-test.js +216 -0
  26. package/dist/slack-artifacts.js +165 -0
  27. package/dist/slack-bot.js +1461 -0
  28. package/dist/slack-channel-runtime.js +147 -0
  29. package/dist/slack-command-surface.js +46 -0
  30. package/dist/slack-diagnostics.js +116 -0
  31. package/dist/slack-rate-limit.js +139 -0
  32. package/dist/user-management-crypto.js +38 -0
  33. package/dist/user-management-normalize.js +188 -0
  34. package/dist/user-management-types.js +1 -0
  35. package/dist/user-management.js +193 -196
  36. package/dist/web-api-contract.js +8 -0
  37. package/dist/web-dashboard-access-routes.js +62 -0
  38. package/dist/web-dashboard-assets.js +1 -0
  39. package/dist/web-dashboard-pages.js +14 -4
  40. package/dist/web-dashboard-peer-routes.js +32 -11
  41. package/dist/web-dashboard.js +34 -0
  42. package/dist/web-state.js +2 -2
  43. package/dist/webui-assets/dashboard.css +193 -0
  44. package/dist/webui-assets/dashboard.js +544 -144
  45. package/package.json +3 -1
  46. package/plugins/nordrelay/scripts/nordrelay.mjs +101 -10
@@ -0,0 +1,77 @@
1
+ import net from "node:net";
2
+ export async function buildPeerReadiness(config) {
3
+ const listenUrl = peerListenUrl(config);
4
+ const localListening = await checkLocalPort(config.peerHost, config.peerPort);
5
+ const loopbackOnly = isLoopbackUrl(listenUrl);
6
+ const bindLoopbackOnly = isLoopbackHost(config.peerHost);
7
+ const warnings = [];
8
+ if (!config.peerEnabled) {
9
+ warnings.push("Peer server is disabled. Invites can be created, but pairing will fail until NORDRELAY_PEER_ENABLED=true and NordRelay is restarted.");
10
+ }
11
+ if (config.peerEnabled && !localListening) {
12
+ warnings.push(`Peer server is enabled, but no listener was detected on ${connectHostForBindHost(config.peerHost)}:${config.peerPort}.`);
13
+ }
14
+ if (loopbackOnly) {
15
+ warnings.push("Listen URL uses a loopback host. Other machines cannot reach this URL unless they run on the same host.");
16
+ }
17
+ if (bindLoopbackOnly && !loopbackOnly) {
18
+ warnings.push("Peer server is bound to loopback. Remote access requires a local tunnel, reverse proxy, or port forward to this host.");
19
+ }
20
+ if (!config.peerTlsEnabled && (!loopbackOnly || !bindLoopbackOnly)) {
21
+ warnings.push("Peer TLS is disabled. Use TLS for non-loopback or internet-reachable peer endpoints.");
22
+ }
23
+ return {
24
+ enabled: config.peerEnabled,
25
+ listenUrl,
26
+ bindHost: config.peerHost,
27
+ port: config.peerPort,
28
+ tlsEnabled: config.peerTlsEnabled,
29
+ requireTls: config.peerRequireTls,
30
+ localListening,
31
+ loopbackOnly,
32
+ bindLoopbackOnly,
33
+ manualCheckCommand: `nordrelay peer check ${listenUrl}`,
34
+ warnings,
35
+ };
36
+ }
37
+ export function peerListenUrl(config) {
38
+ if (config.peerPublicUrl)
39
+ return config.peerPublicUrl;
40
+ const scheme = config.peerTlsEnabled ? "https" : "http";
41
+ const host = config.peerHost === "0.0.0.0" || config.peerHost === "::" ? "127.0.0.1" : config.peerHost;
42
+ const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
43
+ return `${scheme}://${displayHost}:${config.peerPort}`;
44
+ }
45
+ function checkLocalPort(host, port) {
46
+ return new Promise((resolve) => {
47
+ const socket = net.createConnection({ host: connectHostForBindHost(host), port });
48
+ const finish = (ok) => {
49
+ socket.removeAllListeners();
50
+ socket.destroy();
51
+ resolve(ok);
52
+ };
53
+ socket.setTimeout(1_500);
54
+ socket.once("connect", () => finish(true));
55
+ socket.once("timeout", () => finish(false));
56
+ socket.once("error", () => finish(false));
57
+ });
58
+ }
59
+ function connectHostForBindHost(host) {
60
+ if (!host || host === "0.0.0.0")
61
+ return "127.0.0.1";
62
+ if (host === "::")
63
+ return "::1";
64
+ return host;
65
+ }
66
+ function isLoopbackUrl(value) {
67
+ try {
68
+ return isLoopbackHost(new URL(value).hostname);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ function isLoopbackHost(host) {
75
+ const normalized = host.replace(/^\[|\]$/g, "").toLowerCase();
76
+ return normalized === "localhost" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1" || normalized.startsWith("127.");
77
+ }
@@ -6,6 +6,7 @@ import { permissionForWebRequest } from "./access-control.js";
6
6
  import { listChannelDescriptors } from "./channel-adapter.js";
7
7
  import { friendlyErrorText } from "./error-messages.js";
8
8
  import { getPackageVersion } from "./operations.js";
9
+ import { checkPeerEndpoint } from "./peer-client.js";
9
10
  export class PeerRuntimeService {
10
11
  config;
11
12
  runtime;
@@ -26,6 +27,10 @@ export class PeerRuntimeService {
26
27
  this.assertScope(peer, "inspect");
27
28
  return { ok: true, status: "online", version: await getPackageVersion(), at: new Date().toISOString() };
28
29
  }
30
+ if (request.type === "peer.probe") {
31
+ this.assertScope(peer, "inspect");
32
+ return await this.handlePeerProbe(peer, request.payload);
33
+ }
29
34
  throw new Error(`Unsupported peer RPC type: ${request.type}`);
30
35
  }
31
36
  subscribe(peer, sourceContextKey, send) {
@@ -333,6 +338,16 @@ export class PeerRuntimeService {
333
338
  return runtime.restartConnector(remoteActor);
334
339
  throw new Error(`Remote endpoint is not implemented: ${method} ${path}`);
335
340
  }
341
+ async handlePeerProbe(peer, payload) {
342
+ const requestedUrl = stringValue(objectRecord(payload).url);
343
+ if (!peer.url) {
344
+ throw new Error("Remote probe refused because this peer has no registered URL. Pair with --public-url or set the peer URL first.");
345
+ }
346
+ if (requestedUrl && normalizePeerUrl(requestedUrl) !== normalizePeerUrl(peer.url)) {
347
+ throw new Error("Remote probe refused because the requested URL does not match this peer's registered URL.");
348
+ }
349
+ return await checkPeerEndpoint(peer.url, { expectedTlsFingerprint: peer.tlsFingerprint });
350
+ }
336
351
  assertScope(peer, permission) {
337
352
  if (!peer.scopes.includes(permission)) {
338
353
  throw new Error(`Peer permission denied: ${permission}`);
@@ -545,6 +560,13 @@ function normalizePath(value) {
545
560
  }
546
561
  return path;
547
562
  }
563
+ function normalizePeerUrl(value) {
564
+ const url = new URL(value);
565
+ url.pathname = "";
566
+ url.search = "";
567
+ url.hash = "";
568
+ return url.toString().replace(/\/$/, "");
569
+ }
548
570
  function objectRecord(value) {
549
571
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
550
572
  }
@@ -21,6 +21,7 @@ export class PeerStore {
21
21
  enabled: options.enabled,
22
22
  listenUrl: options.listenUrl,
23
23
  requireTls: options.requireTls,
24
+ readiness: options.readiness,
24
25
  peers: payload.peers.map(publicPeer),
25
26
  invitations: payload.invitations.map(publicInvitation),
26
27
  };
@@ -180,6 +181,18 @@ export class PeerStore {
180
181
  });
181
182
  return removed;
182
183
  }
184
+ deleteInvitation(id) {
185
+ let removed = null;
186
+ this.mutatePayload((payload) => {
187
+ const index = payload.invitations.findIndex((invitation) => invitation.id === id);
188
+ if (index < 0) {
189
+ return;
190
+ }
191
+ const [invitation] = payload.invitations.splice(index, 1);
192
+ removed = invitation;
193
+ });
194
+ return removed ? publicInvitation(removed) : null;
195
+ }
183
196
  patchPeer(id, patch) {
184
197
  this.mutatePayload((payload) => {
185
198
  const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
@@ -131,7 +131,9 @@ export function promptActivityToUnifiedJob(event) {
131
131
  ? "Telegram"
132
132
  : event.source === "discord"
133
133
  ? "Discord"
134
- : "CLI";
134
+ : event.source === "slack"
135
+ ? "Slack"
136
+ : "CLI";
135
137
  const promptKey = event.threadId ?? event.contextKey ?? event.id;
136
138
  return {
137
139
  id: `prompt:${event.source}:${promptKey}:${event.id}`,
@@ -29,6 +29,8 @@ import { activeSessionPriority, activityToUnifiedJob, agentUpdateStatusToUnified
29
29
  import { renderSessionInfoPlain, renderSessionUsageRows } from "./session-format.js";
30
30
  import { SessionLockStore } from "./session-locks.js";
31
31
  import { SessionRegistry } from "./session-registry.js";
32
+ import { collectSlackDiagnostics } from "./slack-diagnostics.js";
33
+ import { getSlackRateLimitMetrics } from "./slack-rate-limit.js";
32
34
  import { createSupportBundle } from "./support-bundle.js";
33
35
  import { transcribeAudio } from "./voice.js";
34
36
  import { WebActivityStore, WebChatStore, } from "./web-state.js";
@@ -330,6 +332,11 @@ export class RelayRuntime {
330
332
  queuePaused: this.queueService.isPaused(),
331
333
  externalMirror: this.externalActivityMonitor.snapshot(),
332
334
  agentDiagnostics: getAgentDiagnostics(session, this.config),
335
+ slackDiagnostics: await collectSlackDiagnostics({
336
+ config: this.config,
337
+ timeoutMs: 2_500,
338
+ rateLimit: getSlackRateLimitMetrics(),
339
+ }),
333
340
  },
334
341
  };
335
342
  });
@@ -0,0 +1,216 @@
1
+ export async function runSettingsWizardTest(channel, settings) {
2
+ const parsedChannel = parseSettingsWizardChannel(channel);
3
+ const checks = parsedChannel === "telegram"
4
+ ? await testTelegram(settings)
5
+ : parsedChannel === "discord"
6
+ ? await testDiscord(settings)
7
+ : await testSlack(settings);
8
+ return { channel: parsedChannel, checkedAt: new Date().toISOString(), checks };
9
+ }
10
+ export function mergeSettingsWizardTestSettings(activeSettings, submittedSettings) {
11
+ const merged = {};
12
+ for (const [key, value] of Object.entries(activeSettings)) {
13
+ if (value !== undefined) {
14
+ merged[key] = value;
15
+ }
16
+ }
17
+ for (const [key, value] of Object.entries(submittedSettings)) {
18
+ if (typeof value !== "string" || isMaskedSecret(value)) {
19
+ continue;
20
+ }
21
+ merged[key] = value;
22
+ }
23
+ return merged;
24
+ }
25
+ function parseSettingsWizardChannel(value) {
26
+ if (value === "telegram" || value === "discord" || value === "slack") {
27
+ return value;
28
+ }
29
+ throw new Error("Invalid settings wizard channel.");
30
+ }
31
+ async function testTelegram(settings) {
32
+ const token = settings.TELEGRAM_BOT_TOKEN ?? "";
33
+ const transport = settings.TELEGRAM_TRANSPORT || "polling";
34
+ const checks = [
35
+ tokenCheck("Telegram bot token", token, /^[0-9]{5,}:[A-Za-z0-9_-]{20,}$/),
36
+ {
37
+ label: "Telegram transport",
38
+ status: transport === "polling" || transport === "webhook" ? "ok" : "error",
39
+ detail: transport === "webhook" ? "Webhook mode selected." : "Polling mode selected.",
40
+ },
41
+ ];
42
+ if (transport === "webhook") {
43
+ checks.push({
44
+ label: "Webhook public URL",
45
+ status: /^https:\/\//.test(settings.TELEGRAM_WEBHOOK_URL ?? "") ? "ok" : "error",
46
+ detail: settings.TELEGRAM_WEBHOOK_URL ? "HTTPS URL configured." : "Webhook mode requires a public HTTPS URL.",
47
+ }, {
48
+ label: "Webhook bind endpoint",
49
+ status: settings.TELEGRAM_WEBHOOK_HOST && Number.isFinite(Number(settings.TELEGRAM_WEBHOOK_PORT)) && String(settings.TELEGRAM_WEBHOOK_PATH ?? "").startsWith("/") ? "ok" : "error",
50
+ detail: "Host, port, and path must describe the local webhook listener.",
51
+ });
52
+ }
53
+ if (isUsableSecret(token, /^[0-9]{5,}:[A-Za-z0-9_-]{20,}$/)) {
54
+ checks.push(await fetchTelegramIdentity(token));
55
+ }
56
+ return checks;
57
+ }
58
+ async function testDiscord(settings) {
59
+ const token = settings.DISCORD_BOT_TOKEN ?? "";
60
+ const clientId = settings.DISCORD_CLIENT_ID ?? "";
61
+ const commandMode = settings.DISCORD_COMMAND_MODE || "both";
62
+ const checks = [
63
+ tokenCheck("Discord bot token", token, /^.{20,}$/),
64
+ {
65
+ label: "Discord client ID",
66
+ status: isSnowflake(clientId) ? "ok" : "error",
67
+ detail: isSnowflake(clientId) ? "Application ID looks valid." : "Copy Application ID from Discord Developer Portal > General Information.",
68
+ },
69
+ listCheck("Discord guild IDs", settings.DISCORD_GUILD_IDS, isSnowflake),
70
+ listCheck("Allowed Discord guilds", settings.DISCORD_ALLOWED_GUILD_IDS, isSnowflake),
71
+ listCheck("Allowed Discord channels", settings.DISCORD_ALLOWED_CHANNEL_IDS, isSnowflake),
72
+ {
73
+ label: "Discord command mode",
74
+ status: commandMode === "slash" || commandMode === "message" || commandMode === "both" ? "ok" : "error",
75
+ detail: "Supported values are slash, message, or both.",
76
+ },
77
+ ];
78
+ if ((commandMode === "message" || commandMode === "both") && !truthy(settings.DISCORD_MESSAGE_CONTENT_ENABLED)) {
79
+ checks.push({
80
+ label: "Message Content Intent",
81
+ status: "warn",
82
+ detail: "Message command mode needs Message Content Intent enabled in the Discord Developer Portal.",
83
+ });
84
+ }
85
+ if (isUsableSecret(token, /^.{20,}$/)) {
86
+ checks.push(await fetchDiscordIdentity(token));
87
+ }
88
+ return checks;
89
+ }
90
+ async function testSlack(settings) {
91
+ const botToken = settings.SLACK_BOT_TOKEN ?? "";
92
+ const appToken = settings.SLACK_APP_TOKEN ?? "";
93
+ const socketMode = truthy(settings.SLACK_SOCKET_MODE);
94
+ const checks = [
95
+ tokenCheck("Slack bot token", botToken, /^xoxb-/),
96
+ {
97
+ label: "Slack command",
98
+ status: !settings.SLACK_COMMAND || settings.SLACK_COMMAND.startsWith("/") ? "ok" : "error",
99
+ detail: settings.SLACK_COMMAND || "/nordrelay",
100
+ },
101
+ listCheck("Allowed Slack teams", settings.SLACK_ALLOWED_TEAM_IDS, isSlackId),
102
+ listCheck("Allowed Slack channels", settings.SLACK_ALLOWED_CHANNEL_IDS, isSlackId),
103
+ ];
104
+ if (socketMode) {
105
+ checks.push(tokenCheck("Slack app token", appToken, /^xapp-/));
106
+ }
107
+ else {
108
+ checks.push({
109
+ label: "Slack signing secret",
110
+ status: settings.SLACK_SIGNING_SECRET ? "ok" : "error",
111
+ detail: settings.SLACK_SIGNING_SECRET ? "Signing secret configured." : "HTTP Events mode requires the Slack signing secret.",
112
+ }, {
113
+ label: "Slack HTTP port",
114
+ status: Number.isFinite(Number(settings.SLACK_PORT)) ? "ok" : "error",
115
+ detail: settings.SLACK_PORT || "Not configured.",
116
+ });
117
+ }
118
+ if (isUsableSecret(botToken, /^xoxb-/)) {
119
+ checks.push(await fetchSlackIdentity(botToken));
120
+ }
121
+ return checks;
122
+ }
123
+ function tokenCheck(label, value, pattern) {
124
+ if (!value) {
125
+ return { label, status: "error", detail: "Required value is missing." };
126
+ }
127
+ if (isMaskedSecret(value)) {
128
+ return { label, status: "warn", detail: "Secret is already configured. Paste the real value to run a live API test." };
129
+ }
130
+ return pattern.test(value)
131
+ ? { label, status: "ok", detail: "Format looks valid." }
132
+ : { label, status: "error", detail: "Value does not match the expected format." };
133
+ }
134
+ function listCheck(label, value, predicate) {
135
+ const items = parseList(value);
136
+ const invalid = items.filter((item) => !predicate(item));
137
+ if (invalid.length > 0) {
138
+ return { label, status: "error", detail: `Invalid values: ${invalid.join(", ")}` };
139
+ }
140
+ return { label, status: "ok", detail: items.length ? `${items.length} value(s) configured.` : "No allow-list configured." };
141
+ }
142
+ async function fetchTelegramIdentity(token) {
143
+ try {
144
+ const data = await fetchJson(`https://api.telegram.org/bot${token}/getMe`);
145
+ if (data.ok === true) {
146
+ const result = data.result;
147
+ return { label: "Telegram API", status: "ok", detail: `Bot reachable: ${result?.username ?? result?.first_name ?? "configured bot"}.` };
148
+ }
149
+ return { label: "Telegram API", status: "error", detail: String(data.description ?? "Telegram rejected the token.") };
150
+ }
151
+ catch (error) {
152
+ return { label: "Telegram API", status: "warn", detail: `Live check failed: ${errorText(error)}` };
153
+ }
154
+ }
155
+ async function fetchDiscordIdentity(token) {
156
+ try {
157
+ const data = await fetchJson("https://discord.com/api/v10/users/@me", {
158
+ headers: { authorization: `Bot ${token}` },
159
+ });
160
+ if (typeof data.id === "string") {
161
+ return { label: "Discord API", status: "ok", detail: `Bot reachable: ${data.username ?? data.id}.` };
162
+ }
163
+ return { label: "Discord API", status: "error", detail: String(data.message ?? "Discord rejected the bot token.") };
164
+ }
165
+ catch (error) {
166
+ return { label: "Discord API", status: "warn", detail: `Live check failed: ${errorText(error)}` };
167
+ }
168
+ }
169
+ async function fetchSlackIdentity(token) {
170
+ try {
171
+ const data = await fetchJson("https://slack.com/api/auth.test", {
172
+ headers: { authorization: `Bearer ${token}` },
173
+ });
174
+ if (data.ok === true) {
175
+ return { label: "Slack API", status: "ok", detail: `Bot reachable in ${data.team ?? "workspace"} as ${data.user ?? data.bot_id ?? "bot"}.` };
176
+ }
177
+ return { label: "Slack API", status: "error", detail: String(data.error ?? "Slack rejected the bot token.") };
178
+ }
179
+ catch (error) {
180
+ return { label: "Slack API", status: "warn", detail: `Live check failed: ${errorText(error)}` };
181
+ }
182
+ }
183
+ async function fetchJson(url, init = {}) {
184
+ const response = await fetch(url, {
185
+ ...init,
186
+ signal: AbortSignal.timeout(5_000),
187
+ });
188
+ const text = await response.text();
189
+ try {
190
+ return JSON.parse(text);
191
+ }
192
+ catch {
193
+ return { ok: response.ok, status: response.status, description: text.slice(0, 200) };
194
+ }
195
+ }
196
+ function isUsableSecret(value, pattern) {
197
+ return Boolean(value) && !isMaskedSecret(value) && pattern.test(value);
198
+ }
199
+ function isMaskedSecret(value) {
200
+ return /^\*+$/.test(value) || value.includes("...");
201
+ }
202
+ function parseList(value) {
203
+ return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
204
+ }
205
+ function isSnowflake(value) {
206
+ return /^[0-9]{5,32}$/.test(value);
207
+ }
208
+ function isSlackId(value) {
209
+ return /^[A-Z0-9]{2,64}$/.test(value);
210
+ }
211
+ function truthy(value) {
212
+ return ["true", "1", "yes", "on"].includes(String(value ?? "").toLowerCase());
213
+ }
214
+ function errorText(error) {
215
+ return error instanceof Error ? error.message : String(error);
216
+ }
@@ -0,0 +1,165 @@
1
+ import { collectRecentWorkspaceArtifacts, createArtifactZipBundle, formatArtifactSummary, listRecentArtifactReports } from "./artifacts.js";
2
+ import { filterArtifactReports as filterArtifactReportsForCommand } from "./bot-rendering.js";
3
+ import { renderArtifactReportsAction } from "./channel-actions.js";
4
+ import { deliverChannelAction } from "./channel-runtime.js";
5
+ export function createSlackArtifactCommandHandler(deps) {
6
+ return async (request, argument) => {
7
+ const session = await deps.getSession(request, { deferThreadStart: true });
8
+ const [action, turnId] = argument.trim().split(/\s+/, 2);
9
+ const info = session.getInfo();
10
+ const workspace = info.workspace;
11
+ const reports = await listRecentArtifactReports(workspace, 10, deps.config.maxFileSize);
12
+ if (reports.length === 0) {
13
+ await deps.reply(request, "No generated artifacts found for this workspace.");
14
+ return;
15
+ }
16
+ if (action) {
17
+ if (action.toLowerCase() === "delete" && turnId) {
18
+ const selected = findArtifactReport(reports, turnId);
19
+ if (!selected) {
20
+ await deps.reply(request, `No artifact turn found for "${turnId}".`);
21
+ return;
22
+ }
23
+ const removed = await deps.artifactService.delete(workspace, selected.turnId);
24
+ deps.appendActivity(request, {
25
+ status: removed ? "info" : "failed",
26
+ type: "artifact_deleted",
27
+ threadId: info.threadId,
28
+ workspace,
29
+ agentId: info.agentId,
30
+ detail: selected.turnId,
31
+ });
32
+ await deps.reply(request, removed ? `Deleted artifact turn: ${selected.turnId}` : `Artifact turn not found: ${selected.turnId}`);
33
+ return;
34
+ }
35
+ const filtered = filterArtifactReportsForCommand(reports, argument);
36
+ if (filtered) {
37
+ if (filtered.length === 0) {
38
+ await deps.reply(request, `No artifacts matched "${argument}".`);
39
+ return;
40
+ }
41
+ await deliverChannelAction(deps.runtime, request.context, renderSlackArtifactReports(request.contextKey, filtered));
42
+ return;
43
+ }
44
+ const normalizedAction = action.toLowerCase();
45
+ const shouldZip = normalizedAction === "zip";
46
+ const shouldSend = normalizedAction === "send";
47
+ const selected = findArtifactReport(reports, shouldZip || shouldSend ? turnId : action);
48
+ if (!selected) {
49
+ await deps.reply(request, `No artifact turn found for "${argument}".`);
50
+ return;
51
+ }
52
+ deps.appendActivity(request, {
53
+ status: "info",
54
+ type: shouldZip ? "artifact_zip_sent" : "artifacts_sent",
55
+ threadId: info.threadId,
56
+ workspace,
57
+ agentId: info.agentId,
58
+ detail: selected.turnId,
59
+ });
60
+ if (shouldZip) {
61
+ await deliverSlackArtifactZip(deps, request, selected);
62
+ }
63
+ else {
64
+ await deliverSlackArtifactReport(deps, request, selected);
65
+ }
66
+ return;
67
+ }
68
+ await deliverChannelAction(deps.runtime, request.context, renderSlackArtifactReports(request.contextKey, reports));
69
+ };
70
+ }
71
+ export async function sendRecentSlackArtifacts(deps, request, session, since, turnId) {
72
+ const report = await collectRecentWorkspaceArtifacts(session.getInfo().workspace, {
73
+ since,
74
+ until: new Date(),
75
+ maxFileSize: deps.config.maxFileSize,
76
+ limit: 5,
77
+ });
78
+ if (report.artifacts.length === 0) {
79
+ return;
80
+ }
81
+ await deps.reply(request, `${report.artifacts.length} artifacts generated.`);
82
+ for (const artifact of report.artifacts.slice(0, 5)) {
83
+ await sendSlackArtifactFile(deps, request, artifact);
84
+ }
85
+ deps.appendActivity(request, {
86
+ status: "info",
87
+ type: "artifacts_sent",
88
+ detail: `${report.artifacts.length} artifacts for ${turnId}`,
89
+ threadId: session.getInfo().threadId,
90
+ workspace: session.getInfo().workspace,
91
+ agentId: session.getInfo().agentId,
92
+ });
93
+ }
94
+ function renderSlackArtifactReports(contextKey, reports) {
95
+ const rendered = renderArtifactReportsAction(reports);
96
+ return {
97
+ ...rendered,
98
+ buttons: reports.slice(0, 5).map((report, index) => [
99
+ { label: `${index + 1} Send`, action: `slack_artifact_send:${contextKey}:${report.turnId}` },
100
+ { label: `${index + 1} ZIP`, action: `slack_artifact_zip:${contextKey}:${report.turnId}` },
101
+ { label: `${index + 1} Delete`, action: `slack_artifact_delete:${contextKey}:${report.turnId}` },
102
+ ]),
103
+ };
104
+ }
105
+ function findArtifactReport(reports, requested) {
106
+ const value = requested?.trim();
107
+ if (!value || value.toLowerCase() === "latest") {
108
+ return reports[0];
109
+ }
110
+ return reports.find((report) => report.turnId === value || report.turnId.startsWith(value));
111
+ }
112
+ async function deliverSlackArtifactZip(deps, request, report) {
113
+ const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
114
+ maxFileSize: deps.config.maxFileSize,
115
+ bundleName: `nordrelay-artifacts-${report.turnId}.zip`,
116
+ });
117
+ if (!bundle) {
118
+ await deps.reply(request, "Could not create a ZIP bundle for this artifact turn.");
119
+ return;
120
+ }
121
+ if (!deps.runtime.sendFile) {
122
+ await deps.reply(request, "This Slack runtime cannot send artifact files.");
123
+ return;
124
+ }
125
+ await deps.runtime.sendFile(request.context, { localPath: bundle.localPath, name: bundle.name });
126
+ await deps.reply(request, `Sent ZIP artifact bundle: ${bundle.name}`);
127
+ }
128
+ async function deliverSlackArtifactReport(deps, request, report) {
129
+ if (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount) {
130
+ await deps.reply(request, "No generated artifacts found for this turn.");
131
+ return;
132
+ }
133
+ let failedCount = 0;
134
+ let bundledArtifact = null;
135
+ if (report.artifacts.length > 5) {
136
+ bundledArtifact = await createArtifactZipBundle(report.artifacts, report.outDir, {
137
+ maxFileSize: deps.config.maxFileSize,
138
+ });
139
+ }
140
+ const delivered = bundledArtifact ? [bundledArtifact] : report.artifacts;
141
+ for (const artifact of delivered) {
142
+ if (!await sendSlackArtifactFile(deps, request, artifact)) {
143
+ failedCount += 1;
144
+ }
145
+ }
146
+ const summary = formatArtifactSummary(report.artifacts, report.skippedCount + failedCount, report.omittedCount);
147
+ if (summary) {
148
+ const bundleNote = bundledArtifact ? `\nSent as ZIP: ${bundledArtifact.name}` : "";
149
+ await deps.reply(request, `${summary}${bundleNote}`);
150
+ }
151
+ }
152
+ async function sendSlackArtifactFile(deps, request, artifact) {
153
+ if (!deps.runtime.sendFile) {
154
+ await deps.reply(request, "This Slack runtime cannot send artifact files.");
155
+ return false;
156
+ }
157
+ try {
158
+ await deps.runtime.sendFile(request.context, { localPath: artifact.localPath, name: artifact.name });
159
+ return true;
160
+ }
161
+ catch (error) {
162
+ console.error(`Failed to send Slack artifact ${artifact.name}:`, error);
163
+ return false;
164
+ }
165
+ }