@rokrokss/claude-slack-channel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +27 -0
- package/README.md +270 -0
- package/bun.lock +266 -0
- package/lib/audit.ts +24 -0
- package/lib/event.ts +30 -0
- package/lib/formatting.ts +73 -0
- package/lib/gate.ts +47 -0
- package/lib/index.ts +7 -0
- package/lib/permalink.ts +8 -0
- package/lib/resilience.ts +45 -0
- package/lib/security.ts +9 -0
- package/package.json +27 -0
- package/server.test.ts +722 -0
- package/server.ts +412 -0
- package/tools.ts +171 -0
- package/tsconfig.json +17 -0
package/server.test.ts
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
gate,
|
|
4
|
+
assertOutboundAllowed,
|
|
5
|
+
defaultAccess,
|
|
6
|
+
fixSlackMrkdwn,
|
|
7
|
+
extractMessageText,
|
|
8
|
+
formatAuditLine,
|
|
9
|
+
auditLog,
|
|
10
|
+
buildPermalink,
|
|
11
|
+
isDm,
|
|
12
|
+
resolveThreadTs,
|
|
13
|
+
parseSlackTimestamp,
|
|
14
|
+
isStaleEvent,
|
|
15
|
+
isEmptyMessage,
|
|
16
|
+
EventDeduplicator,
|
|
17
|
+
clientSupportsChannels,
|
|
18
|
+
type Access,
|
|
19
|
+
type AuditEntry,
|
|
20
|
+
type GateOptions,
|
|
21
|
+
} from './lib/index.ts'
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function makeAccess(overrides: Partial<Access> = {}): Access {
|
|
28
|
+
return { ...defaultAccess(), ...overrides }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeOpts(overrides: Partial<GateOptions> = {}): GateOptions {
|
|
32
|
+
return {
|
|
33
|
+
access: makeAccess(),
|
|
34
|
+
botUserId: 'U_BOT',
|
|
35
|
+
...overrides,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// gate()
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe('gate', () => {
|
|
44
|
+
test('drops messages from our own bot (user === botUserId)', () => {
|
|
45
|
+
const result = gate(
|
|
46
|
+
{ user: 'U_BOT', channel_type: 'im', channel: 'D1' },
|
|
47
|
+
makeOpts({ botUserId: 'U_BOT' }),
|
|
48
|
+
)
|
|
49
|
+
expect(result.action).toBe('drop')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('drops message_changed subtype', () => {
|
|
53
|
+
const result = gate(
|
|
54
|
+
{ subtype: 'message_changed', user: 'U123', channel: 'D1' },
|
|
55
|
+
makeOpts(),
|
|
56
|
+
)
|
|
57
|
+
expect(result.action).toBe('drop')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('drops message_deleted subtype', () => {
|
|
61
|
+
const result = gate(
|
|
62
|
+
{ subtype: 'message_deleted', user: 'U123', channel: 'D1' },
|
|
63
|
+
makeOpts(),
|
|
64
|
+
)
|
|
65
|
+
expect(result.action).toBe('drop')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('drops channel_join subtype', () => {
|
|
69
|
+
const result = gate(
|
|
70
|
+
{ subtype: 'channel_join', user: 'U123', channel: 'D1' },
|
|
71
|
+
makeOpts(),
|
|
72
|
+
)
|
|
73
|
+
expect(result.action).toBe('drop')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('allows file_share subtype through', () => {
|
|
77
|
+
const access = makeAccess({ allowFrom: ['U123'] })
|
|
78
|
+
const result = gate(
|
|
79
|
+
{ subtype: 'file_share', user: 'U123', channel: 'D1' },
|
|
80
|
+
makeOpts({ access }),
|
|
81
|
+
)
|
|
82
|
+
expect(result.action).toBe('deliver')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('drops messages with no user field', () => {
|
|
86
|
+
const result = gate(
|
|
87
|
+
{ channel: 'D1' },
|
|
88
|
+
makeOpts(),
|
|
89
|
+
)
|
|
90
|
+
expect(result.action).toBe('drop')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// -- allowlist --
|
|
94
|
+
|
|
95
|
+
test('delivers from allowlisted users', () => {
|
|
96
|
+
const access = makeAccess({ allowFrom: ['U_ALLOWED'] })
|
|
97
|
+
const result = gate(
|
|
98
|
+
{ user: 'U_ALLOWED', channel: 'D1' },
|
|
99
|
+
makeOpts({ access }),
|
|
100
|
+
)
|
|
101
|
+
expect(result.action).toBe('deliver')
|
|
102
|
+
expect(result.access).toBeDefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('drops from non-allowlisted users', () => {
|
|
106
|
+
const access = makeAccess({ allowFrom: ['U_OTHER'] })
|
|
107
|
+
const result = gate(
|
|
108
|
+
{ user: 'U_STRANGER', channel: 'D1' },
|
|
109
|
+
makeOpts({ access }),
|
|
110
|
+
)
|
|
111
|
+
expect(result.action).toBe('drop')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('drops when allowlist is empty', () => {
|
|
115
|
+
const result = gate(
|
|
116
|
+
{ user: 'U_ANYONE', channel: 'D1' },
|
|
117
|
+
makeOpts(),
|
|
118
|
+
)
|
|
119
|
+
expect(result.action).toBe('drop')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('delivers from allowlisted user in channel', () => {
|
|
123
|
+
const access = makeAccess({ allowFrom: ['U_ALLOWED'] })
|
|
124
|
+
const result = gate(
|
|
125
|
+
{ user: 'U_ALLOWED', channel: 'C_ANY', channel_type: 'channel' },
|
|
126
|
+
makeOpts({ access }),
|
|
127
|
+
)
|
|
128
|
+
expect(result.action).toBe('deliver')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('drops from non-allowlisted user in channel', () => {
|
|
132
|
+
const access = makeAccess({ allowFrom: ['U_VIP'] })
|
|
133
|
+
const result = gate(
|
|
134
|
+
{ user: 'U_NOBODY', channel: 'C_ANY', channel_type: 'channel' },
|
|
135
|
+
makeOpts({ access }),
|
|
136
|
+
)
|
|
137
|
+
expect(result.action).toBe('drop')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// -- bot_message --
|
|
141
|
+
|
|
142
|
+
test('delivers bot_message from any channel', () => {
|
|
143
|
+
const result = gate(
|
|
144
|
+
{ subtype: 'bot_message', bot_id: 'B_OTHER', channel: 'C_ANY' },
|
|
145
|
+
makeOpts(),
|
|
146
|
+
)
|
|
147
|
+
expect(result.action).toBe('deliver')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('delivers bot_message in DM', () => {
|
|
151
|
+
const result = gate(
|
|
152
|
+
{ subtype: 'bot_message', bot_id: 'B_OTHER', channel: 'D1', channel_type: 'im' },
|
|
153
|
+
makeOpts(),
|
|
154
|
+
)
|
|
155
|
+
expect(result.action).toBe('deliver')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('allows other bot with user field if in allowlist', () => {
|
|
159
|
+
const access = makeAccess({ allowFrom: ['U_OTHER_BOT'] })
|
|
160
|
+
const result = gate(
|
|
161
|
+
{ bot_id: 'B_OTHER', user: 'U_OTHER_BOT', channel: 'D1' },
|
|
162
|
+
makeOpts({ access, botUserId: 'U_BOT' }),
|
|
163
|
+
)
|
|
164
|
+
expect(result.action).toBe('deliver')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// assertOutboundAllowed()
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe('assertOutboundAllowed', () => {
|
|
173
|
+
test('allows delivered channels', () => {
|
|
174
|
+
const delivered = new Set(['D_DELIVERED'])
|
|
175
|
+
expect(() => assertOutboundAllowed('D_DELIVERED', delivered)).not.toThrow()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('blocks unknown channels', () => {
|
|
179
|
+
expect(() => assertOutboundAllowed('C_RANDO', new Set())).toThrow('Outbound gate')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('blocks channels not delivered to', () => {
|
|
183
|
+
const delivered = new Set(['D_DIFFERENT'])
|
|
184
|
+
expect(() => assertOutboundAllowed('C_ATTACKER', delivered)).toThrow('Outbound gate')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// defaultAccess()
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe('defaultAccess', () => {
|
|
193
|
+
test('returns empty allowlist', () => {
|
|
194
|
+
expect(defaultAccess().allowFrom).toEqual([])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('has no ackReaction by default', () => {
|
|
198
|
+
expect(defaultAccess().ackReaction).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// fixSlackMrkdwn()
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe('fixSlackMrkdwn', () => {
|
|
207
|
+
test('inserts ZWS around bold', () => {
|
|
208
|
+
expect(fixSlackMrkdwn('*hello*')).toBe('\u200B*hello*\u200B')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('handles multiple bold patterns', () => {
|
|
212
|
+
const result = fixSlackMrkdwn('*a* and *b*')
|
|
213
|
+
expect(result).toBe('\u200B*a*\u200B and \u200B*b*\u200B')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('leaves text without bold unchanged', () => {
|
|
217
|
+
expect(fixSlackMrkdwn('no bold here')).toBe('no bold here')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('handles empty string', () => {
|
|
221
|
+
expect(fixSlackMrkdwn('')).toBe('')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// auditLog
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
describe('formatAuditLine', () => {
|
|
230
|
+
test('produces JSON with trailing newline', () => {
|
|
231
|
+
const entry: AuditEntry = {
|
|
232
|
+
ts: '2026-03-25T12:00:00.000Z',
|
|
233
|
+
direction: 'inbound',
|
|
234
|
+
userId: 'U123',
|
|
235
|
+
chatId: 'C456',
|
|
236
|
+
action: 'deliver',
|
|
237
|
+
}
|
|
238
|
+
const line = formatAuditLine(entry)
|
|
239
|
+
expect(line.endsWith('\n')).toBe(true)
|
|
240
|
+
expect(JSON.parse(line.trim())).toEqual(entry)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('includes optional fields when present', () => {
|
|
244
|
+
const entry: AuditEntry = {
|
|
245
|
+
ts: '2026-03-25T12:00:00.000Z',
|
|
246
|
+
direction: 'outbound',
|
|
247
|
+
chatId: 'C456',
|
|
248
|
+
action: 'reply',
|
|
249
|
+
threadTs: '1234.5678',
|
|
250
|
+
text: 'hello world',
|
|
251
|
+
}
|
|
252
|
+
const parsed = JSON.parse(formatAuditLine(entry).trim())
|
|
253
|
+
expect(parsed.threadTs).toBe('1234.5678')
|
|
254
|
+
expect(parsed.text).toBe('hello world')
|
|
255
|
+
expect(parsed.userId).toBeUndefined()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('omits undefined fields', () => {
|
|
259
|
+
const entry: AuditEntry = {
|
|
260
|
+
ts: '2026-03-25T12:00:00.000Z',
|
|
261
|
+
direction: 'inbound',
|
|
262
|
+
chatId: 'C456',
|
|
263
|
+
action: 'drop',
|
|
264
|
+
}
|
|
265
|
+
const line = formatAuditLine(entry).trim()
|
|
266
|
+
expect(line).not.toContain('userId')
|
|
267
|
+
expect(line).not.toContain('threadTs')
|
|
268
|
+
expect(line).not.toContain('"text"')
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
describe('auditLog', () => {
|
|
273
|
+
test('writes to audit directory without throwing', () => {
|
|
274
|
+
const tmpDir = `/tmp/slack-audit-test-${Date.now()}`
|
|
275
|
+
auditLog(tmpDir, {
|
|
276
|
+
ts: '2026-03-25T12:00:00.000Z',
|
|
277
|
+
direction: 'inbound',
|
|
278
|
+
userId: 'U123',
|
|
279
|
+
chatId: 'C456',
|
|
280
|
+
action: 'deliver',
|
|
281
|
+
})
|
|
282
|
+
const { existsSync, readFileSync, rmSync } = require('fs')
|
|
283
|
+
const { join } = require('path')
|
|
284
|
+
const auditDir = join(tmpDir, 'audit')
|
|
285
|
+
expect(existsSync(auditDir)).toBe(true)
|
|
286
|
+
const files = require('fs').readdirSync(auditDir)
|
|
287
|
+
expect(files.length).toBe(1)
|
|
288
|
+
expect(files[0]).toMatch(/^\d{4}-\d{2}-\d{2}\.jsonl$/)
|
|
289
|
+
const content = readFileSync(join(auditDir, files[0]), 'utf-8')
|
|
290
|
+
const parsed = JSON.parse(content.trim())
|
|
291
|
+
expect(parsed.action).toBe('deliver')
|
|
292
|
+
rmSync(tmpDir, { recursive: true })
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('throws on invalid path', () => {
|
|
296
|
+
expect(() => {
|
|
297
|
+
auditLog('/dev/null/impossible', {
|
|
298
|
+
ts: '2026-03-25T12:00:00.000Z',
|
|
299
|
+
direction: 'inbound',
|
|
300
|
+
chatId: 'C456',
|
|
301
|
+
action: 'drop',
|
|
302
|
+
})
|
|
303
|
+
}).toThrow()
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// extractMessageText()
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
describe('extractMessageText', () => {
|
|
312
|
+
test('returns plain text from text field', () => {
|
|
313
|
+
expect(extractMessageText({ text: 'hello world' })).toBe('hello world')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('returns empty string for empty message', () => {
|
|
317
|
+
expect(extractMessageText({})).toBe('')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('parses rich_text blocks', () => {
|
|
321
|
+
const msg = {
|
|
322
|
+
blocks: [{
|
|
323
|
+
type: 'rich_text',
|
|
324
|
+
elements: [{
|
|
325
|
+
elements: [
|
|
326
|
+
{ text: 'hello ' },
|
|
327
|
+
{ text: 'world' },
|
|
328
|
+
],
|
|
329
|
+
}],
|
|
330
|
+
}],
|
|
331
|
+
}
|
|
332
|
+
expect(extractMessageText(msg)).toBe('hello world')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('parses section blocks', () => {
|
|
336
|
+
const msg = {
|
|
337
|
+
blocks: [{
|
|
338
|
+
type: 'section',
|
|
339
|
+
text: { text: 'Section content' },
|
|
340
|
+
}],
|
|
341
|
+
}
|
|
342
|
+
expect(extractMessageText(msg)).toBe('Section content')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('parses section with fields', () => {
|
|
346
|
+
const msg = {
|
|
347
|
+
blocks: [{
|
|
348
|
+
type: 'section',
|
|
349
|
+
fields: [{ text: 'Field 1' }, { text: 'Field 2' }],
|
|
350
|
+
}],
|
|
351
|
+
}
|
|
352
|
+
expect(extractMessageText(msg)).toBe('Field 1 Field 2')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('parses header blocks', () => {
|
|
356
|
+
const msg = {
|
|
357
|
+
blocks: [{ type: 'header', text: { text: 'My Header' } }],
|
|
358
|
+
}
|
|
359
|
+
expect(extractMessageText(msg)).toBe('*My Header*')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('parses context blocks', () => {
|
|
363
|
+
const msg = {
|
|
364
|
+
blocks: [{
|
|
365
|
+
type: 'context',
|
|
366
|
+
elements: [{ text: 'Context 1' }, { text: 'Context 2' }],
|
|
367
|
+
}],
|
|
368
|
+
}
|
|
369
|
+
expect(extractMessageText(msg)).toBe('Context 1 Context 2')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('parses divider blocks', () => {
|
|
373
|
+
const msg = {
|
|
374
|
+
blocks: [
|
|
375
|
+
{ type: 'section', text: { text: 'Above' } },
|
|
376
|
+
{ type: 'divider' },
|
|
377
|
+
{ type: 'section', text: { text: 'Below' } },
|
|
378
|
+
],
|
|
379
|
+
}
|
|
380
|
+
expect(extractMessageText(msg)).toBe('Above\n---\nBelow')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('parses image blocks', () => {
|
|
384
|
+
const msg = {
|
|
385
|
+
blocks: [{ type: 'image', alt_text: 'A chart' }],
|
|
386
|
+
}
|
|
387
|
+
expect(extractMessageText(msg)).toBe('A chart')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('falls back to text when no blocks', () => {
|
|
391
|
+
const msg = { text: 'fallback text', blocks: [] }
|
|
392
|
+
expect(extractMessageText(msg)).toBe('fallback text')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('parses attachments', () => {
|
|
396
|
+
const msg = {
|
|
397
|
+
attachments: [{
|
|
398
|
+
pretext: 'Alert',
|
|
399
|
+
title: 'CPU High',
|
|
400
|
+
title_link: 'https://grafana.example.com',
|
|
401
|
+
text: 'CPU usage > 90%',
|
|
402
|
+
}],
|
|
403
|
+
}
|
|
404
|
+
expect(extractMessageText(msg)).toContain('Alert')
|
|
405
|
+
expect(extractMessageText(msg)).toContain('<https://grafana.example.com|CPU High>')
|
|
406
|
+
expect(extractMessageText(msg)).toContain('CPU usage > 90%')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('parses attachment fields', () => {
|
|
410
|
+
const msg = {
|
|
411
|
+
attachments: [{
|
|
412
|
+
fields: [
|
|
413
|
+
{ title: 'Status', value: 'Critical' },
|
|
414
|
+
{ title: 'Region', value: 'us-east-1' },
|
|
415
|
+
],
|
|
416
|
+
}],
|
|
417
|
+
}
|
|
418
|
+
const result = extractMessageText(msg)
|
|
419
|
+
expect(result).toContain('Status: Critical')
|
|
420
|
+
expect(result).toContain('Region: us-east-1')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('uses fallback when attachment has no content', () => {
|
|
424
|
+
const msg = {
|
|
425
|
+
attachments: [{ fallback: 'Fallback text' }],
|
|
426
|
+
}
|
|
427
|
+
expect(extractMessageText(msg)).toBe('Fallback text')
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
test('parses attachment with image_url', () => {
|
|
431
|
+
const msg = {
|
|
432
|
+
attachments: [{ image_url: 'https://example.com/img.png' }],
|
|
433
|
+
}
|
|
434
|
+
expect(extractMessageText(msg)).toBe('[image: https://example.com/img.png]')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('parses blocks inside attachments', () => {
|
|
438
|
+
const msg = {
|
|
439
|
+
attachments: [{
|
|
440
|
+
blocks: [{
|
|
441
|
+
type: 'section',
|
|
442
|
+
text: { text: 'Inner block content' },
|
|
443
|
+
}],
|
|
444
|
+
}],
|
|
445
|
+
}
|
|
446
|
+
expect(extractMessageText(msg)).toBe('Inner block content')
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test('returns file descriptions for file-only messages', () => {
|
|
450
|
+
const msg = {
|
|
451
|
+
files: [
|
|
452
|
+
{ name: 'report.pdf' },
|
|
453
|
+
{ name: 'data.csv' },
|
|
454
|
+
],
|
|
455
|
+
}
|
|
456
|
+
expect(extractMessageText(msg)).toBe('[file: report.pdf], [file: data.csv]')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
test('blocks take priority over text', () => {
|
|
460
|
+
const msg = {
|
|
461
|
+
text: 'plain text',
|
|
462
|
+
blocks: [{ type: 'section', text: { text: 'block text' } }],
|
|
463
|
+
}
|
|
464
|
+
expect(extractMessageText(msg)).toBe('block text')
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
test('multiple block types combined', () => {
|
|
468
|
+
const msg = {
|
|
469
|
+
blocks: [
|
|
470
|
+
{ type: 'header', text: { text: 'Alert' } },
|
|
471
|
+
{ type: 'section', text: { text: 'Something broke' } },
|
|
472
|
+
{ type: 'context', elements: [{ text: 'via Grafana' }] },
|
|
473
|
+
],
|
|
474
|
+
}
|
|
475
|
+
const result = extractMessageText(msg)
|
|
476
|
+
expect(result).toBe('*Alert*\nSomething broke\nvia Grafana')
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// buildPermalink()
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
describe('buildPermalink', () => {
|
|
485
|
+
const workspace = 'msuniverse'
|
|
486
|
+
|
|
487
|
+
test('root message permalink', () => {
|
|
488
|
+
const result = buildPermalink(workspace, 'C09GDRYF3FF', '1774461389.128779')
|
|
489
|
+
expect(result).toBe('https://msuniverse.slack.com/archives/C09GDRYF3FF/p1774461389128779')
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('thread reply permalink', () => {
|
|
493
|
+
const result = buildPermalink(workspace, 'C09GDRYF3FF', '1774461419.933019', '1774461389.128779')
|
|
494
|
+
expect(result).toBe('https://msuniverse.slack.com/archives/C09GDRYF3FF/p1774461419933019?thread_ts=1774461389.128779&cid=C09GDRYF3FF')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('ts dot removal', () => {
|
|
498
|
+
const result = buildPermalink(workspace, 'C123', '1234567890.123456')
|
|
499
|
+
expect(result).toBe('https://msuniverse.slack.com/archives/C123/p1234567890123456')
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// isDm()
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
describe('isDm', () => {
|
|
508
|
+
test('channel_type im is DM', () => {
|
|
509
|
+
expect(isDm({ channel_type: 'im' })).toBe(true)
|
|
510
|
+
})
|
|
511
|
+
test('channel_type channel is not DM', () => {
|
|
512
|
+
expect(isDm({ channel_type: 'channel' })).toBe(false)
|
|
513
|
+
})
|
|
514
|
+
test('no channel_type is not DM', () => {
|
|
515
|
+
expect(isDm({})).toBe(false)
|
|
516
|
+
})
|
|
517
|
+
test('app_mention event (no channel_type) is not DM', () => {
|
|
518
|
+
expect(isDm({ type: 'app_mention' })).toBe(false)
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// resolveThreadTs()
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
describe('resolveThreadTs', () => {
|
|
527
|
+
test('uses thread_ts when present', () => {
|
|
528
|
+
expect(resolveThreadTs({ thread_ts: '111.222', ts: '333.444' })).toBe('111.222')
|
|
529
|
+
})
|
|
530
|
+
test('falls back to ts when thread_ts is missing', () => {
|
|
531
|
+
expect(resolveThreadTs({ ts: '333.444' })).toBe('333.444')
|
|
532
|
+
})
|
|
533
|
+
test('falls back to ts when thread_ts is empty string', () => {
|
|
534
|
+
expect(resolveThreadTs({ thread_ts: '', ts: '333.444' })).toBe('333.444')
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// parseSlackTimestamp()
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
describe('parseSlackTimestamp', () => {
|
|
543
|
+
test('parses standard Slack timestamp', () => {
|
|
544
|
+
const result = parseSlackTimestamp('1711500000.123456')
|
|
545
|
+
expect(result).toBeInstanceOf(Date)
|
|
546
|
+
expect(result!.getTime()).toBe(1711500000123)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test('returns null for invalid timestamp', () => {
|
|
550
|
+
expect(parseSlackTimestamp('')).toBeNull()
|
|
551
|
+
expect(parseSlackTimestamp('not-a-number')).toBeNull()
|
|
552
|
+
expect(parseSlackTimestamp('123abc')).toBeNull()
|
|
553
|
+
expect(parseSlackTimestamp('123')).toBeNull()
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
test('handles integer timestamp without fractional part', () => {
|
|
557
|
+
const result = parseSlackTimestamp('1711500000.000000')
|
|
558
|
+
expect(result).toBeInstanceOf(Date)
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// isStaleEvent()
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
describe('isStaleEvent', () => {
|
|
567
|
+
test('returns true for event older than maxAge', () => {
|
|
568
|
+
const oldTs = String((Date.now() / 1000) - 700) // 11+ minutes ago
|
|
569
|
+
expect(isStaleEvent(oldTs, 600_000)).toBe(true)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
test('returns false for recent event', () => {
|
|
573
|
+
const recentTs = String(Date.now() / 1000) // now
|
|
574
|
+
expect(isStaleEvent(recentTs, 600_000)).toBe(false)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test('returns false for unparseable timestamp', () => {
|
|
578
|
+
expect(isStaleEvent('', 600_000)).toBe(false)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test('respects custom maxAge', () => {
|
|
582
|
+
const ts = String((Date.now() / 1000) - 30) // 30 seconds ago
|
|
583
|
+
expect(isStaleEvent(ts, 60_000)).toBe(false) // 1 min threshold
|
|
584
|
+
expect(isStaleEvent(ts, 20_000)).toBe(true) // 20 sec threshold
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// isEmptyMessage()
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
describe('isEmptyMessage', () => {
|
|
593
|
+
test('returns true for empty text', () => {
|
|
594
|
+
expect(isEmptyMessage({ text: '' })).toBe(true)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('returns true for whitespace-only text', () => {
|
|
598
|
+
expect(isEmptyMessage({ text: ' \n\t ' })).toBe(true)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
test('returns true for no text field', () => {
|
|
602
|
+
expect(isEmptyMessage({})).toBe(true)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
test('returns false for message with text', () => {
|
|
606
|
+
expect(isEmptyMessage({ text: 'hello' })).toBe(false)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
test('returns false for message with files (no text)', () => {
|
|
610
|
+
expect(isEmptyMessage({ files: [{ id: 'F1' }] })).toBe(false)
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('returns false for message with blocks (no text)', () => {
|
|
614
|
+
expect(isEmptyMessage({ blocks: [{ type: 'section' }] })).toBe(false)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
test('returns false for message with attachments', () => {
|
|
618
|
+
expect(isEmptyMessage({ attachments: [{ text: 'alert' }] })).toBe(false)
|
|
619
|
+
})
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// EventDeduplicator
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
describe('EventDeduplicator', () => {
|
|
627
|
+
test('first occurrence returns false (not duplicate)', () => {
|
|
628
|
+
const dedup = new EventDeduplicator(60_000)
|
|
629
|
+
expect(dedup.isDuplicate('C1', '1234.5678')).toBe(false)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
test('second occurrence returns true (duplicate)', () => {
|
|
633
|
+
const dedup = new EventDeduplicator(60_000)
|
|
634
|
+
dedup.isDuplicate('C1', '1234.5678')
|
|
635
|
+
expect(dedup.isDuplicate('C1', '1234.5678')).toBe(true)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
test('different channels with same ts are not duplicates', () => {
|
|
639
|
+
const dedup = new EventDeduplicator(60_000)
|
|
640
|
+
dedup.isDuplicate('C1', '1234.5678')
|
|
641
|
+
expect(dedup.isDuplicate('C2', '1234.5678')).toBe(false)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
test('different ts in same channel are not duplicates', () => {
|
|
645
|
+
const dedup = new EventDeduplicator(60_000)
|
|
646
|
+
dedup.isDuplicate('C1', '1234.5678')
|
|
647
|
+
expect(dedup.isDuplicate('C1', '1234.9999')).toBe(false)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
test('entries expire after TTL', () => {
|
|
651
|
+
const dedup = new EventDeduplicator(50) // 50ms TTL
|
|
652
|
+
dedup.isDuplicate('C1', '1234.5678')
|
|
653
|
+
// Wait for expiry
|
|
654
|
+
const start = Date.now()
|
|
655
|
+
while (Date.now() - start < 60) {} // busy wait 60ms
|
|
656
|
+
expect(dedup.isDuplicate('C1', '1234.5678')).toBe(false)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
test('cleanup removes expired entries', () => {
|
|
660
|
+
const dedup = new EventDeduplicator(50, 1) // TTL 50ms, cleanup every call
|
|
661
|
+
dedup.isDuplicate('C1', '1.0')
|
|
662
|
+
dedup.isDuplicate('C1', '2.0')
|
|
663
|
+
const start = Date.now()
|
|
664
|
+
while (Date.now() - start < 60) {}
|
|
665
|
+
dedup.isDuplicate('C1', '3.0') // triggers cleanup (interval=1)
|
|
666
|
+
expect(dedup.size).toBe(1) // only '3.0' remains
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// clientSupportsChannels()
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
describe('clientSupportsChannels', () => {
|
|
675
|
+
test('returns false for undefined capabilities', () => {
|
|
676
|
+
expect(clientSupportsChannels(undefined)).toBe(false)
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
test('returns false for empty capabilities', () => {
|
|
680
|
+
expect(clientSupportsChannels({})).toBe(false)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
test('returns false when no experimental field', () => {
|
|
684
|
+
expect(clientSupportsChannels({ tools: {} })).toBe(false)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
test('returns false when experimental is empty', () => {
|
|
688
|
+
expect(clientSupportsChannels({ experimental: {} })).toBe(false)
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
test('returns false when experimental has other keys but not claude/channel', () => {
|
|
692
|
+
expect(clientSupportsChannels({
|
|
693
|
+
experimental: { 'some/other': {} },
|
|
694
|
+
})).toBe(false)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
test('returns true when claude/channel is present', () => {
|
|
698
|
+
expect(clientSupportsChannels({
|
|
699
|
+
experimental: { 'claude/channel': {} },
|
|
700
|
+
})).toBe(true)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
test('returns true when claude/channel is present alongside other keys', () => {
|
|
704
|
+
expect(clientSupportsChannels({
|
|
705
|
+
experimental: {
|
|
706
|
+
'claude/channel': {},
|
|
707
|
+
'claude/channel/permission': {},
|
|
708
|
+
},
|
|
709
|
+
})).toBe(true)
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
test('returns true even if claude/channel value is null', () => {
|
|
713
|
+
expect(clientSupportsChannels({
|
|
714
|
+
experimental: { 'claude/channel': null },
|
|
715
|
+
})).toBe(true)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
test('returns false when experimental is not an object', () => {
|
|
719
|
+
expect(clientSupportsChannels({ experimental: 'string' })).toBe(false)
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
|