@openpalm/discord-portal 0.12.7
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/README.md +64 -0
- package/package.json +24 -0
- package/src/commands.ts +202 -0
- package/src/index.test.ts +1154 -0
- package/src/index.ts +814 -0
- package/src/oc-event-hub.test.ts +100 -0
- package/src/oc-event-hub.ts +161 -0
- package/src/oc-events.ts +157 -0
- package/src/opencode.test.ts +78 -0
- package/src/opencode.ts +130 -0
- package/src/permissions.ts +55 -0
- package/src/runtime.ts +211 -0
- package/src/session.test.ts +74 -0
- package/src/stream-render.test.ts +127 -0
- package/src/stream-render.ts +482 -0
- package/src/types.ts +49 -0
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { MessageFlags } from "discord.js";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
BUILTIN_COMMANDS,
|
|
8
|
+
buildCommandRegistry,
|
|
9
|
+
findCommand,
|
|
10
|
+
parseCustomCommands,
|
|
11
|
+
resolvePromptTemplate,
|
|
12
|
+
} from "./commands.ts";
|
|
13
|
+
import DiscordChannel from "./index.ts";
|
|
14
|
+
import { checkPermissions, loadPermissionConfig, parseIdList } from "./permissions.ts";
|
|
15
|
+
import type { CustomCommandDef, PermissionConfig, UserInfo } from "./types.ts";
|
|
16
|
+
import { CommandOptionType } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function emptyPermissions(): PermissionConfig {
|
|
21
|
+
return {
|
|
22
|
+
allowedGuilds: new Set(),
|
|
23
|
+
allowedRoles: new Set(),
|
|
24
|
+
allowedUsers: new Set(),
|
|
25
|
+
blockedUsers: new Set(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function testUser(overrides: Partial<UserInfo> = {}): UserInfo {
|
|
30
|
+
return {
|
|
31
|
+
userId: "user-1",
|
|
32
|
+
guildId: "guild-1",
|
|
33
|
+
roles: ["role-1"],
|
|
34
|
+
username: "testuser",
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function withSecretFile(envKey: string, value: string, run: () => void): void {
|
|
40
|
+
const original = Bun.env[envKey];
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "openpalm-channel-test-"));
|
|
42
|
+
const path = join(dir, envKey.toLowerCase());
|
|
43
|
+
writeFileSync(path, `${value}\n`);
|
|
44
|
+
try {
|
|
45
|
+
Bun.env[envKey] = path;
|
|
46
|
+
run();
|
|
47
|
+
} finally {
|
|
48
|
+
if (original === undefined) delete Bun.env[envKey];
|
|
49
|
+
else Bun.env[envKey] = original;
|
|
50
|
+
rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type TestInteraction = {
|
|
55
|
+
commandName: string;
|
|
56
|
+
channelId: string;
|
|
57
|
+
guildId: string;
|
|
58
|
+
channel?: { id: string; isThread: () => boolean };
|
|
59
|
+
user: { id: string; username: string };
|
|
60
|
+
member: { roles: { cache: Map<string, { id: string }> } };
|
|
61
|
+
options: { data: Array<{ name: string; value?: string }> };
|
|
62
|
+
reply: ReturnType<typeof mock>;
|
|
63
|
+
deferReply: ReturnType<typeof mock>;
|
|
64
|
+
editReply: ReturnType<typeof mock>;
|
|
65
|
+
followUp: ReturnType<typeof mock>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function createInteraction(overrides: Partial<TestInteraction> = {}): TestInteraction {
|
|
69
|
+
const roleEntries = [["role-1", { id: "role-1" }]];
|
|
70
|
+
return {
|
|
71
|
+
commandName: "ask",
|
|
72
|
+
channelId: "channel-1",
|
|
73
|
+
guildId: "guild-1",
|
|
74
|
+
channel: { id: "channel-1", isThread: () => false },
|
|
75
|
+
user: { id: "user-1", username: "testuser" },
|
|
76
|
+
member: {
|
|
77
|
+
roles: {
|
|
78
|
+
cache: {
|
|
79
|
+
map: <T>(fn: (role: { id: string }) => T) => roleEntries.map(([, role]) => fn(role)),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
options: { data: [{ name: "message", value: "hello" }] },
|
|
84
|
+
reply: mock(async () => {}),
|
|
85
|
+
deferReply: mock(async () => {}),
|
|
86
|
+
editReply: mock(async () => {}),
|
|
87
|
+
followUp: mock(async () => {}),
|
|
88
|
+
...overrides,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
Bun.env.DISCORD_CUSTOM_COMMANDS = undefined;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── Health Endpoint ─────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("health endpoint", () => {
|
|
99
|
+
it("GET /health returns 200 with service info", async () => {
|
|
100
|
+
const channel = new DiscordChannel();
|
|
101
|
+
const handler = channel.createFetch();
|
|
102
|
+
const resp = await handler(new Request("http://discord/health"));
|
|
103
|
+
expect(resp.status).toBe(200);
|
|
104
|
+
const body = (await resp.json()) as Record<string, unknown>;
|
|
105
|
+
expect(body.ok).toBe(true);
|
|
106
|
+
expect(body.service).toBe("channel-discord");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("GET /health responds to any host header", async () => {
|
|
110
|
+
const channel = new DiscordChannel();
|
|
111
|
+
const handler = channel.createFetch();
|
|
112
|
+
const resp = await handler(new Request("http://127.0.0.1:8184/health"));
|
|
113
|
+
expect(resp.status).toBe(200);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Routing ─────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("routing", () => {
|
|
120
|
+
it("unknown path → 404", async () => {
|
|
121
|
+
const channel = new DiscordChannel();
|
|
122
|
+
const handler = channel.createFetch();
|
|
123
|
+
const resp = await handler(new Request("http://discord/nope"));
|
|
124
|
+
expect(resp.status).toBe(404);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("GET on non-health path → 404", async () => {
|
|
128
|
+
const channel = new DiscordChannel();
|
|
129
|
+
const handler = channel.createFetch();
|
|
130
|
+
const resp = await handler(new Request("http://discord/webhook"));
|
|
131
|
+
expect(resp.status).toBe(404);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("handleRequest returns null (Gateway channels don't use HTTP inbound)", async () => {
|
|
135
|
+
const channel = new DiscordChannel();
|
|
136
|
+
const result = await channel.handleRequest(new Request("http://discord/", { method: "POST" }));
|
|
137
|
+
expect(result).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── Permissions: parseIdList ────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe("parseIdList", () => {
|
|
144
|
+
it("parses and trims IDs", () => {
|
|
145
|
+
const result = parseIdList("id1, id2 , id3");
|
|
146
|
+
expect(result.size).toBe(3);
|
|
147
|
+
expect(result.has("id1")).toBe(true);
|
|
148
|
+
expect(result.has("id2")).toBe(true);
|
|
149
|
+
expect(result.has("id3")).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns empty set for undefined", () => {
|
|
153
|
+
expect(parseIdList(undefined).size).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns empty set for empty string", () => {
|
|
157
|
+
expect(parseIdList("").size).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns empty set for whitespace-only", () => {
|
|
161
|
+
expect(parseIdList(" ").size).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("handles single ID without commas", () => {
|
|
165
|
+
const result = parseIdList("solo-id");
|
|
166
|
+
expect(result.size).toBe(1);
|
|
167
|
+
expect(result.has("solo-id")).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("filters empty entries from trailing commas", () => {
|
|
171
|
+
const result = parseIdList("id1,,id2,");
|
|
172
|
+
expect(result.size).toBe(2);
|
|
173
|
+
expect(result.has("id1")).toBe(true);
|
|
174
|
+
expect(result.has("id2")).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("deduplicates repeated IDs", () => {
|
|
178
|
+
const result = parseIdList("id1,id1,id1");
|
|
179
|
+
expect(result.size).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Permissions: checkPermissions ───────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe("checkPermissions", () => {
|
|
186
|
+
it("allows all when no restrictions configured", () => {
|
|
187
|
+
const config = emptyPermissions();
|
|
188
|
+
const result = checkPermissions(config, testUser());
|
|
189
|
+
expect(result.allowed).toBe(true);
|
|
190
|
+
expect(result.reason).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("blocks blocked users", () => {
|
|
194
|
+
const config = emptyPermissions();
|
|
195
|
+
config.blockedUsers.add("user-1");
|
|
196
|
+
const result = checkPermissions(config, testUser({ userId: "user-1" }));
|
|
197
|
+
expect(result.allowed).toBe(false);
|
|
198
|
+
expect(result.reason).toBe("user_blocked");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("blocked user check takes precedence over allowlist", () => {
|
|
202
|
+
const config = emptyPermissions();
|
|
203
|
+
config.allowedUsers.add("user-1");
|
|
204
|
+
config.blockedUsers.add("user-1");
|
|
205
|
+
const result = checkPermissions(config, testUser({ userId: "user-1" }));
|
|
206
|
+
expect(result.allowed).toBe(false);
|
|
207
|
+
expect(result.reason).toBe("user_blocked");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("denies user not in allowlist", () => {
|
|
211
|
+
const config = emptyPermissions();
|
|
212
|
+
config.allowedUsers.add("other-user");
|
|
213
|
+
const result = checkPermissions(config, testUser({ userId: "user-1" }));
|
|
214
|
+
expect(result.allowed).toBe(false);
|
|
215
|
+
expect(result.reason).toBe("user_not_allowed");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("allows user in allowlist", () => {
|
|
219
|
+
const config = emptyPermissions();
|
|
220
|
+
config.allowedUsers.add("user-1");
|
|
221
|
+
const result = checkPermissions(config, testUser({ userId: "user-1" }));
|
|
222
|
+
expect(result.allowed).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("denies guild not in allowlist", () => {
|
|
226
|
+
const config = emptyPermissions();
|
|
227
|
+
config.allowedGuilds.add("other-guild");
|
|
228
|
+
const result = checkPermissions(config, testUser({ guildId: "guild-1" }));
|
|
229
|
+
expect(result.allowed).toBe(false);
|
|
230
|
+
expect(result.reason).toBe("guild_not_allowed");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("allows guild in allowlist", () => {
|
|
234
|
+
const config = emptyPermissions();
|
|
235
|
+
config.allowedGuilds.add("guild-1");
|
|
236
|
+
const result = checkPermissions(config, testUser({ guildId: "guild-1" }));
|
|
237
|
+
expect(result.allowed).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("denies when no matching role", () => {
|
|
241
|
+
const config = emptyPermissions();
|
|
242
|
+
config.allowedRoles.add("required-role");
|
|
243
|
+
const result = checkPermissions(config, testUser({ roles: ["other-role"] }));
|
|
244
|
+
expect(result.allowed).toBe(false);
|
|
245
|
+
expect(result.reason).toBe("role_not_allowed");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("allows matching role", () => {
|
|
249
|
+
const config = emptyPermissions();
|
|
250
|
+
config.allowedRoles.add("role-1");
|
|
251
|
+
const result = checkPermissions(config, testUser({ roles: ["role-1"] }));
|
|
252
|
+
expect(result.allowed).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("allows if user has any one of multiple required roles", () => {
|
|
256
|
+
const config = emptyPermissions();
|
|
257
|
+
config.allowedRoles.add("admin");
|
|
258
|
+
config.allowedRoles.add("moderator");
|
|
259
|
+
const result = checkPermissions(config, testUser({ roles: ["moderator", "member"] }));
|
|
260
|
+
expect(result.allowed).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("denies user with empty roles array when roles are required", () => {
|
|
264
|
+
const config = emptyPermissions();
|
|
265
|
+
config.allowedRoles.add("required-role");
|
|
266
|
+
const result = checkPermissions(config, testUser({ roles: [] }));
|
|
267
|
+
expect(result.allowed).toBe(false);
|
|
268
|
+
expect(result.reason).toBe("role_not_allowed");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("denies user with empty userId when users are restricted", () => {
|
|
272
|
+
const config = emptyPermissions();
|
|
273
|
+
config.allowedUsers.add("some-user");
|
|
274
|
+
const result = checkPermissions(config, testUser({ userId: "" }));
|
|
275
|
+
expect(result.allowed).toBe(false);
|
|
276
|
+
expect(result.reason).toBe("user_not_allowed");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("denies user with empty guildId when guilds are restricted", () => {
|
|
280
|
+
const config = emptyPermissions();
|
|
281
|
+
config.allowedGuilds.add("some-guild");
|
|
282
|
+
const result = checkPermissions(config, testUser({ guildId: "" }));
|
|
283
|
+
expect(result.allowed).toBe(false);
|
|
284
|
+
expect(result.reason).toBe("guild_not_allowed");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("checks all layers: guild + role + user", () => {
|
|
288
|
+
const config = emptyPermissions();
|
|
289
|
+
config.allowedGuilds.add("guild-1");
|
|
290
|
+
config.allowedRoles.add("role-1");
|
|
291
|
+
config.allowedUsers.add("user-1");
|
|
292
|
+
const result = checkPermissions(config, testUser());
|
|
293
|
+
expect(result.allowed).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("fails if guild matches but role does not", () => {
|
|
297
|
+
const config = emptyPermissions();
|
|
298
|
+
config.allowedGuilds.add("guild-1");
|
|
299
|
+
config.allowedRoles.add("admin");
|
|
300
|
+
const result = checkPermissions(config, testUser({ roles: ["member"] }));
|
|
301
|
+
expect(result.allowed).toBe(false);
|
|
302
|
+
expect(result.reason).toBe("role_not_allowed");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── Permissions: loadPermissionConfig ───────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
describe("loadPermissionConfig", () => {
|
|
309
|
+
it("reads from env vars", () => {
|
|
310
|
+
const config = loadPermissionConfig({
|
|
311
|
+
DISCORD_ALLOWED_GUILDS: "g1,g2",
|
|
312
|
+
DISCORD_ALLOWED_ROLES: undefined,
|
|
313
|
+
DISCORD_ALLOWED_USERS: "u1",
|
|
314
|
+
DISCORD_BLOCKED_USERS: "b1,b2,b3",
|
|
315
|
+
});
|
|
316
|
+
expect(config.allowedGuilds.size).toBe(2);
|
|
317
|
+
expect(config.allowedRoles.size).toBe(0);
|
|
318
|
+
expect(config.allowedUsers.size).toBe(1);
|
|
319
|
+
expect(config.blockedUsers.size).toBe(3);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("returns all-empty config when no env vars set", () => {
|
|
323
|
+
const config = loadPermissionConfig({});
|
|
324
|
+
expect(config.allowedGuilds.size).toBe(0);
|
|
325
|
+
expect(config.allowedRoles.size).toBe(0);
|
|
326
|
+
expect(config.allowedUsers.size).toBe(0);
|
|
327
|
+
expect(config.blockedUsers.size).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("handles whitespace in env values", () => {
|
|
331
|
+
const config = loadPermissionConfig({
|
|
332
|
+
DISCORD_ALLOWED_GUILDS: " g1 , g2 ",
|
|
333
|
+
});
|
|
334
|
+
expect(config.allowedGuilds.has("g1")).toBe(true);
|
|
335
|
+
expect(config.allowedGuilds.has("g2")).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Commands: parseCustomCommands ───────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
describe("parseCustomCommands", () => {
|
|
342
|
+
it("parses valid command with options", () => {
|
|
343
|
+
const json = JSON.stringify([
|
|
344
|
+
{
|
|
345
|
+
name: "summarize",
|
|
346
|
+
description: "Summarize a topic",
|
|
347
|
+
options: [{ name: "topic", description: "The topic", type: 3, required: true }],
|
|
348
|
+
promptTemplate: "Please summarize: {{topic}}",
|
|
349
|
+
},
|
|
350
|
+
]);
|
|
351
|
+
const commands = parseCustomCommands(json);
|
|
352
|
+
expect(commands.length).toBe(1);
|
|
353
|
+
expect(commands[0].name).toBe("summarize");
|
|
354
|
+
expect(commands[0].options?.length).toBe(1);
|
|
355
|
+
expect(commands[0].promptTemplate).toBe("Please summarize: {{topic}}");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("rejects uppercase command names", () => {
|
|
359
|
+
const json = JSON.stringify([{ name: "Summarize", description: "Invalid name" }]);
|
|
360
|
+
const commands = parseCustomCommands(json);
|
|
361
|
+
expect(commands.length).toBe(0);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("rejects builtin name conflicts", () => {
|
|
365
|
+
for (const builtin of ["ask", "health", "help", "clear"]) {
|
|
366
|
+
const json = JSON.stringify([{ name: builtin, description: "conflicts" }]);
|
|
367
|
+
const commands = parseCustomCommands(json);
|
|
368
|
+
expect(commands.length).toBe(0);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("returns empty for invalid JSON", () => {
|
|
373
|
+
expect(parseCustomCommands("not-json").length).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("returns empty for undefined", () => {
|
|
377
|
+
expect(parseCustomCommands(undefined).length).toBe(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("returns empty for empty string", () => {
|
|
381
|
+
expect(parseCustomCommands("").length).toBe(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("returns empty for whitespace-only string", () => {
|
|
385
|
+
expect(parseCustomCommands(" ").length).toBe(0);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("returns empty for non-array JSON", () => {
|
|
389
|
+
const json = JSON.stringify({ name: "cmd", description: "not array" });
|
|
390
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("rejects commands with empty description", () => {
|
|
394
|
+
const json = JSON.stringify([{ name: "cmd", description: "" }]);
|
|
395
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("rejects commands with description over 100 chars", () => {
|
|
399
|
+
const json = JSON.stringify([{ name: "cmd", description: "x".repeat(101) }]);
|
|
400
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("rejects commands with special characters in name", () => {
|
|
404
|
+
const json = JSON.stringify([{ name: "cmd!@#", description: "bad name" }]);
|
|
405
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("accepts hyphens and underscores in name", () => {
|
|
409
|
+
const json = JSON.stringify([{ name: "my-cmd_v2", description: "valid name" }]);
|
|
410
|
+
const commands = parseCustomCommands(json);
|
|
411
|
+
expect(commands.length).toBe(1);
|
|
412
|
+
expect(commands[0].name).toBe("my-cmd_v2");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("limits to MAX_CUSTOM_COMMANDS (20)", () => {
|
|
416
|
+
const cmds = Array.from({ length: 25 }, (_, i) => ({
|
|
417
|
+
name: `cmd${i}`,
|
|
418
|
+
description: `Command ${i}`,
|
|
419
|
+
}));
|
|
420
|
+
const json = JSON.stringify(cmds);
|
|
421
|
+
const commands = parseCustomCommands(json);
|
|
422
|
+
expect(commands.length).toBe(20);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("skips null/non-object entries gracefully", () => {
|
|
426
|
+
const json = JSON.stringify([null, 42, "string", { name: "valid", description: "ok" }]);
|
|
427
|
+
const commands = parseCustomCommands(json);
|
|
428
|
+
expect(commands.length).toBe(1);
|
|
429
|
+
expect(commands[0].name).toBe("valid");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("sets ephemeral flag when present", () => {
|
|
433
|
+
const json = JSON.stringify([{ name: "silent", description: "Ephemeral cmd", ephemeral: true }]);
|
|
434
|
+
const commands = parseCustomCommands(json);
|
|
435
|
+
expect(commands[0].ephemeral).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("defaults ephemeral to false", () => {
|
|
439
|
+
const json = JSON.stringify([{ name: "loud", description: "Non-ephemeral" }]);
|
|
440
|
+
const commands = parseCustomCommands(json);
|
|
441
|
+
expect(commands[0].ephemeral).toBe(false);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("validates option types against enum", () => {
|
|
445
|
+
const json = JSON.stringify([{
|
|
446
|
+
name: "typed",
|
|
447
|
+
description: "Has typed options",
|
|
448
|
+
options: [
|
|
449
|
+
{ name: "str", description: "A string", type: CommandOptionType.STRING },
|
|
450
|
+
{ name: "num", description: "A number", type: CommandOptionType.NUMBER },
|
|
451
|
+
{ name: "bool", description: "A boolean", type: CommandOptionType.BOOLEAN },
|
|
452
|
+
{ name: "invalid", description: "Invalid type", type: 99 },
|
|
453
|
+
],
|
|
454
|
+
}]);
|
|
455
|
+
const commands = parseCustomCommands(json);
|
|
456
|
+
expect(commands.length).toBe(1);
|
|
457
|
+
const opts = commands[0].options!;
|
|
458
|
+
expect(opts.length).toBe(4);
|
|
459
|
+
expect(opts[0].type).toBe(CommandOptionType.STRING);
|
|
460
|
+
expect(opts[1].type).toBe(CommandOptionType.NUMBER);
|
|
461
|
+
expect(opts[2].type).toBe(CommandOptionType.BOOLEAN);
|
|
462
|
+
// Invalid type defaults to STRING
|
|
463
|
+
expect(opts[3].type).toBe(CommandOptionType.STRING);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("handles option choices", () => {
|
|
467
|
+
const json = JSON.stringify([{
|
|
468
|
+
name: "pick",
|
|
469
|
+
description: "Pick one",
|
|
470
|
+
options: [{
|
|
471
|
+
name: "color",
|
|
472
|
+
description: "Choose a color",
|
|
473
|
+
type: CommandOptionType.STRING,
|
|
474
|
+
choices: [
|
|
475
|
+
{ name: "Red", value: "red" },
|
|
476
|
+
{ name: "Blue", value: "blue" },
|
|
477
|
+
],
|
|
478
|
+
}],
|
|
479
|
+
}]);
|
|
480
|
+
const commands = parseCustomCommands(json);
|
|
481
|
+
expect(commands[0].options![0].choices?.length).toBe(2);
|
|
482
|
+
expect(commands[0].options![0].choices![0].name).toBe("Red");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("limits choices to 25", () => {
|
|
486
|
+
const choices = Array.from({ length: 30 }, (_, i) => ({ name: `c${i}`, value: `v${i}` }));
|
|
487
|
+
const json = JSON.stringify([{
|
|
488
|
+
name: "many",
|
|
489
|
+
description: "Many choices",
|
|
490
|
+
options: [{ name: "opt", description: "Pick", type: 3, choices }],
|
|
491
|
+
}]);
|
|
492
|
+
const commands = parseCustomCommands(json);
|
|
493
|
+
expect(commands[0].options![0].choices?.length).toBe(25);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ── Commands: buildCommandRegistry ──────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
describe("buildCommandRegistry", () => {
|
|
500
|
+
it("includes all builtins", () => {
|
|
501
|
+
const { all, registrationPayload } = buildCommandRegistry([]);
|
|
502
|
+
expect(all.length).toBe(BUILTIN_COMMANDS.length);
|
|
503
|
+
expect(registrationPayload.length).toBe(BUILTIN_COMMANDS.length);
|
|
504
|
+
expect(all.some((cmd) => cmd.name === "queue")).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("includes custom commands after builtins", () => {
|
|
508
|
+
const custom: CustomCommandDef[] = [{ name: "custom", description: "A custom command" }];
|
|
509
|
+
const { all, registrationPayload } = buildCommandRegistry(custom);
|
|
510
|
+
expect(all.length).toBe(BUILTIN_COMMANDS.length + 1);
|
|
511
|
+
expect(registrationPayload.length).toBe(BUILTIN_COMMANDS.length + 1);
|
|
512
|
+
expect(all[all.length - 1].name).toBe("custom");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("registration payload has correct structure", () => {
|
|
516
|
+
const { registrationPayload } = buildCommandRegistry([]);
|
|
517
|
+
for (const cmd of registrationPayload) {
|
|
518
|
+
expect(cmd.type).toBe(1); // CHAT_INPUT
|
|
519
|
+
expect(typeof cmd.name).toBe("string");
|
|
520
|
+
expect(typeof cmd.description).toBe("string");
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("registration payload includes options for ask command", () => {
|
|
525
|
+
const { registrationPayload } = buildCommandRegistry([]);
|
|
526
|
+
const ask = registrationPayload.find((c) => c.name === "ask");
|
|
527
|
+
expect(ask).toBeDefined();
|
|
528
|
+
expect(ask!.options).toBeDefined();
|
|
529
|
+
expect(ask!.options!.length).toBeGreaterThan(0);
|
|
530
|
+
expect(ask!.options![0].name).toBe("message");
|
|
531
|
+
expect(ask!.options![0].required).toBe(true);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("builtin commands: ask, health, help, clear", () => {
|
|
535
|
+
const names = BUILTIN_COMMANDS.map((c) => c.name);
|
|
536
|
+
expect(names).toContain("ask");
|
|
537
|
+
expect(names).toContain("health");
|
|
538
|
+
expect(names).toContain("help");
|
|
539
|
+
expect(names).toContain("clear");
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// ── Commands: resolvePromptTemplate ─────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
describe("resolvePromptTemplate", () => {
|
|
546
|
+
it("replaces single placeholder", () => {
|
|
547
|
+
expect(resolvePromptTemplate("Hello {{name}}", { name: "Alice" })).toBe("Hello Alice");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("replaces multiple placeholders", () => {
|
|
551
|
+
const result = resolvePromptTemplate("{{greeting}} {{name}}, welcome to {{place}}", {
|
|
552
|
+
greeting: "Hi",
|
|
553
|
+
name: "Bob",
|
|
554
|
+
place: "Discord",
|
|
555
|
+
});
|
|
556
|
+
expect(result).toBe("Hi Bob, welcome to Discord");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("replaces missing placeholders with empty string", () => {
|
|
560
|
+
expect(resolvePromptTemplate("Hello {{name}}", {})).toBe("Hello ");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("leaves text without placeholders unchanged", () => {
|
|
564
|
+
expect(resolvePromptTemplate("No placeholders here", { name: "Alice" })).toBe(
|
|
565
|
+
"No placeholders here",
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("handles repeated same placeholder", () => {
|
|
570
|
+
expect(resolvePromptTemplate("{{x}} and {{x}}", { x: "val" })).toBe("val and val");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("handles empty template", () => {
|
|
574
|
+
expect(resolvePromptTemplate("", { key: "val" })).toBe("");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("handles empty options", () => {
|
|
578
|
+
expect(resolvePromptTemplate("static text", {})).toBe("static text");
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ── Commands: findCommand ───────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
describe("findCommand", () => {
|
|
585
|
+
it("returns command by name", () => {
|
|
586
|
+
const cmd = findCommand(BUILTIN_COMMANDS, "ask");
|
|
587
|
+
expect(cmd?.name).toBe("ask");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("returns undefined for unknown command", () => {
|
|
591
|
+
expect(findCommand(BUILTIN_COMMANDS, "nonexistent")).toBeUndefined();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("returns undefined for empty array", () => {
|
|
595
|
+
expect(findCommand([], "ask")).toBeUndefined();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("is case-sensitive", () => {
|
|
599
|
+
expect(findCommand(BUILTIN_COMMANDS, "ASK")).toBeUndefined();
|
|
600
|
+
expect(findCommand(BUILTIN_COMMANDS, "Ask")).toBeUndefined();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("finds custom commands in mixed array", () => {
|
|
604
|
+
const all: CustomCommandDef[] = [
|
|
605
|
+
...BUILTIN_COMMANDS,
|
|
606
|
+
{ name: "custom", description: "Custom" },
|
|
607
|
+
];
|
|
608
|
+
expect(findCommand(all, "custom")?.name).toBe("custom");
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe("discord command behavior", () => {
|
|
613
|
+
it("/clear forwards a clearSession request with session metadata", async () => {
|
|
614
|
+
const channel = new DiscordChannel();
|
|
615
|
+
const forward = mock(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
616
|
+
const interaction = createInteraction({ commandName: "clear" });
|
|
617
|
+
|
|
618
|
+
Object.assign(channel, { forward });
|
|
619
|
+
|
|
620
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(
|
|
621
|
+
interaction,
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
expect(interaction.deferReply).toHaveBeenCalledWith({ flags: MessageFlags.Ephemeral });
|
|
625
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
626
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({
|
|
627
|
+
userId: "discord:user-1",
|
|
628
|
+
text: "clear session",
|
|
629
|
+
metadata: {
|
|
630
|
+
command: "clear",
|
|
631
|
+
channelId: "channel-1",
|
|
632
|
+
guildId: "guild-1",
|
|
633
|
+
username: "testuser",
|
|
634
|
+
sessionKey: "discord:channel:channel-1:user:user-1",
|
|
635
|
+
clearSession: true,
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
expect(interaction.editReply).toHaveBeenCalledWith("Conversation cleared.");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("thread slash commands include a thread session key", async () => {
|
|
642
|
+
const channel = new DiscordChannel();
|
|
643
|
+
const forward = mock(async () => new Response(JSON.stringify({ answer: "done" }), { status: 200 }));
|
|
644
|
+
const interaction = createInteraction({
|
|
645
|
+
commandName: "ask",
|
|
646
|
+
channelId: "parent-channel",
|
|
647
|
+
channel: { id: "thread-1", isThread: () => true },
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
Object.assign(channel, { forward });
|
|
651
|
+
|
|
652
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(
|
|
653
|
+
interaction,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({
|
|
657
|
+
metadata: {
|
|
658
|
+
command: "ask",
|
|
659
|
+
channelId: "parent-channel",
|
|
660
|
+
sessionKey: "discord:thread:thread-1",
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
expect(interaction.editReply).toHaveBeenCalledWith("done");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("/queue replies immediately when conversation is busy and sends result later", async () => {
|
|
667
|
+
const channel = new DiscordChannel();
|
|
668
|
+
let release = () => {};
|
|
669
|
+
const forward = mock(
|
|
670
|
+
() =>
|
|
671
|
+
new Promise<Response>((resolve) => {
|
|
672
|
+
if (forward.mock.calls.length === 1) {
|
|
673
|
+
release = () => resolve(new Response(JSON.stringify({ answer: "first" }), { status: 200 }));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
resolve(new Response(JSON.stringify({ answer: "second" }), { status: 200 }));
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
680
|
+
Object.assign(channel, { forward });
|
|
681
|
+
|
|
682
|
+
const askInteraction = createInteraction({ commandName: "ask" });
|
|
683
|
+
const queueInteraction = createInteraction({
|
|
684
|
+
commandName: "queue",
|
|
685
|
+
options: { data: [{ name: "message", value: "follow-up" }] },
|
|
686
|
+
reply: mock(async () => {}),
|
|
687
|
+
followUp: mock(async () => {}),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const firstRun = (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(
|
|
691
|
+
askInteraction,
|
|
692
|
+
);
|
|
693
|
+
await Bun.sleep(0);
|
|
694
|
+
|
|
695
|
+
const secondRun = (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(
|
|
696
|
+
queueInteraction,
|
|
697
|
+
);
|
|
698
|
+
await Bun.sleep(0);
|
|
699
|
+
|
|
700
|
+
expect(queueInteraction.reply).toHaveBeenCalledWith({
|
|
701
|
+
content: "Queued. I will run that next.",
|
|
702
|
+
flags: MessageFlags.Ephemeral,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
release();
|
|
706
|
+
await firstRun;
|
|
707
|
+
await secondRun;
|
|
708
|
+
await Bun.sleep(0);
|
|
709
|
+
|
|
710
|
+
expect(queueInteraction.followUp).toHaveBeenCalledWith({ content: "second", flags: MessageFlags.Ephemeral });
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("thread slash commands in different threads do not share a session key", async () => {
|
|
714
|
+
const channel = new DiscordChannel();
|
|
715
|
+
const forward = mock(async () => new Response(JSON.stringify({ answer: "done" }), { status: 200 }));
|
|
716
|
+
Object.assign(channel, { forward });
|
|
717
|
+
|
|
718
|
+
const firstInteraction = createInteraction({
|
|
719
|
+
commandName: "ask",
|
|
720
|
+
channelId: "parent-channel",
|
|
721
|
+
channel: { id: "thread-1", isThread: () => true },
|
|
722
|
+
});
|
|
723
|
+
const secondInteraction = createInteraction({
|
|
724
|
+
commandName: "ask",
|
|
725
|
+
channelId: "parent-channel",
|
|
726
|
+
channel: { id: "thread-2", isThread: () => true },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(firstInteraction);
|
|
730
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(secondInteraction);
|
|
731
|
+
|
|
732
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({ metadata: { sessionKey: "discord:thread:thread-1" } });
|
|
733
|
+
expect(forward.mock.calls[1]?.[0]).toMatchObject({ metadata: { sessionKey: "discord:thread:thread-2" } });
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("thread ask and clear use the same thread session key", async () => {
|
|
737
|
+
const channel = new DiscordChannel();
|
|
738
|
+
const forward = mock(async () => new Response(JSON.stringify({ answer: "done" }), { status: 200 }));
|
|
739
|
+
Object.assign(channel, { forward });
|
|
740
|
+
|
|
741
|
+
const askInteraction = createInteraction({
|
|
742
|
+
commandName: "ask",
|
|
743
|
+
channelId: "parent-channel",
|
|
744
|
+
channel: { id: "thread-77", isThread: () => true },
|
|
745
|
+
});
|
|
746
|
+
const clearInteraction = createInteraction({
|
|
747
|
+
commandName: "clear",
|
|
748
|
+
channelId: "parent-channel",
|
|
749
|
+
channel: { id: "thread-77", isThread: () => true },
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(askInteraction);
|
|
753
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(clearInteraction);
|
|
754
|
+
|
|
755
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({ metadata: { sessionKey: "discord:thread:thread-77" } });
|
|
756
|
+
expect(forward.mock.calls[1]?.[0]).toMatchObject({
|
|
757
|
+
metadata: {
|
|
758
|
+
sessionKey: "discord:thread:thread-77",
|
|
759
|
+
clearSession: true,
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// Regression: a throwing command handler (e.g. an expired/already-acknowledged
|
|
765
|
+
// interaction → DiscordAPIError 10062/40060 from interaction.reply) must NOT
|
|
766
|
+
// escape onSlashCommand. The InteractionCreate listener fires it
|
|
767
|
+
// fire-and-forget, so an escaped rejection becomes an unhandled rejection and
|
|
768
|
+
// crashes the Bun process (which is what took the portal container down).
|
|
769
|
+
it("onSlashCommand swallows a throwing handler instead of rejecting", async () => {
|
|
770
|
+
const channel = new DiscordChannel();
|
|
771
|
+
const replyError = new Error("Unknown interaction");
|
|
772
|
+
const interaction = createInteraction({
|
|
773
|
+
commandName: "help",
|
|
774
|
+
reply: mock(async () => {
|
|
775
|
+
throw replyError;
|
|
776
|
+
}),
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// Must resolve, not reject — a rejection here is the crash we are guarding against.
|
|
780
|
+
await expect(
|
|
781
|
+
(channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(interaction),
|
|
782
|
+
).resolves.toBeUndefined();
|
|
783
|
+
|
|
784
|
+
// The handler reply throws, then the catch attempts one best-effort fallback reply.
|
|
785
|
+
expect(interaction.reply).toHaveBeenCalledTimes(2);
|
|
786
|
+
expect(interaction.reply.mock.calls[1]?.[0]).toMatchObject({
|
|
787
|
+
content: "An error occurred.",
|
|
788
|
+
flags: MessageFlags.Ephemeral,
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// ── Timeout behavior ────────────────────────────────────────────────────────
|
|
794
|
+
|
|
795
|
+
describe("timeout handling", () => {
|
|
796
|
+
it("forwardToGuardian surfaces timeout errors as message_error", async () => {
|
|
797
|
+
const channel = new DiscordChannel();
|
|
798
|
+
// Simulate a timeout by having forward reject with an abort error
|
|
799
|
+
const forward = mock(async () => {
|
|
800
|
+
throw new Error("The operation timed out.");
|
|
801
|
+
});
|
|
802
|
+
Object.assign(channel, { forward });
|
|
803
|
+
|
|
804
|
+
const interaction = createInteraction({
|
|
805
|
+
commandName: "ask",
|
|
806
|
+
options: { data: [{ name: "message", value: "long running task" }] },
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(
|
|
810
|
+
interaction,
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
// Should have deferred, then edited with the error
|
|
814
|
+
expect(interaction.deferReply).toHaveBeenCalled();
|
|
815
|
+
expect(interaction.editReply).toHaveBeenCalledWith(
|
|
816
|
+
"Error: The operation timed out.",
|
|
817
|
+
);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("thread message surfaces timeout error in thread", async () => {
|
|
821
|
+
const channel = new DiscordChannel();
|
|
822
|
+
const forward = mock(async () => {
|
|
823
|
+
throw new Error("The operation timed out.");
|
|
824
|
+
});
|
|
825
|
+
Object.assign(channel, { forward });
|
|
826
|
+
|
|
827
|
+
const sentMessages: string[] = [];
|
|
828
|
+
const fakeThread = {
|
|
829
|
+
id: "thread-timeout",
|
|
830
|
+
send: mock(async (msg: string) => { sentMessages.push(msg); }),
|
|
831
|
+
sendTyping: mock(async () => {}),
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// Directly test runThreadConversation
|
|
835
|
+
const runConvo = (channel as unknown as {
|
|
836
|
+
runThreadConversation: (
|
|
837
|
+
thread: unknown,
|
|
838
|
+
userInfo: UserInfo,
|
|
839
|
+
text: string,
|
|
840
|
+
metadata: Record<string, unknown>,
|
|
841
|
+
) => Promise<void>;
|
|
842
|
+
}).runThreadConversation.bind(channel);
|
|
843
|
+
|
|
844
|
+
await runConvo(
|
|
845
|
+
fakeThread,
|
|
846
|
+
testUser(),
|
|
847
|
+
"long running task",
|
|
848
|
+
{ sessionKey: "test-key" },
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
// Should send the error to the thread
|
|
852
|
+
expect(sentMessages.length).toBe(1);
|
|
853
|
+
expect(sentMessages[0]).toContain("The operation timed out.");
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// ── DiscordChannel class ────────────────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
describe("DiscordChannel", () => {
|
|
860
|
+
it("has name 'discord'", () => {
|
|
861
|
+
const channel = new DiscordChannel();
|
|
862
|
+
expect(channel.name).toBe("discord");
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("botToken reads from DISCORD_BOT_TOKEN_FILE", () => {
|
|
866
|
+
withSecretFile("DISCORD_BOT_TOKEN_FILE", "discord-token", () => {
|
|
867
|
+
const channel = new DiscordChannel();
|
|
868
|
+
expect(channel.botToken).toBe("discord-token");
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it("applicationId reads from env", () => {
|
|
873
|
+
const channel = new DiscordChannel();
|
|
874
|
+
expect(typeof channel.applicationId).toBe("string");
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("inherits port from env or defaults to 8080", () => {
|
|
878
|
+
const channel = new DiscordChannel();
|
|
879
|
+
expect(typeof channel.port).toBe("number");
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("inherits guardianUrl from env or defaults", () => {
|
|
883
|
+
const channel = new DiscordChannel();
|
|
884
|
+
expect(typeof channel.guardianUrl).toBe("string");
|
|
885
|
+
expect(channel.guardianUrl).toContain("guardian");
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it("secret resolves from PRINCIPAL_SECRET_FILE", () => {
|
|
889
|
+
withSecretFile("PRINCIPAL_SECRET_FILE", "channel-secret", () => {
|
|
890
|
+
const channel = new DiscordChannel();
|
|
891
|
+
expect(channel.secret).toBe("channel-secret");
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// ── Thread TTL tracking ─────────────────────────────────────────────────────
|
|
897
|
+
|
|
898
|
+
describe("thread TTL tracking", () => {
|
|
899
|
+
it("isThreadActive returns false for unknown thread", () => {
|
|
900
|
+
const channel = new DiscordChannel();
|
|
901
|
+
const isActive = (channel as unknown as { isThreadActive: (id: string) => boolean }).isThreadActive;
|
|
902
|
+
expect(isActive.call(channel, "unknown-thread")).toBe(false);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("touchThread makes thread active", () => {
|
|
906
|
+
const channel = new DiscordChannel();
|
|
907
|
+
const touch = (channel as unknown as { touchThread: (id: string) => void }).touchThread;
|
|
908
|
+
const isActive = (channel as unknown as { isThreadActive: (id: string) => boolean }).isThreadActive;
|
|
909
|
+
|
|
910
|
+
touch.call(channel, "thread-1");
|
|
911
|
+
expect(isActive.call(channel, "thread-1")).toBe(true);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("forgetThread makes thread inactive", () => {
|
|
915
|
+
const channel = new DiscordChannel();
|
|
916
|
+
const touch = (channel as unknown as { touchThread: (id: string) => void }).touchThread;
|
|
917
|
+
const forget = (channel as unknown as { forgetThread: (id: string) => void }).forgetThread;
|
|
918
|
+
const isActive = (channel as unknown as { isThreadActive: (id: string) => boolean }).isThreadActive;
|
|
919
|
+
|
|
920
|
+
touch.call(channel, "thread-1");
|
|
921
|
+
expect(isActive.call(channel, "thread-1")).toBe(true);
|
|
922
|
+
|
|
923
|
+
forget.call(channel, "thread-1");
|
|
924
|
+
expect(isActive.call(channel, "thread-1")).toBe(false);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it("expired thread is not active", () => {
|
|
928
|
+
const channel = new DiscordChannel();
|
|
929
|
+
// Set a very short TTL for testing
|
|
930
|
+
Object.assign(channel, { threadTtlMs: 1 });
|
|
931
|
+
|
|
932
|
+
const touch = (channel as unknown as { touchThread: (id: string) => void }).touchThread;
|
|
933
|
+
const isActive = (channel as unknown as { isThreadActive: (id: string) => boolean }).isThreadActive;
|
|
934
|
+
const activeThreads = (channel as unknown as { activeThreads: Map<string, number> }).activeThreads;
|
|
935
|
+
|
|
936
|
+
// Manually set an old timestamp
|
|
937
|
+
activeThreads.set("old-thread", Date.now() - 100);
|
|
938
|
+
expect(isActive.call(channel, "old-thread")).toBe(false);
|
|
939
|
+
// Should also be cleaned up from the map
|
|
940
|
+
expect(activeThreads.has("old-thread")).toBe(false);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it("touchThread prunes stale entries when map is large", () => {
|
|
944
|
+
const channel = new DiscordChannel();
|
|
945
|
+
Object.assign(channel, { threadTtlMs: 1 });
|
|
946
|
+
|
|
947
|
+
const touch = (channel as unknown as { touchThread: (id: string) => void }).touchThread;
|
|
948
|
+
const activeThreads = (channel as unknown as { activeThreads: Map<string, number> }).activeThreads;
|
|
949
|
+
|
|
950
|
+
// Add 101 stale entries
|
|
951
|
+
const staleTime = Date.now() - 100;
|
|
952
|
+
for (let i = 0; i < 101; i++) {
|
|
953
|
+
activeThreads.set(`stale-${i}`, staleTime);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Touch a new thread — should trigger pruning
|
|
957
|
+
touch.call(channel, "fresh-thread");
|
|
958
|
+
|
|
959
|
+
// All stale entries should be pruned, only fresh one remains
|
|
960
|
+
expect(activeThreads.size).toBe(1);
|
|
961
|
+
expect(activeThreads.has("fresh-thread")).toBe(true);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("/clear command forgets thread", async () => {
|
|
965
|
+
const channel = new DiscordChannel();
|
|
966
|
+
const forward = mock(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
967
|
+
Object.assign(channel, { forward });
|
|
968
|
+
|
|
969
|
+
// Touch a thread first
|
|
970
|
+
const touch = (channel as unknown as { touchThread: (id: string) => void }).touchThread;
|
|
971
|
+
const isActive = (channel as unknown as { isThreadActive: (id: string) => boolean }).isThreadActive;
|
|
972
|
+
touch.call(channel, "thread-clear");
|
|
973
|
+
|
|
974
|
+
expect(isActive.call(channel, "thread-clear")).toBe(true);
|
|
975
|
+
|
|
976
|
+
const interaction = createInteraction({
|
|
977
|
+
commandName: "clear",
|
|
978
|
+
channel: { id: "thread-clear", isThread: () => true },
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
await (channel as unknown as { onSlashCommand: (input: TestInteraction) => Promise<void> }).onSlashCommand(
|
|
982
|
+
interaction,
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
// Thread should no longer be active after /clear
|
|
986
|
+
expect(isActive.call(channel, "thread-clear")).toBe(false);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it("forward timeout defaults to 0 (off)", () => {
|
|
990
|
+
const channel = new DiscordChannel();
|
|
991
|
+
const timeoutMs = (channel as unknown as { forwardTimeoutMs: number }).forwardTimeoutMs;
|
|
992
|
+
expect(timeoutMs).toBe(0);
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// ── CommandOptionType enum ──────────────────────────────────────────────────
|
|
997
|
+
|
|
998
|
+
describe("CommandOptionType", () => {
|
|
999
|
+
it("has expected values matching Discord API", () => {
|
|
1000
|
+
expect(CommandOptionType.SUB_COMMAND).toBe(1);
|
|
1001
|
+
expect(CommandOptionType.SUB_COMMAND_GROUP).toBe(2);
|
|
1002
|
+
expect(CommandOptionType.STRING).toBe(3);
|
|
1003
|
+
expect(CommandOptionType.INTEGER).toBe(4);
|
|
1004
|
+
expect(CommandOptionType.BOOLEAN).toBe(5);
|
|
1005
|
+
expect(CommandOptionType.USER).toBe(6);
|
|
1006
|
+
expect(CommandOptionType.CHANNEL).toBe(7);
|
|
1007
|
+
expect(CommandOptionType.ROLE).toBe(8);
|
|
1008
|
+
expect(CommandOptionType.MENTIONABLE).toBe(9);
|
|
1009
|
+
expect(CommandOptionType.NUMBER).toBe(10);
|
|
1010
|
+
expect(CommandOptionType.ATTACHMENT).toBe(11);
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
// ── Edge Cases: Command validation ──────────────────────────────────────────
|
|
1015
|
+
|
|
1016
|
+
describe("command validation edge cases", () => {
|
|
1017
|
+
it("rejects command name longer than 32 chars", () => {
|
|
1018
|
+
const json = JSON.stringify([{ name: "a".repeat(33), description: "Too long name" }]);
|
|
1019
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("accepts command name exactly 32 chars", () => {
|
|
1023
|
+
const json = JSON.stringify([{ name: "a".repeat(32), description: "Max length name" }]);
|
|
1024
|
+
expect(parseCustomCommands(json).length).toBe(1);
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("accepts command name of 1 char", () => {
|
|
1028
|
+
const json = JSON.stringify([{ name: "x", description: "Tiny name" }]);
|
|
1029
|
+
expect(parseCustomCommands(json).length).toBe(1);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it("rejects command with spaces in name", () => {
|
|
1033
|
+
const json = JSON.stringify([{ name: "my cmd", description: "Spaces" }]);
|
|
1034
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it("rejects command with no name", () => {
|
|
1038
|
+
const json = JSON.stringify([{ description: "No name field" }]);
|
|
1039
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("rejects command with numeric name", () => {
|
|
1043
|
+
const json = JSON.stringify([{ name: 42, description: "Numeric name" }]);
|
|
1044
|
+
expect(parseCustomCommands(json).length).toBe(0);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("rejects option with invalid name", () => {
|
|
1048
|
+
const json = JSON.stringify([{
|
|
1049
|
+
name: "cmd",
|
|
1050
|
+
description: "ok",
|
|
1051
|
+
options: [{ name: "BAD NAME!", description: "invalid" }],
|
|
1052
|
+
}]);
|
|
1053
|
+
const commands = parseCustomCommands(json);
|
|
1054
|
+
expect(commands.length).toBe(1);
|
|
1055
|
+
// Invalid options are filtered out
|
|
1056
|
+
expect(commands[0].options?.length).toBe(0);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it("rejects option with missing description", () => {
|
|
1060
|
+
const json = JSON.stringify([{
|
|
1061
|
+
name: "cmd",
|
|
1062
|
+
description: "ok",
|
|
1063
|
+
options: [{ name: "opt" }],
|
|
1064
|
+
}]);
|
|
1065
|
+
const commands = parseCustomCommands(json);
|
|
1066
|
+
expect(commands.length).toBe(1);
|
|
1067
|
+
expect(commands[0].options?.length).toBe(0);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("filters invalid choices from options", () => {
|
|
1071
|
+
const json = JSON.stringify([{
|
|
1072
|
+
name: "cmd",
|
|
1073
|
+
description: "ok",
|
|
1074
|
+
options: [{
|
|
1075
|
+
name: "opt",
|
|
1076
|
+
description: "has choices",
|
|
1077
|
+
type: 3,
|
|
1078
|
+
choices: [
|
|
1079
|
+
{ name: "Good", value: "good" },
|
|
1080
|
+
{ name: 42, value: "bad" }, // invalid: name not string
|
|
1081
|
+
null, // invalid: null
|
|
1082
|
+
{ name: "Also Good", value: "also" },
|
|
1083
|
+
],
|
|
1084
|
+
}],
|
|
1085
|
+
}]);
|
|
1086
|
+
const commands = parseCustomCommands(json);
|
|
1087
|
+
expect(commands[0].options![0].choices?.length).toBe(2);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("handles command with promptTemplate containing multiple variables", () => {
|
|
1091
|
+
const json = JSON.stringify([{
|
|
1092
|
+
name: "translate",
|
|
1093
|
+
description: "Translate text",
|
|
1094
|
+
options: [
|
|
1095
|
+
{ name: "text", description: "Text to translate", type: 3, required: true },
|
|
1096
|
+
{ name: "lang", description: "Target language", type: 3, required: true },
|
|
1097
|
+
],
|
|
1098
|
+
promptTemplate: "Translate '{{text}}' to {{lang}}",
|
|
1099
|
+
}]);
|
|
1100
|
+
const commands = parseCustomCommands(json);
|
|
1101
|
+
expect(commands.length).toBe(1);
|
|
1102
|
+
const resolved = resolvePromptTemplate(commands[0].promptTemplate!, { text: "hello", lang: "Spanish" });
|
|
1103
|
+
expect(resolved).toBe("Translate 'hello' to Spanish");
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// ── Integration: full command flow ──────────────────────────────────────────
|
|
1108
|
+
|
|
1109
|
+
describe("full command flow", () => {
|
|
1110
|
+
it("custom command: parse → registry → find → resolve template", () => {
|
|
1111
|
+
const json = JSON.stringify([{
|
|
1112
|
+
name: "explain",
|
|
1113
|
+
description: "Explain a concept",
|
|
1114
|
+
options: [{ name: "topic", description: "What to explain", type: 3, required: true }],
|
|
1115
|
+
promptTemplate: "Explain {{topic}} in simple terms",
|
|
1116
|
+
}]);
|
|
1117
|
+
|
|
1118
|
+
const custom = parseCustomCommands(json);
|
|
1119
|
+
expect(custom.length).toBe(1);
|
|
1120
|
+
|
|
1121
|
+
const { all } = buildCommandRegistry(custom);
|
|
1122
|
+
const cmd = findCommand(all, "explain");
|
|
1123
|
+
expect(cmd).toBeDefined();
|
|
1124
|
+
expect(cmd!.promptTemplate).toBeDefined();
|
|
1125
|
+
|
|
1126
|
+
const prompt = resolvePromptTemplate(cmd!.promptTemplate!, { topic: "recursion" });
|
|
1127
|
+
expect(prompt).toBe("Explain recursion in simple terms");
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it("ask command exists with required message option", () => {
|
|
1131
|
+
const { all } = buildCommandRegistry([]);
|
|
1132
|
+
const ask = findCommand(all, "ask");
|
|
1133
|
+
expect(ask).toBeDefined();
|
|
1134
|
+
expect(ask!.options).toBeDefined();
|
|
1135
|
+
const msgOpt = ask!.options!.find((o) => o.name === "message");
|
|
1136
|
+
expect(msgOpt).toBeDefined();
|
|
1137
|
+
expect(msgOpt!.required).toBe(true);
|
|
1138
|
+
expect(msgOpt!.type).toBe(CommandOptionType.STRING);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("ephemeral builtins: health, help, clear", () => {
|
|
1142
|
+
for (const name of ["health", "help", "clear"]) {
|
|
1143
|
+
const cmd = findCommand(BUILTIN_COMMANDS, name);
|
|
1144
|
+
expect(cmd).toBeDefined();
|
|
1145
|
+
expect(cmd!.ephemeral).toBe(true);
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("ask command is not ephemeral", () => {
|
|
1150
|
+
const ask = findCommand(BUILTIN_COMMANDS, "ask");
|
|
1151
|
+
expect(ask).toBeDefined();
|
|
1152
|
+
expect(ask!.ephemeral).toBeFalsy();
|
|
1153
|
+
});
|
|
1154
|
+
});
|