@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.
- package/.env.example +52 -0
- package/README.md +171 -50
- package/dist/access-control.js +6 -1
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot-preferences.js +1 -0
- package/dist/bot.js +95 -37
- package/dist/channel-adapter.js +44 -11
- package/dist/channel-command-catalog.js +94 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +230 -1
- package/dist/channel-mirror-registry.js +84 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +82 -8
- package/dist/config.js +79 -7
- package/dist/context-key.js +42 -0
- package/dist/discord-bot.js +173 -342
- package/dist/discord-command-surface.js +11 -73
- package/dist/index.js +29 -0
- package/dist/metrics.js +48 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +288 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +658 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +307 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-runtime-helpers.js +210 -0
- package/dist/relay-runtime.js +79 -274
- package/dist/remote-prompt.js +98 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-general-commands.js +14 -0
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +16 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +26 -4
- package/dist/web-dashboard-peer-routes.js +225 -0
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +46 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +870 -57
- package/package.json +5 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function peerPromptProxyPayload(prompt) {
|
|
4
|
+
if (typeof prompt.input === "string") {
|
|
5
|
+
return {
|
|
6
|
+
method: "POST",
|
|
7
|
+
path: "/api/prompt",
|
|
8
|
+
body: { text: prompt.input },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const files = await remoteUploadFiles(prompt);
|
|
12
|
+
if (files.length > 0) {
|
|
13
|
+
return {
|
|
14
|
+
method: "POST",
|
|
15
|
+
path: "/api/prompt/upload",
|
|
16
|
+
body: {
|
|
17
|
+
text: prompt.input.text ?? "",
|
|
18
|
+
files,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
method: "POST",
|
|
24
|
+
path: "/api/prompt",
|
|
25
|
+
body: { text: prompt.input.text || prompt.description },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function remoteUploadFiles(prompt) {
|
|
29
|
+
if (typeof prompt.input === "string") {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const candidates = new Map();
|
|
33
|
+
for (const imagePath of prompt.input.imagePaths ?? []) {
|
|
34
|
+
candidates.set(imagePath, {
|
|
35
|
+
name: path.basename(imagePath),
|
|
36
|
+
mimeType: mimeTypeFromPath(imagePath),
|
|
37
|
+
localPath: imagePath,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
for (const file of parseStagedFileInstructions(prompt.input.stagedFileInstructions)) {
|
|
41
|
+
candidates.set(file.localPath, file);
|
|
42
|
+
}
|
|
43
|
+
const files = [];
|
|
44
|
+
for (const file of candidates.values()) {
|
|
45
|
+
const data = await readFile(file.localPath);
|
|
46
|
+
files.push({
|
|
47
|
+
name: file.name,
|
|
48
|
+
mimeType: file.mimeType,
|
|
49
|
+
dataBase64: data.toString("base64"),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
function parseStagedFileInstructions(text) {
|
|
55
|
+
if (!text) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const files = [];
|
|
59
|
+
for (const line of text.split(/\r?\n/)) {
|
|
60
|
+
const match = line.match(/^- (.+?) \(([^,]+), [^)]+\) → (.+)$/);
|
|
61
|
+
if (!match)
|
|
62
|
+
continue;
|
|
63
|
+
files.push({
|
|
64
|
+
name: match[1] || path.basename(match[3] ?? "upload"),
|
|
65
|
+
mimeType: match[2],
|
|
66
|
+
localPath: match[3] ?? "",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return files.filter((file) => file.localPath);
|
|
70
|
+
}
|
|
71
|
+
function mimeTypeFromPath(filePath) {
|
|
72
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
73
|
+
if (ext === ".jpg" || ext === ".jpeg")
|
|
74
|
+
return "image/jpeg";
|
|
75
|
+
if (ext === ".png")
|
|
76
|
+
return "image/png";
|
|
77
|
+
if (ext === ".gif")
|
|
78
|
+
return "image/gif";
|
|
79
|
+
if (ext === ".webp")
|
|
80
|
+
return "image/webp";
|
|
81
|
+
if (ext === ".pdf")
|
|
82
|
+
return "application/pdf";
|
|
83
|
+
if (ext === ".txt" || ext === ".md" || ext === ".log")
|
|
84
|
+
return "text/plain";
|
|
85
|
+
if (ext === ".json")
|
|
86
|
+
return "application/json";
|
|
87
|
+
if (ext === ".csv")
|
|
88
|
+
return "text/csv";
|
|
89
|
+
if (ext === ".mp3")
|
|
90
|
+
return "audio/mpeg";
|
|
91
|
+
if (ext === ".wav")
|
|
92
|
+
return "audio/wav";
|
|
93
|
+
if (ext === ".ogg")
|
|
94
|
+
return "audio/ogg";
|
|
95
|
+
if (ext === ".webm")
|
|
96
|
+
return "audio/webm";
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
@@ -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
|
+
}
|