@openpalm/slack-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 +95 -0
- package/package.json +24 -0
- package/src/index.test.ts +1580 -0
- package/src/index.ts +1177 -0
- package/src/oc-events.ts +157 -0
- package/src/opencode.test.ts +79 -0
- package/src/opencode.ts +130 -0
- package/src/permissions.ts +46 -0
- package/src/runtime.ts +211 -0
- package/src/stream-render.test.ts +182 -0
- package/src/stream-render.ts +517 -0
- package/src/types.ts +17 -0
|
@@ -0,0 +1,1580 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import SlackChannel, { DEFAULT_FORWARD_TIMEOUT_MS, parseForwardTimeoutMs } from "./index.ts";
|
|
6
|
+
import { checkPermissions, loadPermissionConfig, parseIdList } from "./permissions.ts";
|
|
7
|
+
import { ConversationQueue } from './runtime.ts';
|
|
8
|
+
import type { PermissionConfig, PermissionResult, UserInfo } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function emptyPermissions(): PermissionConfig {
|
|
13
|
+
return {
|
|
14
|
+
allowedChannels: new Set(),
|
|
15
|
+
allowedUsers: new Set(),
|
|
16
|
+
blockedUsers: new Set(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function testUser(overrides: Partial<UserInfo> = {}): UserInfo {
|
|
21
|
+
return {
|
|
22
|
+
userId: "U12345",
|
|
23
|
+
teamId: "T12345",
|
|
24
|
+
channelId: "C12345",
|
|
25
|
+
username: "testuser",
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withSecretFile(envKey: string, value: string, run: () => void): void {
|
|
31
|
+
const original = Bun.env[envKey];
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "openpalm-channel-test-"));
|
|
33
|
+
const path = join(dir, envKey.toLowerCase());
|
|
34
|
+
writeFileSync(path, `${value}\n`);
|
|
35
|
+
try {
|
|
36
|
+
Bun.env[envKey] = path;
|
|
37
|
+
run();
|
|
38
|
+
} finally {
|
|
39
|
+
if (original === undefined) delete Bun.env[envKey];
|
|
40
|
+
else Bun.env[envKey] = original;
|
|
41
|
+
rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
46
|
+
let resolve = () => {};
|
|
47
|
+
const promise = new Promise<void>((r) => {
|
|
48
|
+
resolve = r;
|
|
49
|
+
});
|
|
50
|
+
return { promise, resolve };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type MockClient = {
|
|
54
|
+
chat: {
|
|
55
|
+
postMessage: ReturnType<typeof mock>;
|
|
56
|
+
update: ReturnType<typeof mock>;
|
|
57
|
+
};
|
|
58
|
+
conversations: {
|
|
59
|
+
open: ReturnType<typeof mock>;
|
|
60
|
+
};
|
|
61
|
+
users: {
|
|
62
|
+
info: ReturnType<typeof mock>;
|
|
63
|
+
};
|
|
64
|
+
views: {
|
|
65
|
+
open: ReturnType<typeof mock>;
|
|
66
|
+
publish: ReturnType<typeof mock>;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function createMockClient(): MockClient {
|
|
71
|
+
return {
|
|
72
|
+
chat: {
|
|
73
|
+
postMessage: mock(async () => ({ ts: "1234567890.123456" })),
|
|
74
|
+
update: mock(async () => ({})),
|
|
75
|
+
},
|
|
76
|
+
conversations: {
|
|
77
|
+
open: mock(async () => ({ channel: { id: "D123" } })),
|
|
78
|
+
},
|
|
79
|
+
users: {
|
|
80
|
+
info: mock(async ({ user }: { user: string }) => ({ user: { name: user } })),
|
|
81
|
+
},
|
|
82
|
+
views: {
|
|
83
|
+
open: mock(async () => ({})),
|
|
84
|
+
publish: mock(async () => ({})),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type MockSay = ReturnType<typeof mock>;
|
|
90
|
+
|
|
91
|
+
function createMockSay(): MockSay {
|
|
92
|
+
return mock(async () => ({}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
delete Bun.env.SLACK_FORWARD_TIMEOUT_MS;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── Forward timeout parsing ──────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("parseForwardTimeoutMs", () => {
|
|
102
|
+
it("uses default when value is missing", () => {
|
|
103
|
+
expect(parseForwardTimeoutMs(undefined)).toBe(DEFAULT_FORWARD_TIMEOUT_MS);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("uses default when value is invalid, zero, or negative", () => {
|
|
107
|
+
expect(parseForwardTimeoutMs("nope")).toBe(DEFAULT_FORWARD_TIMEOUT_MS);
|
|
108
|
+
expect(parseForwardTimeoutMs("0")).toBe(DEFAULT_FORWARD_TIMEOUT_MS);
|
|
109
|
+
expect(parseForwardTimeoutMs("-1")).toBe(DEFAULT_FORWARD_TIMEOUT_MS);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("uses the configured positive value", () => {
|
|
113
|
+
expect(parseForwardTimeoutMs("12345")).toBe(12345);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── parseIdList ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("parseIdList", () => {
|
|
120
|
+
it("returns empty set for undefined", () => {
|
|
121
|
+
expect(parseIdList(undefined).size).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns empty set for empty string", () => {
|
|
125
|
+
expect(parseIdList("").size).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns empty set for whitespace-only", () => {
|
|
129
|
+
expect(parseIdList(" ").size).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("splits comma-separated values", () => {
|
|
133
|
+
const result = parseIdList("a,b,c");
|
|
134
|
+
expect(result.size).toBe(3);
|
|
135
|
+
expect(result.has("a")).toBe(true);
|
|
136
|
+
expect(result.has("b")).toBe(true);
|
|
137
|
+
expect(result.has("c")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("trims whitespace", () => {
|
|
141
|
+
const result = parseIdList(" a , b , c ");
|
|
142
|
+
expect(result.has("a")).toBe(true);
|
|
143
|
+
expect(result.has("b")).toBe(true);
|
|
144
|
+
expect(result.has("c")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("filters empty entries", () => {
|
|
148
|
+
const result = parseIdList("a,,b,,,c");
|
|
149
|
+
expect(result.size).toBe(3);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("handles single value", () => {
|
|
153
|
+
const result = parseIdList("U12345");
|
|
154
|
+
expect(result.size).toBe(1);
|
|
155
|
+
expect(result.has("U12345")).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("deduplicates repeated IDs", () => {
|
|
159
|
+
const result = parseIdList("id1,id1,id1");
|
|
160
|
+
expect(result.size).toBe(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("filters entries from trailing commas", () => {
|
|
164
|
+
const result = parseIdList("id1,,id2,");
|
|
165
|
+
expect(result.size).toBe(2);
|
|
166
|
+
expect(result.has("id1")).toBe(true);
|
|
167
|
+
expect(result.has("id2")).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── checkPermissions ────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("checkPermissions", () => {
|
|
174
|
+
it("allows when all lists are empty", () => {
|
|
175
|
+
const result = checkPermissions(emptyPermissions(), testUser());
|
|
176
|
+
expect(result.allowed).toBe(true);
|
|
177
|
+
expect(result.reason).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("blocks a blocked user", () => {
|
|
181
|
+
const config = { ...emptyPermissions(), blockedUsers: new Set(["U12345"]) };
|
|
182
|
+
const result = checkPermissions(config, testUser());
|
|
183
|
+
expect(result.allowed).toBe(false);
|
|
184
|
+
expect(result.reason).toBe("user_blocked");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("blocked takes precedence over allowed", () => {
|
|
188
|
+
const config: PermissionConfig = {
|
|
189
|
+
allowedChannels: new Set(),
|
|
190
|
+
allowedUsers: new Set(["U12345"]),
|
|
191
|
+
blockedUsers: new Set(["U12345"]),
|
|
192
|
+
};
|
|
193
|
+
const result = checkPermissions(config, testUser());
|
|
194
|
+
expect(result.allowed).toBe(false);
|
|
195
|
+
expect(result.reason).toBe("user_blocked");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("allows user in allowedUsers", () => {
|
|
199
|
+
const config = { ...emptyPermissions(), allowedUsers: new Set(["U12345"]) };
|
|
200
|
+
const result = checkPermissions(config, testUser());
|
|
201
|
+
expect(result.allowed).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("denies user not in allowedUsers", () => {
|
|
205
|
+
const config = { ...emptyPermissions(), allowedUsers: new Set(["U99999"]) };
|
|
206
|
+
const result = checkPermissions(config, testUser());
|
|
207
|
+
expect(result.allowed).toBe(false);
|
|
208
|
+
expect(result.reason).toBe("user_not_allowed");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("allows user in allowed channel", () => {
|
|
212
|
+
const config = { ...emptyPermissions(), allowedChannels: new Set(["C12345"]) };
|
|
213
|
+
const result = checkPermissions(config, testUser());
|
|
214
|
+
expect(result.allowed).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("denies user not in allowed channel", () => {
|
|
218
|
+
const config = { ...emptyPermissions(), allowedChannels: new Set(["C99999"]) };
|
|
219
|
+
const result = checkPermissions(config, testUser());
|
|
220
|
+
expect(result.allowed).toBe(false);
|
|
221
|
+
expect(result.reason).toBe("channel_not_allowed");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("allows when no channel provided and channels unrestricted", () => {
|
|
225
|
+
const result = checkPermissions(emptyPermissions(), testUser({ channelId: "" }));
|
|
226
|
+
expect(result.allowed).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("denies when channel required but empty", () => {
|
|
230
|
+
const config = { ...emptyPermissions(), allowedChannels: new Set(["C12345"]) };
|
|
231
|
+
const result = checkPermissions(config, testUser({ channelId: "" }));
|
|
232
|
+
expect(result.allowed).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("denies user with empty userId when users are restricted", () => {
|
|
236
|
+
const config = { ...emptyPermissions(), allowedUsers: new Set(["U12345"]) };
|
|
237
|
+
const result = checkPermissions(config, testUser({ userId: "" }));
|
|
238
|
+
expect(result.allowed).toBe(false);
|
|
239
|
+
expect(result.reason).toBe("user_not_allowed");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("checks both user and channel restrictions", () => {
|
|
243
|
+
const config: PermissionConfig = {
|
|
244
|
+
allowedChannels: new Set(["C12345"]),
|
|
245
|
+
allowedUsers: new Set(["U12345"]),
|
|
246
|
+
blockedUsers: new Set(),
|
|
247
|
+
};
|
|
248
|
+
const result = checkPermissions(config, testUser());
|
|
249
|
+
expect(result.allowed).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("fails if user matches but channel does not", () => {
|
|
253
|
+
const config: PermissionConfig = {
|
|
254
|
+
allowedChannels: new Set(["C99999"]),
|
|
255
|
+
allowedUsers: new Set(["U12345"]),
|
|
256
|
+
blockedUsers: new Set(),
|
|
257
|
+
};
|
|
258
|
+
const result = checkPermissions(config, testUser());
|
|
259
|
+
expect(result.allowed).toBe(false);
|
|
260
|
+
expect(result.reason).toBe("channel_not_allowed");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ── loadPermissionConfig ────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
describe("loadPermissionConfig", () => {
|
|
267
|
+
it("loads from env vars", () => {
|
|
268
|
+
const config = loadPermissionConfig({
|
|
269
|
+
SLACK_ALLOWED_CHANNELS: "C1,C2",
|
|
270
|
+
SLACK_ALLOWED_USERS: "U1",
|
|
271
|
+
SLACK_BLOCKED_USERS: "U99",
|
|
272
|
+
});
|
|
273
|
+
expect(config.allowedChannels.size).toBe(2);
|
|
274
|
+
expect(config.allowedUsers.size).toBe(1);
|
|
275
|
+
expect(config.blockedUsers.size).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("returns empty sets when env vars missing", () => {
|
|
279
|
+
const config = loadPermissionConfig({});
|
|
280
|
+
expect(config.allowedChannels.size).toBe(0);
|
|
281
|
+
expect(config.allowedUsers.size).toBe(0);
|
|
282
|
+
expect(config.blockedUsers.size).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("handles whitespace in env values", () => {
|
|
286
|
+
const config = loadPermissionConfig({
|
|
287
|
+
SLACK_ALLOWED_CHANNELS: " C1 , C2 ",
|
|
288
|
+
});
|
|
289
|
+
expect(config.allowedChannels.has("C1")).toBe(true);
|
|
290
|
+
expect(config.allowedChannels.has("C2")).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── ConversationQueue ───────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
describe("ConversationQueue", () => {
|
|
297
|
+
it("runs task immediately when not processing", async () => {
|
|
298
|
+
const queue = new ConversationQueue();
|
|
299
|
+
let ran = false;
|
|
300
|
+
const result = await queue.runOrQueue("key1", {
|
|
301
|
+
run: async () => {
|
|
302
|
+
ran = true;
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
expect(result).toBe("started");
|
|
306
|
+
expect(ran).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("queues task when already processing", async () => {
|
|
310
|
+
const queue = new ConversationQueue();
|
|
311
|
+
const order: number[] = [];
|
|
312
|
+
const blocker = deferred();
|
|
313
|
+
|
|
314
|
+
const firstPromise = queue.runOrQueue("key1", {
|
|
315
|
+
run: async () => {
|
|
316
|
+
order.push(1);
|
|
317
|
+
await blocker.promise;
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const secondResult = await queue.runOrQueue("key1", {
|
|
322
|
+
run: async () => {
|
|
323
|
+
order.push(2);
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(secondResult).toBe("queued");
|
|
328
|
+
blocker.resolve();
|
|
329
|
+
await firstPromise;
|
|
330
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
331
|
+
expect(order).toEqual([1, 2]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("calls onQueued when task is queued", async () => {
|
|
335
|
+
const queue = new ConversationQueue();
|
|
336
|
+
let queuedCalled = false;
|
|
337
|
+
const blocker = deferred();
|
|
338
|
+
|
|
339
|
+
queue.runOrQueue("key1", {
|
|
340
|
+
run: async () => {
|
|
341
|
+
await blocker.promise;
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await queue.runOrQueue("key1", {
|
|
346
|
+
onQueued: async () => {
|
|
347
|
+
queuedCalled = true;
|
|
348
|
+
},
|
|
349
|
+
run: async () => {},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(queuedCalled).toBe(true);
|
|
353
|
+
blocker.resolve();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("clear drops queued tasks", async () => {
|
|
357
|
+
const queue = new ConversationQueue();
|
|
358
|
+
const blocker = deferred();
|
|
359
|
+
|
|
360
|
+
queue.runOrQueue("key1", {
|
|
361
|
+
run: async () => {
|
|
362
|
+
await blocker.promise;
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
queue.runOrQueue("key1", { run: async () => {} });
|
|
367
|
+
queue.runOrQueue("key1", { run: async () => {} });
|
|
368
|
+
|
|
369
|
+
const dropped = queue.clear("key1");
|
|
370
|
+
expect(dropped).toBe(2);
|
|
371
|
+
|
|
372
|
+
blocker.resolve();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("isProcessing returns correct state", async () => {
|
|
376
|
+
const queue = new ConversationQueue();
|
|
377
|
+
expect(queue.isProcessing("key1")).toBe(false);
|
|
378
|
+
|
|
379
|
+
const blocker = deferred();
|
|
380
|
+
|
|
381
|
+
queue.runOrQueue("key1", {
|
|
382
|
+
run: async () => {
|
|
383
|
+
await blocker.promise;
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
expect(queue.isProcessing("key1")).toBe(true);
|
|
388
|
+
blocker.resolve();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("queuedCount tracks pending tasks", async () => {
|
|
392
|
+
const queue = new ConversationQueue();
|
|
393
|
+
const blocker = deferred();
|
|
394
|
+
|
|
395
|
+
queue.runOrQueue("key1", {
|
|
396
|
+
run: async () => {
|
|
397
|
+
await blocker.promise;
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(queue.queuedCount("key1")).toBe(0);
|
|
402
|
+
|
|
403
|
+
queue.runOrQueue("key1", { run: async () => {} });
|
|
404
|
+
expect(queue.queuedCount("key1")).toBe(1);
|
|
405
|
+
|
|
406
|
+
queue.runOrQueue("key1", { run: async () => {} });
|
|
407
|
+
expect(queue.queuedCount("key1")).toBe(2);
|
|
408
|
+
|
|
409
|
+
blocker.resolve();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("cleans up state after all tasks complete", async () => {
|
|
413
|
+
const queue = new ConversationQueue();
|
|
414
|
+
await queue.runOrQueue("key1", { run: async () => {} });
|
|
415
|
+
expect(queue.isProcessing("key1")).toBe(false);
|
|
416
|
+
expect(queue.queuedCount("key1")).toBe(0);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("clear returns 0 for unknown session key", () => {
|
|
420
|
+
const queue = new ConversationQueue();
|
|
421
|
+
expect(queue.clear("nonexistent")).toBe(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("runs queued work sequentially (FIFO)", async () => {
|
|
425
|
+
const queue = new ConversationQueue();
|
|
426
|
+
const blocker = deferred();
|
|
427
|
+
const events: string[] = [];
|
|
428
|
+
|
|
429
|
+
const first = queue.runOrQueue("s1", {
|
|
430
|
+
run: async () => {
|
|
431
|
+
events.push("first:start");
|
|
432
|
+
await blocker.promise;
|
|
433
|
+
events.push("first:end");
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const second = queue.runOrQueue("s1", {
|
|
438
|
+
onQueued: async () => {
|
|
439
|
+
events.push("second:queued");
|
|
440
|
+
},
|
|
441
|
+
run: async () => {
|
|
442
|
+
events.push("second:run");
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(await second).toBe("queued");
|
|
447
|
+
expect(queue.queuedCount("s1")).toBe(1);
|
|
448
|
+
|
|
449
|
+
blocker.resolve();
|
|
450
|
+
expect(await first).toBe("started");
|
|
451
|
+
|
|
452
|
+
await Bun.sleep(0);
|
|
453
|
+
expect(events).toEqual(["first:start", "second:queued", "first:end", "second:run"]);
|
|
454
|
+
expect(queue.isProcessing("s1")).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("drops queued work when cleared", async () => {
|
|
458
|
+
const queue = new ConversationQueue();
|
|
459
|
+
const blocker = deferred();
|
|
460
|
+
const events: string[] = [];
|
|
461
|
+
|
|
462
|
+
const first = queue.runOrQueue("s1", {
|
|
463
|
+
run: async () => {
|
|
464
|
+
events.push("first:start");
|
|
465
|
+
await blocker.promise;
|
|
466
|
+
events.push("first:end");
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await queue.runOrQueue("s1", {
|
|
471
|
+
run: async () => {
|
|
472
|
+
events.push("second:run");
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(queue.clear("s1")).toBe(1);
|
|
477
|
+
|
|
478
|
+
blocker.resolve();
|
|
479
|
+
await first;
|
|
480
|
+
await Bun.sleep(0);
|
|
481
|
+
|
|
482
|
+
expect(events).toEqual(["first:start", "first:end"]);
|
|
483
|
+
expect(queue.queuedCount("s1")).toBe(0);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// ── SlackChannel class ──────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
describe("SlackChannel", () => {
|
|
490
|
+
it("has correct name", () => {
|
|
491
|
+
const channel = new SlackChannel();
|
|
492
|
+
expect(channel.name).toBe("slack");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("handleRequest returns null (Socket Mode, no HTTP inbound)", () => {
|
|
496
|
+
const channel = new SlackChannel();
|
|
497
|
+
const req = new Request("http://localhost/test", { method: "POST" });
|
|
498
|
+
return channel.handleRequest(req).then((result) => {
|
|
499
|
+
expect(result).toBeNull();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("health endpoint returns correct service name", async () => {
|
|
504
|
+
const channel = new SlackChannel();
|
|
505
|
+
Object.defineProperty(channel, "secret", { value: "test-secret" });
|
|
506
|
+
const handler = channel.createFetch();
|
|
507
|
+
const resp = await handler(new Request("http://localhost/health"));
|
|
508
|
+
expect(resp.status).toBe(200);
|
|
509
|
+
const body = (await resp.json()) as Record<string, unknown>;
|
|
510
|
+
expect(body.ok).toBe(true);
|
|
511
|
+
expect(body.service).toBe("channel-slack");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("health endpoint responds to any host header", async () => {
|
|
515
|
+
const channel = new SlackChannel();
|
|
516
|
+
Object.defineProperty(channel, "secret", { value: "test-secret" });
|
|
517
|
+
const handler = channel.createFetch();
|
|
518
|
+
const resp = await handler(new Request("http://127.0.0.1:8185/health"));
|
|
519
|
+
expect(resp.status).toBe(200);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("returns 404 for non-POST requests", async () => {
|
|
523
|
+
const channel = new SlackChannel();
|
|
524
|
+
Object.defineProperty(channel, "secret", { value: "test-secret" });
|
|
525
|
+
const handler = channel.createFetch();
|
|
526
|
+
const resp = await handler(new Request("http://localhost/message", { method: "GET" }));
|
|
527
|
+
expect(resp.status).toBe(404);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("returns 404 for unknown paths", async () => {
|
|
531
|
+
const channel = new SlackChannel();
|
|
532
|
+
Object.defineProperty(channel, "secret", { value: "test-secret" });
|
|
533
|
+
const handler = channel.createFetch();
|
|
534
|
+
const resp = await handler(new Request("http://localhost/nope"));
|
|
535
|
+
expect(resp.status).toBe(404);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("botToken reads from SLACK_BOT_TOKEN_FILE", () => {
|
|
539
|
+
withSecretFile("SLACK_BOT_TOKEN_FILE", "slack-bot-token", () => {
|
|
540
|
+
const channel = new SlackChannel();
|
|
541
|
+
expect(channel.botToken).toBe("slack-bot-token");
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("appToken reads from SLACK_APP_TOKEN_FILE", () => {
|
|
546
|
+
withSecretFile("SLACK_APP_TOKEN_FILE", "slack-app-token", () => {
|
|
547
|
+
const channel = new SlackChannel();
|
|
548
|
+
expect(channel.appToken).toBe("slack-app-token");
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("inherits port from env or defaults to 8080", () => {
|
|
553
|
+
const channel = new SlackChannel();
|
|
554
|
+
expect(typeof channel.port).toBe("number");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("inherits guardianUrl from env or defaults", () => {
|
|
558
|
+
const channel = new SlackChannel();
|
|
559
|
+
expect(typeof channel.guardianUrl).toBe("string");
|
|
560
|
+
expect(channel.guardianUrl).toContain("guardian");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("secret resolves from PRINCIPAL_SECRET_FILE", () => {
|
|
564
|
+
withSecretFile("PRINCIPAL_SECRET_FILE", "channel-secret", () => {
|
|
565
|
+
const channel = new SlackChannel();
|
|
566
|
+
expect(channel.secret).toBe("channel-secret");
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ── Message handling behavior ───────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
describe("DM message handling", () => {
|
|
574
|
+
it("ignores bot messages (bot_id present)", async () => {
|
|
575
|
+
const channel = new SlackChannel();
|
|
576
|
+
const forward = mock(async () => new Response(JSON.stringify({ answer: "hi" }), { status: 200 }));
|
|
577
|
+
Object.assign(channel, { forward });
|
|
578
|
+
|
|
579
|
+
const say = createMockSay();
|
|
580
|
+
const client = createMockClient();
|
|
581
|
+
|
|
582
|
+
await (channel as unknown as {
|
|
583
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
584
|
+
}).onMessage(
|
|
585
|
+
{ user: "U123", channel: "D123", text: "hello", ts: "1.1", channel_type: "im", bot_id: "B123" },
|
|
586
|
+
say,
|
|
587
|
+
client,
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
expect(forward).not.toHaveBeenCalled();
|
|
591
|
+
expect(say).not.toHaveBeenCalled();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("ignores messages with subtype", async () => {
|
|
595
|
+
const channel = new SlackChannel();
|
|
596
|
+
const forward = mock(async () => new Response("{}", { status: 200 }));
|
|
597
|
+
Object.assign(channel, { forward });
|
|
598
|
+
|
|
599
|
+
const say = createMockSay();
|
|
600
|
+
const client = createMockClient();
|
|
601
|
+
|
|
602
|
+
await (channel as unknown as {
|
|
603
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
604
|
+
}).onMessage(
|
|
605
|
+
{ user: "U123", channel: "D123", text: "hello", ts: "1.1", channel_type: "im", subtype: "message_changed" },
|
|
606
|
+
say,
|
|
607
|
+
client,
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
expect(forward).not.toHaveBeenCalled();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("ignores empty text", async () => {
|
|
614
|
+
const channel = new SlackChannel();
|
|
615
|
+
const forward = mock(async () => new Response("{}", { status: 200 }));
|
|
616
|
+
Object.assign(channel, { forward });
|
|
617
|
+
|
|
618
|
+
const say = createMockSay();
|
|
619
|
+
const client = createMockClient();
|
|
620
|
+
|
|
621
|
+
await (channel as unknown as {
|
|
622
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
623
|
+
}).onMessage(
|
|
624
|
+
{ user: "U123", channel: "D123", text: " ", ts: "1.1", channel_type: "im" },
|
|
625
|
+
say,
|
|
626
|
+
client,
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
expect(forward).not.toHaveBeenCalled();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("ignores own messages (bot self-reply guard)", async () => {
|
|
633
|
+
const channel = new SlackChannel();
|
|
634
|
+
Object.assign(channel, { botUserId: "BSELF" });
|
|
635
|
+
const forward = mock(async () => new Response("{}", { status: 200 }));
|
|
636
|
+
Object.assign(channel, { forward });
|
|
637
|
+
|
|
638
|
+
const say = createMockSay();
|
|
639
|
+
const client = createMockClient();
|
|
640
|
+
|
|
641
|
+
await (channel as unknown as {
|
|
642
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
643
|
+
}).onMessage(
|
|
644
|
+
{ user: "BSELF", channel: "D123", text: "echo", ts: "1.1", channel_type: "im" },
|
|
645
|
+
say,
|
|
646
|
+
client,
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
expect(forward).not.toHaveBeenCalled();
|
|
650
|
+
expect(say).not.toHaveBeenCalled();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("ignores non-DM messages (channel_type !== 'im')", async () => {
|
|
654
|
+
const channel = new SlackChannel();
|
|
655
|
+
const forward = mock(async () => new Response("{}", { status: 200 }));
|
|
656
|
+
Object.assign(channel, { forward });
|
|
657
|
+
|
|
658
|
+
const say = createMockSay();
|
|
659
|
+
const client = createMockClient();
|
|
660
|
+
|
|
661
|
+
await (channel as unknown as {
|
|
662
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
663
|
+
}).onMessage(
|
|
664
|
+
{ user: "U123", channel: "C123", text: "hello", ts: "1.1", channel_type: "channel" },
|
|
665
|
+
say,
|
|
666
|
+
client,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
expect(forward).not.toHaveBeenCalled();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("forwards DM to guardian and replies in thread", async () => {
|
|
673
|
+
const channel = new SlackChannel();
|
|
674
|
+
const forward = mock(async () =>
|
|
675
|
+
new Response(JSON.stringify({ answer: "Hi there!" }), { status: 200 }),
|
|
676
|
+
);
|
|
677
|
+
Object.assign(channel, { forward });
|
|
678
|
+
|
|
679
|
+
const say = createMockSay();
|
|
680
|
+
const client = createMockClient();
|
|
681
|
+
|
|
682
|
+
await (channel as unknown as {
|
|
683
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
684
|
+
}).onMessage(
|
|
685
|
+
{ user: "U123", channel: "D123", text: "hello bot", ts: "1.1", channel_type: "im", team: "T1" },
|
|
686
|
+
say,
|
|
687
|
+
client,
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
691
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({
|
|
692
|
+
userId: "slack:U123",
|
|
693
|
+
text: "hello bot",
|
|
694
|
+
});
|
|
695
|
+
// Thinking message posted, then updated with response
|
|
696
|
+
expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({
|
|
697
|
+
channel: "D123",
|
|
698
|
+
text: ":hourglass: Processing your request...",
|
|
699
|
+
thread_ts: "1.1",
|
|
700
|
+
});
|
|
701
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
702
|
+
channel: "D123",
|
|
703
|
+
ts: "1234567890.123456",
|
|
704
|
+
text: "Hi there!",
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("uses thread_ts for session key when in a DM thread", async () => {
|
|
709
|
+
const channel = new SlackChannel();
|
|
710
|
+
const forward = mock(async () =>
|
|
711
|
+
new Response(JSON.stringify({ answer: "reply" }), { status: 200 }),
|
|
712
|
+
);
|
|
713
|
+
Object.assign(channel, { forward });
|
|
714
|
+
|
|
715
|
+
const say = createMockSay();
|
|
716
|
+
const client = createMockClient();
|
|
717
|
+
|
|
718
|
+
await (channel as unknown as {
|
|
719
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
720
|
+
}).onMessage(
|
|
721
|
+
{
|
|
722
|
+
user: "U123",
|
|
723
|
+
channel: "D123",
|
|
724
|
+
text: "follow up",
|
|
725
|
+
ts: "2.2",
|
|
726
|
+
thread_ts: "1.1",
|
|
727
|
+
channel_type: "im",
|
|
728
|
+
team: "T1",
|
|
729
|
+
},
|
|
730
|
+
say,
|
|
731
|
+
client,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
expect(forward.mock.calls[0]?.[0].metadata).toMatchObject({
|
|
735
|
+
sessionKey: "slack:thread:D123:1.1",
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("denies blocked user in DM", async () => {
|
|
740
|
+
const channel = new SlackChannel();
|
|
741
|
+
Object.assign(channel, {
|
|
742
|
+
permissions: {
|
|
743
|
+
allowedChannels: new Set<string>(),
|
|
744
|
+
allowedUsers: new Set<string>(),
|
|
745
|
+
blockedUsers: new Set(["U123"]),
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const say = createMockSay();
|
|
750
|
+
const client = createMockClient();
|
|
751
|
+
|
|
752
|
+
await (channel as unknown as {
|
|
753
|
+
onMessage: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
754
|
+
}).onMessage(
|
|
755
|
+
{ user: "U123", channel: "D123", text: "hello", ts: "1.1", channel_type: "im" },
|
|
756
|
+
say,
|
|
757
|
+
client,
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
expect(say).toHaveBeenCalledWith({
|
|
761
|
+
text: "You do not have permission to use this bot.",
|
|
762
|
+
thread_ts: "1.1",
|
|
763
|
+
});
|
|
764
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ── App mention handling ────────────────────────────────────────────────────
|
|
769
|
+
|
|
770
|
+
describe("app mention handling", () => {
|
|
771
|
+
it("responds to app_mention and replies in thread", async () => {
|
|
772
|
+
const channel = new SlackChannel();
|
|
773
|
+
const forward = mock(async () =>
|
|
774
|
+
new Response(JSON.stringify({ answer: "mentioned!" }), { status: 200 }),
|
|
775
|
+
);
|
|
776
|
+
Object.assign(channel, { forward });
|
|
777
|
+
|
|
778
|
+
const say = createMockSay();
|
|
779
|
+
const client = createMockClient();
|
|
780
|
+
|
|
781
|
+
await (channel as unknown as {
|
|
782
|
+
onAppMention: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
783
|
+
}).onAppMention(
|
|
784
|
+
{ user: "U123", channel: "C456", text: "hey bot help me", ts: "1.1", team: "T1" },
|
|
785
|
+
say,
|
|
786
|
+
client,
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
790
|
+
// Thinking message posted, then updated with response
|
|
791
|
+
expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({
|
|
792
|
+
channel: "C456",
|
|
793
|
+
text: ":hourglass: Processing your request...",
|
|
794
|
+
thread_ts: "1.1",
|
|
795
|
+
});
|
|
796
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
797
|
+
channel: "C456",
|
|
798
|
+
ts: "1234567890.123456",
|
|
799
|
+
text: "mentioned!",
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("ignores empty text in app_mention", async () => {
|
|
804
|
+
const channel = new SlackChannel();
|
|
805
|
+
const forward = mock(async () => new Response("{}", { status: 200 }));
|
|
806
|
+
Object.assign(channel, { forward });
|
|
807
|
+
|
|
808
|
+
const say = createMockSay();
|
|
809
|
+
const client = createMockClient();
|
|
810
|
+
|
|
811
|
+
await (channel as unknown as {
|
|
812
|
+
onAppMention: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
813
|
+
}).onAppMention(
|
|
814
|
+
{ user: "U123", channel: "C456", text: "", ts: "1.1" },
|
|
815
|
+
say,
|
|
816
|
+
client,
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
expect(forward).not.toHaveBeenCalled();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("strips bot mention from text", async () => {
|
|
823
|
+
const channel = new SlackChannel();
|
|
824
|
+
Object.assign(channel, { botUserId: "B999" });
|
|
825
|
+
const forward = mock(async () =>
|
|
826
|
+
new Response(JSON.stringify({ answer: "ok" }), { status: 200 }),
|
|
827
|
+
);
|
|
828
|
+
Object.assign(channel, { forward });
|
|
829
|
+
|
|
830
|
+
const say = createMockSay();
|
|
831
|
+
const client = createMockClient();
|
|
832
|
+
|
|
833
|
+
await (channel as unknown as {
|
|
834
|
+
onAppMention: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
835
|
+
}).onAppMention(
|
|
836
|
+
{ user: "U123", channel: "C456", text: "<@B999> help me please", ts: "1.1", team: "T1" },
|
|
837
|
+
say,
|
|
838
|
+
client,
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
expect(forward.mock.calls[0]?.[0].text).toBe("help me please");
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("replies 'Please provide a message' when mention-only (no text after strip)", async () => {
|
|
845
|
+
const channel = new SlackChannel();
|
|
846
|
+
Object.assign(channel, { botUserId: "B999" });
|
|
847
|
+
|
|
848
|
+
const say = createMockSay();
|
|
849
|
+
const client = createMockClient();
|
|
850
|
+
|
|
851
|
+
await (channel as unknown as {
|
|
852
|
+
onAppMention: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
853
|
+
}).onAppMention(
|
|
854
|
+
{ user: "U123", channel: "C456", text: "<@B999>", ts: "1.1", team: "T1" },
|
|
855
|
+
say,
|
|
856
|
+
client,
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
expect(say).toHaveBeenCalledWith({
|
|
860
|
+
text: "Please provide a message.",
|
|
861
|
+
thread_ts: "1.1",
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("uses existing thread_ts for threaded mentions", async () => {
|
|
866
|
+
const channel = new SlackChannel();
|
|
867
|
+
const forward = mock(async () =>
|
|
868
|
+
new Response(JSON.stringify({ answer: "threaded!" }), { status: 200 }),
|
|
869
|
+
);
|
|
870
|
+
Object.assign(channel, { forward });
|
|
871
|
+
|
|
872
|
+
const say = createMockSay();
|
|
873
|
+
const client = createMockClient();
|
|
874
|
+
|
|
875
|
+
await (channel as unknown as {
|
|
876
|
+
onAppMention: (event: Record<string, unknown>, say: MockSay, client: MockClient) => Promise<void>;
|
|
877
|
+
}).onAppMention(
|
|
878
|
+
{ user: "U123", channel: "C456", text: "question", ts: "2.2", thread_ts: "1.1", team: "T1" },
|
|
879
|
+
say,
|
|
880
|
+
client,
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
expect(forward.mock.calls[0]?.[0].metadata).toMatchObject({
|
|
884
|
+
sessionKey: "slack:thread:C456:1.1",
|
|
885
|
+
});
|
|
886
|
+
// Thinking message posted in thread, then updated with response
|
|
887
|
+
expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({
|
|
888
|
+
channel: "C456",
|
|
889
|
+
text: ":hourglass: Processing your request...",
|
|
890
|
+
thread_ts: "1.1",
|
|
891
|
+
});
|
|
892
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
893
|
+
channel: "C456",
|
|
894
|
+
ts: "1234567890.123456",
|
|
895
|
+
text: "threaded!",
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// ── Slash command: /clear ───────────────────────────────────────────────────
|
|
901
|
+
|
|
902
|
+
describe("/clear command", () => {
|
|
903
|
+
it("forwards clearSession request with session metadata", async () => {
|
|
904
|
+
const channel = new SlackChannel();
|
|
905
|
+
const forward = mock(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
906
|
+
Object.assign(channel, { forward });
|
|
907
|
+
|
|
908
|
+
const say = createMockSay();
|
|
909
|
+
|
|
910
|
+
await (channel as unknown as {
|
|
911
|
+
onClearCommand: (cmd: Record<string, string>, say: MockSay) => Promise<void>;
|
|
912
|
+
}).onClearCommand(
|
|
913
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" },
|
|
914
|
+
say,
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
918
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({
|
|
919
|
+
userId: "slack:U123",
|
|
920
|
+
text: "clear session",
|
|
921
|
+
metadata: {
|
|
922
|
+
command: "clear",
|
|
923
|
+
channelId: "C456",
|
|
924
|
+
teamId: "T1",
|
|
925
|
+
username: "tester",
|
|
926
|
+
clearSession: true,
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
expect(forward.mock.calls[0]?.[2]).toBe(DEFAULT_FORWARD_TIMEOUT_MS);
|
|
930
|
+
expect(say).toHaveBeenCalledWith({ text: "Conversation cleared." });
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it("reports dropped queued follow-ups", async () => {
|
|
934
|
+
const channel = new SlackChannel();
|
|
935
|
+
const forward = mock(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
936
|
+
Object.assign(channel, { forward });
|
|
937
|
+
|
|
938
|
+
// Pre-populate the queue
|
|
939
|
+
const queue = new ConversationQueue();
|
|
940
|
+
const blocker = deferred();
|
|
941
|
+
queue.runOrQueue("slack:channel:C456:user:U123", { run: async () => { await blocker.promise; } });
|
|
942
|
+
queue.runOrQueue("slack:channel:C456:user:U123", { run: async () => {} });
|
|
943
|
+
Object.assign(channel, { conversationQueue: queue });
|
|
944
|
+
|
|
945
|
+
const say = createMockSay();
|
|
946
|
+
|
|
947
|
+
await (channel as unknown as {
|
|
948
|
+
onClearCommand: (cmd: Record<string, string>, say: MockSay) => Promise<void>;
|
|
949
|
+
}).onClearCommand(
|
|
950
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" },
|
|
951
|
+
say,
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
expect(say).toHaveBeenCalledWith({ text: "Conversation cleared. Dropped queued follow-ups." });
|
|
955
|
+
blocker.resolve();
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("handles guardian error gracefully", async () => {
|
|
959
|
+
const channel = new SlackChannel();
|
|
960
|
+
const forward = mock(async () => { throw new Error("network failure"); });
|
|
961
|
+
Object.assign(channel, { forward });
|
|
962
|
+
|
|
963
|
+
const say = createMockSay();
|
|
964
|
+
|
|
965
|
+
await (channel as unknown as {
|
|
966
|
+
onClearCommand: (cmd: Record<string, string>, say: MockSay) => Promise<void>;
|
|
967
|
+
}).onClearCommand(
|
|
968
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" },
|
|
969
|
+
say,
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
expect(say).toHaveBeenCalledWith({ text: "Could not clear this conversation right now." });
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("denies blocked user", async () => {
|
|
976
|
+
const channel = new SlackChannel();
|
|
977
|
+
Object.assign(channel, {
|
|
978
|
+
permissions: {
|
|
979
|
+
allowedChannels: new Set<string>(),
|
|
980
|
+
allowedUsers: new Set<string>(),
|
|
981
|
+
blockedUsers: new Set(["U123"]),
|
|
982
|
+
},
|
|
983
|
+
});
|
|
984
|
+
const forward = mock(async () => new Response("{}", { status: 200 }));
|
|
985
|
+
Object.assign(channel, { forward });
|
|
986
|
+
|
|
987
|
+
const say = createMockSay();
|
|
988
|
+
|
|
989
|
+
await (channel as unknown as {
|
|
990
|
+
onClearCommand: (cmd: Record<string, string>, say: MockSay) => Promise<void>;
|
|
991
|
+
}).onClearCommand(
|
|
992
|
+
{ user_id: "U123", user_name: "blocked", team_id: "T1", channel_id: "C456", text: "" },
|
|
993
|
+
say,
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
expect(say).toHaveBeenCalledWith({ text: "You do not have permission to use this bot." });
|
|
997
|
+
expect(forward).not.toHaveBeenCalled();
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("handles non-ok guardian response", async () => {
|
|
1001
|
+
const channel = new SlackChannel();
|
|
1002
|
+
const forward = mock(async () => new Response("{}", { status: 500 }));
|
|
1003
|
+
Object.assign(channel, { forward });
|
|
1004
|
+
|
|
1005
|
+
const say = createMockSay();
|
|
1006
|
+
|
|
1007
|
+
await (channel as unknown as {
|
|
1008
|
+
onClearCommand: (cmd: Record<string, string>, say: MockSay) => Promise<void>;
|
|
1009
|
+
}).onClearCommand(
|
|
1010
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" },
|
|
1011
|
+
say,
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
expect(say).toHaveBeenCalledWith({ text: "Could not clear this conversation right now." });
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// ── Slash command: /ask ─────────────────────────────────────────────────────
|
|
1019
|
+
|
|
1020
|
+
describe("/ask command", () => {
|
|
1021
|
+
it("replies with usage when text is empty", async () => {
|
|
1022
|
+
const channel = new SlackChannel();
|
|
1023
|
+
const say = createMockSay();
|
|
1024
|
+
const client = createMockClient();
|
|
1025
|
+
|
|
1026
|
+
await (channel as unknown as {
|
|
1027
|
+
onAskCommand: (cmd: Record<string, string>, say: MockSay, client: MockClient) => Promise<void>;
|
|
1028
|
+
}).onAskCommand(
|
|
1029
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" },
|
|
1030
|
+
say,
|
|
1031
|
+
client,
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
expect(say).toHaveBeenCalledWith({ text: "Usage: `/ask <message>`" });
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it("posts thinking message, forwards to guardian, updates with answer", async () => {
|
|
1038
|
+
const channel = new SlackChannel();
|
|
1039
|
+
const forward = mock(async () =>
|
|
1040
|
+
new Response(JSON.stringify({ answer: "Here is my answer" }), { status: 200 }),
|
|
1041
|
+
);
|
|
1042
|
+
Object.assign(channel, { forward });
|
|
1043
|
+
|
|
1044
|
+
const say = createMockSay();
|
|
1045
|
+
const client = createMockClient();
|
|
1046
|
+
|
|
1047
|
+
await (channel as unknown as {
|
|
1048
|
+
onAskCommand: (cmd: Record<string, string>, say: MockSay, client: MockClient) => Promise<void>;
|
|
1049
|
+
}).onAskCommand(
|
|
1050
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "what is AI?" },
|
|
1051
|
+
say,
|
|
1052
|
+
client,
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
// Should post thinking message
|
|
1056
|
+
expect(client.chat.postMessage).toHaveBeenCalledWith({
|
|
1057
|
+
channel: "C456",
|
|
1058
|
+
text: `:hourglass: Processing your request...`,
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Should update thinking message with answer
|
|
1062
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
1063
|
+
channel: "C456",
|
|
1064
|
+
ts: "1234567890.123456",
|
|
1065
|
+
text: "Here is my answer",
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// Should have forwarded to guardian
|
|
1069
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
1070
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({
|
|
1071
|
+
userId: "slack:U123",
|
|
1072
|
+
text: "what is AI?",
|
|
1073
|
+
metadata: {
|
|
1074
|
+
command: "ask",
|
|
1075
|
+
teamId: "T1",
|
|
1076
|
+
username: "tester",
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
expect(forward.mock.calls[0]?.[2]).toBe(DEFAULT_FORWARD_TIMEOUT_MS);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("uses configured SLACK_FORWARD_TIMEOUT_MS for guardian forwarding", async () => {
|
|
1083
|
+
Bun.env.SLACK_FORWARD_TIMEOUT_MS = "4321";
|
|
1084
|
+
const channel = new SlackChannel();
|
|
1085
|
+
const forward = mock(async () =>
|
|
1086
|
+
new Response(JSON.stringify({ answer: "Here is my answer" }), { status: 200 }),
|
|
1087
|
+
);
|
|
1088
|
+
Object.assign(channel, { forward });
|
|
1089
|
+
|
|
1090
|
+
const say = createMockSay();
|
|
1091
|
+
const client = createMockClient();
|
|
1092
|
+
|
|
1093
|
+
await (channel as unknown as {
|
|
1094
|
+
onAskCommand: (cmd: Record<string, string>, say: MockSay, client: MockClient) => Promise<void>;
|
|
1095
|
+
}).onAskCommand(
|
|
1096
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "what is AI?" },
|
|
1097
|
+
say,
|
|
1098
|
+
client,
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
expect(forward.mock.calls[0]?.[2]).toBe(4321);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("updates thinking message with error on guardian failure", async () => {
|
|
1105
|
+
const channel = new SlackChannel();
|
|
1106
|
+
const forward = mock(async () => new Response("{}", { status: 500 }));
|
|
1107
|
+
Object.assign(channel, { forward });
|
|
1108
|
+
|
|
1109
|
+
const say = createMockSay();
|
|
1110
|
+
const client = createMockClient();
|
|
1111
|
+
|
|
1112
|
+
await (channel as unknown as {
|
|
1113
|
+
onAskCommand: (cmd: Record<string, string>, say: MockSay, client: MockClient) => Promise<void>;
|
|
1114
|
+
}).onAskCommand(
|
|
1115
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "test" },
|
|
1116
|
+
say,
|
|
1117
|
+
client,
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
1121
|
+
channel: "C456",
|
|
1122
|
+
ts: "1234567890.123456",
|
|
1123
|
+
text: "Error: Guardian returned status 500",
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it("denies blocked user", async () => {
|
|
1128
|
+
const channel = new SlackChannel();
|
|
1129
|
+
Object.assign(channel, {
|
|
1130
|
+
permissions: {
|
|
1131
|
+
allowedChannels: new Set<string>(),
|
|
1132
|
+
allowedUsers: new Set<string>(),
|
|
1133
|
+
blockedUsers: new Set(["U123"]),
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const say = createMockSay();
|
|
1138
|
+
const client = createMockClient();
|
|
1139
|
+
|
|
1140
|
+
await (channel as unknown as {
|
|
1141
|
+
onAskCommand: (cmd: Record<string, string>, say: MockSay, client: MockClient) => Promise<void>;
|
|
1142
|
+
}).onAskCommand(
|
|
1143
|
+
{ user_id: "U123", user_name: "blocked", team_id: "T1", channel_id: "C456", text: "hello" },
|
|
1144
|
+
say,
|
|
1145
|
+
client,
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
expect(say).toHaveBeenCalledWith({ text: "You do not have permission to use this bot." });
|
|
1149
|
+
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// ── Slash command: /help ────────────────────────────────────────────────────
|
|
1154
|
+
|
|
1155
|
+
describe("/help command", () => {
|
|
1156
|
+
it("denies blocked user", async () => {
|
|
1157
|
+
const channel = new SlackChannel();
|
|
1158
|
+
Object.assign(channel, {
|
|
1159
|
+
permissions: {
|
|
1160
|
+
allowedChannels: new Set<string>(),
|
|
1161
|
+
allowedUsers: new Set<string>(),
|
|
1162
|
+
blockedUsers: new Set(["U123"]),
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const say = createMockSay();
|
|
1167
|
+
|
|
1168
|
+
await (channel as unknown as {
|
|
1169
|
+
onHelpCommand: (cmd: Record<string, string>, say: MockSay) => Promise<void>;
|
|
1170
|
+
}).onHelpCommand(
|
|
1171
|
+
{ user_id: "U123", user_name: "blocked", team_id: "T1", channel_id: "C456", text: "" },
|
|
1172
|
+
say,
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
expect(say).toHaveBeenCalledWith({ text: "You do not have permission to use this bot." });
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("lists available commands", async () => {
|
|
1179
|
+
const channel = new SlackChannel();
|
|
1180
|
+
const say = createMockSay();
|
|
1181
|
+
|
|
1182
|
+
await (channel as unknown as {
|
|
1183
|
+
onHelpCommand: (cmd: Record<string, string>, say: MockSay) => Promise<void>;
|
|
1184
|
+
}).onHelpCommand(
|
|
1185
|
+
{ user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" },
|
|
1186
|
+
say,
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
expect(say).toHaveBeenCalledTimes(1);
|
|
1190
|
+
const text = say.mock.calls[0]?.[0]?.text as string;
|
|
1191
|
+
expect(text).toContain("/ask");
|
|
1192
|
+
expect(text).toContain("/clear");
|
|
1193
|
+
expect(text).toContain("/help");
|
|
1194
|
+
expect(text).toContain("mention me");
|
|
1195
|
+
expect(text).toContain("DM");
|
|
1196
|
+
});
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// ── Shortcuts, modal submissions, and App Home ─────────────────────────────
|
|
1200
|
+
|
|
1201
|
+
describe("shortcut and modal handlers", () => {
|
|
1202
|
+
it("opens Ask OpenPalm modal from global shortcut", async () => {
|
|
1203
|
+
const channel = new SlackChannel();
|
|
1204
|
+
const client = createMockClient();
|
|
1205
|
+
|
|
1206
|
+
await (channel as unknown as {
|
|
1207
|
+
onGlobalShortcut: (shortcut: Record<string, unknown>, client: MockClient) => Promise<void>;
|
|
1208
|
+
}).onGlobalShortcut(
|
|
1209
|
+
{
|
|
1210
|
+
trigger_id: "trigger-1",
|
|
1211
|
+
user: { id: "U123" },
|
|
1212
|
+
team: { id: "T1" },
|
|
1213
|
+
},
|
|
1214
|
+
client,
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
expect(client.views.open).toHaveBeenCalledTimes(1);
|
|
1218
|
+
const args = client.views.open.mock.calls[0]?.[0];
|
|
1219
|
+
expect(args.trigger_id).toBe("trigger-1");
|
|
1220
|
+
expect(args.view.callback_id).toBe("ask_openpalm_modal");
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it("opens prefilled modal from message shortcut", async () => {
|
|
1224
|
+
const channel = new SlackChannel();
|
|
1225
|
+
const client = createMockClient();
|
|
1226
|
+
|
|
1227
|
+
await (channel as unknown as {
|
|
1228
|
+
onMessageShortcut: (shortcut: Record<string, unknown>, client: MockClient) => Promise<void>;
|
|
1229
|
+
}).onMessageShortcut(
|
|
1230
|
+
{
|
|
1231
|
+
trigger_id: "trigger-2",
|
|
1232
|
+
user: { id: "U123" },
|
|
1233
|
+
team: { id: "T1" },
|
|
1234
|
+
channel: { id: "C456" },
|
|
1235
|
+
message: { ts: "1710000000.000001", text: "Please summarize this" },
|
|
1236
|
+
},
|
|
1237
|
+
client,
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
const args = client.views.open.mock.calls[0]?.[0];
|
|
1241
|
+
expect(args.view.blocks[0].element.initial_value).toContain("Please summarize this");
|
|
1242
|
+
expect(args.view.private_metadata).toContain("message-shortcut");
|
|
1243
|
+
expect(args.view.private_metadata).toContain("C456");
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it("handles modal submission from message shortcut using thread session", async () => {
|
|
1247
|
+
const channel = new SlackChannel();
|
|
1248
|
+
const forward = mock(async () =>
|
|
1249
|
+
new Response(JSON.stringify({ answer: "modal answer" }), { status: 200 }),
|
|
1250
|
+
);
|
|
1251
|
+
Object.assign(channel, { forward });
|
|
1252
|
+
const client = createMockClient();
|
|
1253
|
+
|
|
1254
|
+
await (channel as unknown as {
|
|
1255
|
+
onAskModalSubmission: (
|
|
1256
|
+
body: Record<string, unknown>,
|
|
1257
|
+
view: Record<string, unknown>,
|
|
1258
|
+
client: MockClient,
|
|
1259
|
+
) => Promise<void>;
|
|
1260
|
+
}).onAskModalSubmission(
|
|
1261
|
+
{
|
|
1262
|
+
user: { id: "U123", username: "tester" },
|
|
1263
|
+
team: { id: "T1" },
|
|
1264
|
+
},
|
|
1265
|
+
{
|
|
1266
|
+
private_metadata: JSON.stringify({
|
|
1267
|
+
source: "message-shortcut",
|
|
1268
|
+
channelId: "C456",
|
|
1269
|
+
threadTs: "1710000000.000001",
|
|
1270
|
+
teamId: "T1",
|
|
1271
|
+
}),
|
|
1272
|
+
state: {
|
|
1273
|
+
values: {
|
|
1274
|
+
ask_openpalm_prompt_block: {
|
|
1275
|
+
ask_openpalm_prompt_action: {
|
|
1276
|
+
value: "use this context",
|
|
1277
|
+
},
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
client,
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
1286
|
+
expect(forward.mock.calls[0]?.[0]).toMatchObject({
|
|
1287
|
+
userId: "slack:U123",
|
|
1288
|
+
text: "use this context",
|
|
1289
|
+
});
|
|
1290
|
+
expect(forward.mock.calls[0]?.[0].metadata.sessionKey).toBe("slack:thread:C456:1710000000.000001");
|
|
1291
|
+
expect(client.chat.postMessage.mock.calls[0]?.[0]).toMatchObject({
|
|
1292
|
+
channel: "C456",
|
|
1293
|
+
thread_ts: "1710000000.000001",
|
|
1294
|
+
});
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("handles modal submission from global shortcut via DM channel", async () => {
|
|
1298
|
+
const channel = new SlackChannel();
|
|
1299
|
+
const forward = mock(async () =>
|
|
1300
|
+
new Response(JSON.stringify({ answer: "dm modal answer" }), { status: 200 }),
|
|
1301
|
+
);
|
|
1302
|
+
Object.assign(channel, { forward });
|
|
1303
|
+
const client = createMockClient();
|
|
1304
|
+
|
|
1305
|
+
await (channel as unknown as {
|
|
1306
|
+
onAskModalSubmission: (
|
|
1307
|
+
body: Record<string, unknown>,
|
|
1308
|
+
view: Record<string, unknown>,
|
|
1309
|
+
client: MockClient,
|
|
1310
|
+
) => Promise<void>;
|
|
1311
|
+
}).onAskModalSubmission(
|
|
1312
|
+
{
|
|
1313
|
+
user: { id: "U999", username: "tester" },
|
|
1314
|
+
team: { id: "T1" },
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
private_metadata: JSON.stringify({ source: "global-shortcut", teamId: "T1" }),
|
|
1318
|
+
state: {
|
|
1319
|
+
values: {
|
|
1320
|
+
ask_openpalm_prompt_block: {
|
|
1321
|
+
ask_openpalm_prompt_action: {
|
|
1322
|
+
value: "question from modal",
|
|
1323
|
+
},
|
|
1324
|
+
},
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
},
|
|
1328
|
+
client,
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
expect(client.conversations.open).toHaveBeenCalledWith({ users: "U999" });
|
|
1332
|
+
expect(forward).toHaveBeenCalledTimes(1);
|
|
1333
|
+
expect(forward.mock.calls[0]?.[0].metadata.sessionKey).toBe("slack:dm:U999");
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
describe("app home", () => {
|
|
1338
|
+
it("publishes Home tab content on app_home_opened", async () => {
|
|
1339
|
+
const channel = new SlackChannel();
|
|
1340
|
+
const client = createMockClient();
|
|
1341
|
+
|
|
1342
|
+
await (channel as unknown as {
|
|
1343
|
+
onAppHomeOpened: (event: Record<string, unknown>, client: MockClient) => Promise<void>;
|
|
1344
|
+
}).onAppHomeOpened(
|
|
1345
|
+
{ user: "U123" },
|
|
1346
|
+
client,
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
expect(client.views.publish).toHaveBeenCalledTimes(1);
|
|
1350
|
+
const payload = client.views.publish.mock.calls[0]?.[0];
|
|
1351
|
+
expect(payload.user_id).toBe("U123");
|
|
1352
|
+
expect(payload.view.type).toBe("home");
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// ── Conversation runner ─────────────────────────────────────────────────────
|
|
1357
|
+
|
|
1358
|
+
describe("runConversation", () => {
|
|
1359
|
+
it("posts thinking message and updates it with response", async () => {
|
|
1360
|
+
const channel = new SlackChannel();
|
|
1361
|
+
const forward = mock(async () =>
|
|
1362
|
+
new Response(JSON.stringify({ answer: "done" }), { status: 200 }),
|
|
1363
|
+
);
|
|
1364
|
+
Object.assign(channel, { forward });
|
|
1365
|
+
|
|
1366
|
+
const client = createMockClient();
|
|
1367
|
+
|
|
1368
|
+
await (channel as unknown as {
|
|
1369
|
+
runConversation: (
|
|
1370
|
+
client: MockClient, channel: string, threadTs: string,
|
|
1371
|
+
userInfo: UserInfo, text: string, sessionKey: string,
|
|
1372
|
+
) => Promise<void>;
|
|
1373
|
+
}).runConversation(client, "C123", "1.1", testUser(), "hello", "key1");
|
|
1374
|
+
|
|
1375
|
+
// First call: thinking message
|
|
1376
|
+
expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({
|
|
1377
|
+
channel: "C123",
|
|
1378
|
+
text: ":hourglass: Processing your request...",
|
|
1379
|
+
thread_ts: "1.1",
|
|
1380
|
+
});
|
|
1381
|
+
// Thinking message updated with response
|
|
1382
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
1383
|
+
channel: "C123",
|
|
1384
|
+
ts: "1234567890.123456",
|
|
1385
|
+
text: "done",
|
|
1386
|
+
});
|
|
1387
|
+
expect(forward.mock.calls[0]?.[2]).toBe(DEFAULT_FORWARD_TIMEOUT_MS);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it("updates thinking message with error on failure", async () => {
|
|
1391
|
+
const channel = new SlackChannel();
|
|
1392
|
+
const forward = mock(async () => { throw new Error("timeout"); });
|
|
1393
|
+
Object.assign(channel, { forward });
|
|
1394
|
+
|
|
1395
|
+
const client = createMockClient();
|
|
1396
|
+
|
|
1397
|
+
await (channel as unknown as {
|
|
1398
|
+
runConversation: (
|
|
1399
|
+
client: MockClient, channel: string, threadTs: string,
|
|
1400
|
+
userInfo: UserInfo, text: string, sessionKey: string,
|
|
1401
|
+
) => Promise<void>;
|
|
1402
|
+
}).runConversation(client, "C123", "1.1", testUser(), "hello", "key1");
|
|
1403
|
+
|
|
1404
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
1405
|
+
channel: "C123",
|
|
1406
|
+
ts: "1234567890.123456",
|
|
1407
|
+
text: "Error: timeout",
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it("continues even when thinking message fails to post", async () => {
|
|
1412
|
+
const channel = new SlackChannel();
|
|
1413
|
+
const forward = mock(async () =>
|
|
1414
|
+
new Response(JSON.stringify({ answer: "still works" }), { status: 200 }),
|
|
1415
|
+
);
|
|
1416
|
+
Object.assign(channel, { forward });
|
|
1417
|
+
|
|
1418
|
+
const client = createMockClient();
|
|
1419
|
+
// First postMessage (thinking) fails, second (response) succeeds
|
|
1420
|
+
let callCount = 0;
|
|
1421
|
+
client.chat.postMessage = mock(async (args: Record<string, unknown>) => {
|
|
1422
|
+
callCount++;
|
|
1423
|
+
if (callCount === 1) throw new Error("no permission");
|
|
1424
|
+
return { ts: "1234567890.123456" };
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
await (channel as unknown as {
|
|
1428
|
+
runConversation: (
|
|
1429
|
+
client: MockClient, channel: string, threadTs: string,
|
|
1430
|
+
userInfo: UserInfo, text: string, sessionKey: string,
|
|
1431
|
+
) => Promise<void>;
|
|
1432
|
+
}).runConversation(client, "C123", "1.1", testUser(), "hello", "key1");
|
|
1433
|
+
|
|
1434
|
+
// Should fall back to posting response as new message
|
|
1435
|
+
expect(client.chat.postMessage.mock.calls[1][0]).toMatchObject({
|
|
1436
|
+
channel: "C123",
|
|
1437
|
+
text: "still works",
|
|
1438
|
+
thread_ts: "1.1",
|
|
1439
|
+
});
|
|
1440
|
+
// Should NOT try to update a message that was never posted
|
|
1441
|
+
expect(client.chat.update).not.toHaveBeenCalled();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it("splits long responses into multiple messages", async () => {
|
|
1445
|
+
const channel = new SlackChannel();
|
|
1446
|
+
const longAnswer = "x".repeat(5000);
|
|
1447
|
+
const forward = mock(async () =>
|
|
1448
|
+
new Response(JSON.stringify({ answer: longAnswer }), { status: 200 }),
|
|
1449
|
+
);
|
|
1450
|
+
Object.assign(channel, { forward });
|
|
1451
|
+
|
|
1452
|
+
const client = createMockClient();
|
|
1453
|
+
|
|
1454
|
+
await (channel as unknown as {
|
|
1455
|
+
runConversation: (
|
|
1456
|
+
client: MockClient, channel: string, threadTs: string,
|
|
1457
|
+
userInfo: UserInfo, text: string, sessionKey: string,
|
|
1458
|
+
) => Promise<void>;
|
|
1459
|
+
}).runConversation(client, "C123", "1.1", testUser(), "hello", "key1");
|
|
1460
|
+
|
|
1461
|
+
// First call is thinking message, then update replaces it with first chunk,
|
|
1462
|
+
// then additional chunks posted as new messages
|
|
1463
|
+
const postCalls = client.chat.postMessage.mock.calls;
|
|
1464
|
+
// At least thinking message + follow-up chunks
|
|
1465
|
+
expect(postCalls.length).toBeGreaterThan(1);
|
|
1466
|
+
// Follow-up chunks should be in the thread
|
|
1467
|
+
for (let i = 1; i < postCalls.length; i++) {
|
|
1468
|
+
expect(postCalls[i][0].thread_ts).toBe("1.1");
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
it("returns 'No response received.' when guardian returns no answer", async () => {
|
|
1473
|
+
const channel = new SlackChannel();
|
|
1474
|
+
const forward = mock(async () =>
|
|
1475
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
1476
|
+
);
|
|
1477
|
+
Object.assign(channel, { forward });
|
|
1478
|
+
|
|
1479
|
+
const client = createMockClient();
|
|
1480
|
+
|
|
1481
|
+
await (channel as unknown as {
|
|
1482
|
+
runConversation: (
|
|
1483
|
+
client: MockClient, channel: string, threadTs: string,
|
|
1484
|
+
userInfo: UserInfo, text: string, sessionKey: string,
|
|
1485
|
+
) => Promise<void>;
|
|
1486
|
+
}).runConversation(client, "C123", "1.1", testUser(), "hello", "key1");
|
|
1487
|
+
|
|
1488
|
+
expect(client.chat.update).toHaveBeenCalledWith({
|
|
1489
|
+
channel: "C123",
|
|
1490
|
+
ts: "1234567890.123456",
|
|
1491
|
+
text: "No response received.",
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
// ── Utility: stripMention ───────────────────────────────────────────────────
|
|
1498
|
+
|
|
1499
|
+
describe("stripMention", () => {
|
|
1500
|
+
it("strips bot mention from text", () => {
|
|
1501
|
+
const channel = new SlackChannel();
|
|
1502
|
+
Object.assign(channel, { botUserId: "B999" });
|
|
1503
|
+
|
|
1504
|
+
const result = (channel as unknown as {
|
|
1505
|
+
stripMention: (text: string) => string;
|
|
1506
|
+
}).stripMention("<@B999> help me");
|
|
1507
|
+
|
|
1508
|
+
expect(result).toBe("help me");
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
it("strips multiple mentions", () => {
|
|
1512
|
+
const channel = new SlackChannel();
|
|
1513
|
+
Object.assign(channel, { botUserId: "B999" });
|
|
1514
|
+
|
|
1515
|
+
const result = (channel as unknown as {
|
|
1516
|
+
stripMention: (text: string) => string;
|
|
1517
|
+
}).stripMention("<@B999> do this <@B999>");
|
|
1518
|
+
|
|
1519
|
+
expect(result).toBe("do this");
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it("returns original text when no botUserId", () => {
|
|
1523
|
+
const channel = new SlackChannel();
|
|
1524
|
+
|
|
1525
|
+
const result = (channel as unknown as {
|
|
1526
|
+
stripMention: (text: string) => string;
|
|
1527
|
+
}).stripMention("<@B999> help");
|
|
1528
|
+
|
|
1529
|
+
expect(result).toBe("<@B999> help");
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it("returns original text when no mention present", () => {
|
|
1533
|
+
const channel = new SlackChannel();
|
|
1534
|
+
Object.assign(channel, { botUserId: "B999" });
|
|
1535
|
+
|
|
1536
|
+
const result = (channel as unknown as {
|
|
1537
|
+
stripMention: (text: string) => string;
|
|
1538
|
+
}).stripMention("just a regular message");
|
|
1539
|
+
|
|
1540
|
+
expect(result).toBe("just a regular message");
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// ── Utility: extractUserInfo ────────────────────────────────────────────────
|
|
1545
|
+
|
|
1546
|
+
describe("extractUserInfo", () => {
|
|
1547
|
+
it("extracts user info from message event", async () => {
|
|
1548
|
+
const channel = new SlackChannel();
|
|
1549
|
+
const client = createMockClient();
|
|
1550
|
+
|
|
1551
|
+
const result = await (channel as unknown as {
|
|
1552
|
+
extractUserInfo: (event: Record<string, unknown>, client: MockClient) => Promise<UserInfo>;
|
|
1553
|
+
}).extractUserInfo({
|
|
1554
|
+
user: "U123",
|
|
1555
|
+
channel: "C456",
|
|
1556
|
+
team: "T789",
|
|
1557
|
+
}, client);
|
|
1558
|
+
|
|
1559
|
+
expect(result).toEqual({
|
|
1560
|
+
userId: "U123",
|
|
1561
|
+
teamId: "T789",
|
|
1562
|
+
channelId: "C456",
|
|
1563
|
+
username: "U123",
|
|
1564
|
+
});
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
it("handles missing team field", async () => {
|
|
1568
|
+
const channel = new SlackChannel();
|
|
1569
|
+
const client = createMockClient();
|
|
1570
|
+
|
|
1571
|
+
const result = await (channel as unknown as {
|
|
1572
|
+
extractUserInfo: (event: Record<string, unknown>, client: MockClient) => Promise<UserInfo>;
|
|
1573
|
+
}).extractUserInfo({
|
|
1574
|
+
user: "U123",
|
|
1575
|
+
channel: "C456",
|
|
1576
|
+
}, client);
|
|
1577
|
+
|
|
1578
|
+
expect(result.teamId).toBe("");
|
|
1579
|
+
});
|
|
1580
|
+
});
|