@onmars/lunar-discord 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.
@@ -0,0 +1,919 @@
1
+ /**
2
+ * # Discord Channel Adapter — Functional Specification
3
+ *
4
+ * Tests the Discord adapter's logic without real Discord connections.
5
+ *
6
+ * ## DiscordChannel constructor
7
+ * Creates a discord.js Client with Guilds, GuildMessages, MessageContent, DirectMessages intents.
8
+ * Stores options: token, guildId, allowedUsers, allowedChannels.
9
+ *
10
+ * ## isConnected()
11
+ * Returns the internal connected flag (set by connect/disconnect/shard events).
12
+ *
13
+ * ## onMessage(handler)
14
+ * Stores the handler for incoming messages.
15
+ *
16
+ * ## send(target, content)
17
+ * Returns undefined if disconnected. Otherwise:
18
+ * - Fetches channel by ID, formats text via formatForDiscord, splits via splitMessage.
19
+ * - Converts attachments (Buffer or base64 string → Buffer).
20
+ * - Sends chunks, with reply on first chunk and files on last chunk.
21
+ * - Returns last message ID.
22
+ *
23
+ * ## sendTyping(target)
24
+ * No-op if disconnected. Otherwise sends typing indicator.
25
+ * Swallows errors (best effort).
26
+ *
27
+ * ## inferAttachmentType(contentType)
28
+ * Maps MIME prefixes to attachment type: image/, audio/, video/ → respective types.
29
+ * null/undefined/unknown → 'file'.
30
+ *
31
+ * ## Message filtering (setupMessageListener)
32
+ * Ignores: bots, wrong guild, non-allowed channels, non-allowed users, empty messages.
33
+ * Builds IncomingMessage from Discord.js Message.
34
+ *
35
+ * ## Testing approach:
36
+ * We test inferAttachmentType and send/sendTyping logic by accessing internals.
37
+ * For the message listener, we test filtering logic via the adapter's methods.
38
+ */
39
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
40
+ import { DiscordChannel, type DiscordChannelOptions } from '../adapter'
41
+
42
+ // ─── Helpers ────────────────────────────────────────────────────────
43
+
44
+ const defaultOptions: DiscordChannelOptions = {
45
+ token: 'fake-bot-token',
46
+ guildId: 'guild_123',
47
+ }
48
+
49
+ function createAdapter(overrides: Partial<DiscordChannelOptions> = {}): DiscordChannel {
50
+ return new DiscordChannel({ ...defaultOptions, ...overrides })
51
+ }
52
+
53
+ // ═══════════════════════════════════════════════════════════════════
54
+ // Constructor and metadata
55
+ // ═══════════════════════════════════════════════════════════════════
56
+
57
+ describe('DiscordChannel — metadata', () => {
58
+ it('defaults id and name to "discord" when not specified', () => {
59
+ const adapter = createAdapter()
60
+ expect(adapter.id).toBe('discord')
61
+ expect(adapter.name).toBe('discord')
62
+ })
63
+
64
+ it('uses custom id when provided in options', () => {
65
+ const adapter = createAdapter({ id: 'discord:hermes' })
66
+ expect(adapter.id).toBe('discord:hermes')
67
+ expect(adapter.name).toBe('discord:hermes')
68
+ })
69
+
70
+ it('uses custom id for different account names', () => {
71
+ const athena = createAdapter({ id: 'discord:athena' })
72
+ expect(athena.id).toBe('discord:athena')
73
+
74
+ const themis = createAdapter({ id: 'discord:themis' })
75
+ expect(themis.id).toBe('discord:themis')
76
+ })
77
+ })
78
+
79
+ // ═══════════════════════════════════════════════════════════════════
80
+ // isConnected — Connection state
81
+ // ═══════════════════════════════════════════════════════════════════
82
+
83
+ describe('DiscordChannel — isConnected', () => {
84
+ it('starts disconnected', () => {
85
+ const adapter = createAdapter()
86
+ expect(adapter.isConnected()).toBe(false)
87
+ })
88
+ })
89
+
90
+ // ═══════════════════════════════════════════════════════════════════
91
+ // onMessage — Handler registration
92
+ // ═══════════════════════════════════════════════════════════════════
93
+
94
+ describe('DiscordChannel — onMessage', () => {
95
+ it('stores the message handler', () => {
96
+ const adapter = createAdapter()
97
+ const handler = mock(async () => {})
98
+
99
+ adapter.onMessage(handler)
100
+
101
+ // Verify handler is stored by accessing internal state
102
+ expect((adapter as any).messageHandler).toBe(handler)
103
+ })
104
+ })
105
+
106
+ // ═══════════════════════════════════════════════════════════════════
107
+ // send — Message sending (when disconnected)
108
+ // ═══════════════════════════════════════════════════════════════════
109
+
110
+ describe('DiscordChannel — send (disconnected)', () => {
111
+ it('returns undefined when not connected', async () => {
112
+ const adapter = createAdapter()
113
+ const result = await adapter.send('channel_123', { text: 'Hello' })
114
+
115
+ expect(result).toBeUndefined()
116
+ })
117
+ })
118
+
119
+ // ═══════════════════════════════════════════════════════════════════
120
+ // send — Message sending (when connected, with mocked client)
121
+ // ═══════════════════════════════════════════════════════════════════
122
+
123
+ describe('DiscordChannel — send (connected)', () => {
124
+ it('sends formatted text to the target channel', async () => {
125
+ const adapter = createAdapter()
126
+ // Force connected state
127
+ ;(adapter as any).connected = true
128
+
129
+ const sentMessages: any[] = []
130
+ const mockChannel = {
131
+ isTextBased: () => true,
132
+ send: mock(async (opts: any) => {
133
+ sentMessages.push(opts)
134
+ return { id: 'msg_' + sentMessages.length }
135
+ }),
136
+ }
137
+
138
+ // Mock client.channels.fetch
139
+ ;(adapter as any).client = {
140
+ channels: {
141
+ fetch: mock(async () => mockChannel),
142
+ },
143
+ }
144
+
145
+ const result = await adapter.send('channel_123', { text: 'Hello world' })
146
+
147
+ expect(result).toBe('msg_1')
148
+ expect(sentMessages).toHaveLength(1)
149
+ expect(sentMessages[0].content).toBe('Hello world')
150
+ })
151
+
152
+ it('splits long messages and sends multiple chunks', async () => {
153
+ const adapter = createAdapter()
154
+ ;(adapter as any).connected = true
155
+
156
+ const sentMessages: any[] = []
157
+ const mockChannel = {
158
+ isTextBased: () => true,
159
+ send: mock(async (opts: any) => {
160
+ sentMessages.push(opts)
161
+ return { id: 'msg_' + sentMessages.length }
162
+ }),
163
+ }
164
+
165
+ ;(adapter as any).client = {
166
+ channels: {
167
+ fetch: mock(async () => mockChannel),
168
+ },
169
+ }
170
+
171
+ // Create text longer than 2000 chars
172
+ const longText = 'a'.repeat(3000)
173
+ const result = await adapter.send('channel_123', { text: longText })
174
+
175
+ expect(sentMessages.length).toBeGreaterThanOrEqual(2)
176
+ // Returns last message ID
177
+ expect(result).toBe('msg_' + sentMessages.length)
178
+ })
179
+
180
+ it('includes reply on first chunk only', async () => {
181
+ const adapter = createAdapter()
182
+ ;(adapter as any).connected = true
183
+
184
+ const sentMessages: any[] = []
185
+ const mockChannel = {
186
+ isTextBased: () => true,
187
+ send: mock(async (opts: any) => {
188
+ sentMessages.push(opts)
189
+ return { id: 'msg_' + sentMessages.length }
190
+ }),
191
+ }
192
+
193
+ ;(adapter as any).client = {
194
+ channels: {
195
+ fetch: mock(async () => mockChannel),
196
+ },
197
+ }
198
+
199
+ const longText = 'a'.repeat(3000)
200
+ await adapter.send('channel_123', { text: longText, replyTo: 'original_msg_id' })
201
+
202
+ // First chunk has reply
203
+ expect(sentMessages[0].reply).toEqual({
204
+ messageReference: 'original_msg_id',
205
+ failIfNotExists: false,
206
+ })
207
+ // Subsequent chunks should not have reply
208
+ for (let i = 1; i < sentMessages.length; i++) {
209
+ expect(sentMessages[i].reply).toBeUndefined()
210
+ }
211
+ })
212
+
213
+ it('attaches files to the last chunk', async () => {
214
+ const adapter = createAdapter()
215
+ ;(adapter as any).connected = true
216
+
217
+ const sentMessages: any[] = []
218
+ const mockChannel = {
219
+ isTextBased: () => true,
220
+ send: mock(async (opts: any) => {
221
+ sentMessages.push(opts)
222
+ return { id: 'msg_' + sentMessages.length }
223
+ }),
224
+ }
225
+
226
+ ;(adapter as any).client = {
227
+ channels: {
228
+ fetch: mock(async () => mockChannel),
229
+ },
230
+ }
231
+
232
+ const longText = 'a'.repeat(3000)
233
+ await adapter.send('channel_123', {
234
+ text: longText,
235
+ attachments: [
236
+ {
237
+ type: 'audio',
238
+ data: Buffer.from('audio-data'),
239
+ filename: 'voice.mp3',
240
+ mimeType: 'audio/mpeg',
241
+ },
242
+ ],
243
+ })
244
+
245
+ // Only last chunk has files
246
+ const lastChunk = sentMessages[sentMessages.length - 1]
247
+ expect(lastChunk.files).toHaveLength(1)
248
+ expect(lastChunk.files[0].name).toBe('voice.mp3')
249
+
250
+ // First chunk should NOT have files
251
+ if (sentMessages.length > 1) {
252
+ expect(sentMessages[0].files).toBeUndefined()
253
+ }
254
+ })
255
+
256
+ it('converts base64 attachment data to Buffer', async () => {
257
+ const adapter = createAdapter()
258
+ ;(adapter as any).connected = true
259
+
260
+ const sentMessages: any[] = []
261
+ const mockChannel = {
262
+ isTextBased: () => true,
263
+ send: mock(async (opts: any) => {
264
+ sentMessages.push(opts)
265
+ return { id: 'msg_1' }
266
+ }),
267
+ }
268
+
269
+ ;(adapter as any).client = {
270
+ channels: {
271
+ fetch: mock(async () => mockChannel),
272
+ },
273
+ }
274
+
275
+ const base64Data = Buffer.from('hello').toString('base64')
276
+ await adapter.send('channel_123', {
277
+ text: 'File attached',
278
+ attachments: [{ type: 'file', data: base64Data, filename: 'data.txt' }],
279
+ })
280
+
281
+ const file = sentMessages[0].files[0]
282
+ expect(file.attachment).toBeInstanceOf(Buffer)
283
+ expect(file.name).toBe('data.txt')
284
+ })
285
+
286
+ it('returns undefined for non-text-based channel', async () => {
287
+ const adapter = createAdapter()
288
+ ;(adapter as any).connected = true
289
+
290
+ const mockChannel = {
291
+ isTextBased: () => false,
292
+ }
293
+
294
+ ;(adapter as any).client = {
295
+ channels: {
296
+ fetch: mock(async () => mockChannel),
297
+ },
298
+ }
299
+
300
+ const result = await adapter.send('channel_123', { text: 'Hello' })
301
+ expect(result).toBeUndefined()
302
+ })
303
+
304
+ it('returns undefined when channel is null', async () => {
305
+ const adapter = createAdapter()
306
+ ;(adapter as any).connected = true
307
+
308
+ ;(adapter as any).client = {
309
+ channels: {
310
+ fetch: mock(async () => null),
311
+ },
312
+ }
313
+
314
+ const result = await adapter.send('nonexistent', { text: 'Hello' })
315
+ expect(result).toBeUndefined()
316
+ })
317
+ })
318
+
319
+ // ═══════════════════════════════════════════════════════════════════
320
+ // sendTyping — Typing indicator
321
+ // ═══════════════════════════════════════════════════════════════════
322
+
323
+ describe('DiscordChannel — sendTyping', () => {
324
+ it('does nothing when disconnected', async () => {
325
+ const adapter = createAdapter()
326
+ // Should not throw
327
+ await adapter.sendTyping('channel_123')
328
+ })
329
+
330
+ it('calls channel.sendTyping when connected', async () => {
331
+ const adapter = createAdapter()
332
+ ;(adapter as any).connected = true
333
+
334
+ const sendTypingMock = mock(async () => {})
335
+ const mockChannel = {
336
+ isTextBased: () => true,
337
+ sendTyping: sendTypingMock,
338
+ }
339
+
340
+ ;(adapter as any).client = {
341
+ channels: {
342
+ fetch: mock(async () => mockChannel),
343
+ },
344
+ }
345
+
346
+ await adapter.sendTyping('channel_123')
347
+ expect(sendTypingMock).toHaveBeenCalledTimes(1)
348
+ })
349
+
350
+ it('swallows errors gracefully', async () => {
351
+ const adapter = createAdapter()
352
+ ;(adapter as any).connected = true
353
+
354
+ ;(adapter as any).client = {
355
+ channels: {
356
+ fetch: mock(async () => {
357
+ throw new Error('Channel not accessible')
358
+ }),
359
+ },
360
+ }
361
+
362
+ // Should not throw
363
+ await adapter.sendTyping('channel_123')
364
+ })
365
+ })
366
+
367
+ // ═══════════════════════════════════════════════════════════════════
368
+ // inferAttachmentType — MIME type to attachment type mapping
369
+ // ═══════════════════════════════════════════════════════════════════
370
+
371
+ describe('DiscordChannel — inferAttachmentType', () => {
372
+ it('maps image/* to "image"', () => {
373
+ const adapter = createAdapter()
374
+ const infer = (adapter as any).inferAttachmentType.bind(adapter)
375
+
376
+ expect(infer('image/png')).toBe('image')
377
+ expect(infer('image/jpeg')).toBe('image')
378
+ expect(infer('image/gif')).toBe('image')
379
+ expect(infer('image/webp')).toBe('image')
380
+ })
381
+
382
+ it('maps audio/* to "audio"', () => {
383
+ const adapter = createAdapter()
384
+ const infer = (adapter as any).inferAttachmentType.bind(adapter)
385
+
386
+ expect(infer('audio/ogg')).toBe('audio')
387
+ expect(infer('audio/mpeg')).toBe('audio')
388
+ expect(infer('audio/wav')).toBe('audio')
389
+ })
390
+
391
+ it('maps video/* to "video"', () => {
392
+ const adapter = createAdapter()
393
+ const infer = (adapter as any).inferAttachmentType.bind(adapter)
394
+
395
+ expect(infer('video/mp4')).toBe('video')
396
+ expect(infer('video/webm')).toBe('video')
397
+ })
398
+
399
+ it('maps null/undefined to "file"', () => {
400
+ const adapter = createAdapter()
401
+ const infer = (adapter as any).inferAttachmentType.bind(adapter)
402
+
403
+ expect(infer(null)).toBe('file')
404
+ expect(infer(undefined)).toBe('file')
405
+ })
406
+
407
+ it('maps unknown MIME types to "file"', () => {
408
+ const adapter = createAdapter()
409
+ const infer = (adapter as any).inferAttachmentType.bind(adapter)
410
+
411
+ expect(infer('application/pdf')).toBe('file')
412
+ expect(infer('text/plain')).toBe('file')
413
+ expect(infer('application/json')).toBe('file')
414
+ })
415
+ })
416
+
417
+ // ═══════════════════════════════════════════════════════════════════
418
+ // parseAttachments — Discord.js attachment conversion
419
+ // ═══════════════════════════════════════════════════════════════════
420
+
421
+ describe('DiscordChannel — parseAttachments', () => {
422
+ it('converts discord.js attachment collection to Attachment array', () => {
423
+ const adapter = createAdapter()
424
+ const parse = (adapter as any).parseAttachments.bind(adapter)
425
+
426
+ // Mock a discord.js Message with attachments
427
+ const mockMsg = {
428
+ attachments: {
429
+ map: (fn: any) =>
430
+ [
431
+ {
432
+ contentType: 'image/png',
433
+ url: 'https://cdn.discord.com/test.png',
434
+ name: 'screenshot.png',
435
+ size: 12345,
436
+ },
437
+ {
438
+ contentType: 'audio/ogg',
439
+ url: 'https://cdn.discord.com/voice.ogg',
440
+ name: 'voice.ogg',
441
+ size: 67890,
442
+ },
443
+ ].map(fn),
444
+ },
445
+ }
446
+
447
+ const result = parse(mockMsg)
448
+
449
+ expect(result).toHaveLength(2)
450
+ expect(result[0]).toEqual({
451
+ type: 'image',
452
+ url: 'https://cdn.discord.com/test.png',
453
+ filename: 'screenshot.png',
454
+ mimeType: 'image/png',
455
+ size: 12345,
456
+ })
457
+ expect(result[1]).toEqual({
458
+ type: 'audio',
459
+ url: 'https://cdn.discord.com/voice.ogg',
460
+ filename: 'voice.ogg',
461
+ mimeType: 'audio/ogg',
462
+ size: 67890,
463
+ })
464
+ })
465
+
466
+ it('handles attachments with null name and contentType', () => {
467
+ const adapter = createAdapter()
468
+ const parse = (adapter as any).parseAttachments.bind(adapter)
469
+
470
+ const mockMsg = {
471
+ attachments: {
472
+ map: (fn: any) =>
473
+ [
474
+ {
475
+ contentType: null,
476
+ url: 'https://cdn.discord.com/unknown',
477
+ name: null,
478
+ size: 100,
479
+ },
480
+ ].map(fn),
481
+ },
482
+ }
483
+
484
+ const result = parse(mockMsg)
485
+
486
+ expect(result).toHaveLength(1)
487
+ expect(result[0].type).toBe('file')
488
+ expect(result[0].filename).toBeUndefined()
489
+ expect(result[0].mimeType).toBeUndefined()
490
+ })
491
+ })
492
+
493
+ // ═══════════════════════════════════════════════════════════════════
494
+ // edit — Message editing
495
+ // ═══════════════════════════════════════════════════════════════════
496
+
497
+ describe('DiscordChannel — edit', () => {
498
+ it('does nothing when disconnected', async () => {
499
+ const adapter = createAdapter()
500
+ // Should not throw
501
+ await adapter.edit!('msg_123', 'channel_123', { text: 'Updated' })
502
+ })
503
+
504
+ it('edits message content when connected', async () => {
505
+ const adapter = createAdapter()
506
+ ;(adapter as any).connected = true
507
+
508
+ const editMock = mock(async () => {})
509
+ const mockChannel = {
510
+ isTextBased: () => true,
511
+ messages: {
512
+ fetch: mock(async () => ({ edit: editMock })),
513
+ },
514
+ }
515
+
516
+ ;(adapter as any).client = {
517
+ channels: {
518
+ fetch: mock(async () => mockChannel),
519
+ },
520
+ }
521
+
522
+ await adapter.edit!('msg_123', 'channel_123', { text: 'Updated text' })
523
+ expect(editMock).toHaveBeenCalledTimes(1)
524
+ })
525
+ })
526
+
527
+ // ═══════════════════════════════════════════════════════════════════
528
+ // delete — Message deletion
529
+ // ═══════════════════════════════════════════════════════════════════
530
+
531
+ describe('DiscordChannel — delete', () => {
532
+ it('does nothing when disconnected', async () => {
533
+ const adapter = createAdapter()
534
+ await adapter.delete!('msg_123', 'channel_123')
535
+ })
536
+
537
+ it('deletes message when connected', async () => {
538
+ const adapter = createAdapter()
539
+ ;(adapter as any).connected = true
540
+
541
+ const deleteMock = mock(async () => {})
542
+ const mockChannel = {
543
+ isTextBased: () => true,
544
+ messages: {
545
+ fetch: mock(async () => ({ delete: deleteMock })),
546
+ },
547
+ }
548
+
549
+ ;(adapter as any).client = {
550
+ channels: {
551
+ fetch: mock(async () => mockChannel),
552
+ },
553
+ }
554
+
555
+ await adapter.delete!('msg_123', 'channel_123')
556
+ expect(deleteMock).toHaveBeenCalledTimes(1)
557
+ })
558
+ })
559
+
560
+ // ═══════════════════════════════════════════════════════════════════
561
+ // react — Message reaction
562
+ // ═══════════════════════════════════════════════════════════════════
563
+
564
+ describe('DiscordChannel — react', () => {
565
+ it('does nothing when disconnected', async () => {
566
+ const adapter = createAdapter()
567
+ await adapter.react!('msg_123', 'channel_123', '👍')
568
+ })
569
+
570
+ it('reacts to message when connected', async () => {
571
+ const adapter = createAdapter()
572
+ ;(adapter as any).connected = true
573
+
574
+ const reactMock = mock(async () => {})
575
+ const mockChannel = {
576
+ isTextBased: () => true,
577
+ messages: {
578
+ fetch: mock(async () => ({ react: reactMock })),
579
+ },
580
+ }
581
+
582
+ ;(adapter as any).client = {
583
+ channels: {
584
+ fetch: mock(async () => mockChannel),
585
+ },
586
+ }
587
+
588
+ await adapter.react!('msg_123', 'channel_123', '🚀')
589
+ expect(reactMock).toHaveBeenCalledWith('🚀')
590
+ })
591
+ })
592
+
593
+ // ═══════════════════════════════════════════════════════════════════
594
+ // disconnect — Cleanup
595
+ // ═══════════════════════════════════════════════════════════════════
596
+
597
+ describe('DiscordChannel — disconnect', () => {
598
+ it('sets connected to false and destroys client', async () => {
599
+ const adapter = createAdapter()
600
+ ;(adapter as any).connected = true
601
+
602
+ const destroyMock = mock(() => {})
603
+ ;(adapter as any).client = { destroy: destroyMock }
604
+
605
+ await adapter.disconnect()
606
+
607
+ expect(adapter.isConnected()).toBe(false)
608
+ expect(destroyMock).toHaveBeenCalledTimes(1)
609
+ })
610
+ })
611
+
612
+ // ═══════════════════════════════════════════════════════════════════
613
+ // connect — Event listener setup and client.login
614
+ // ═══════════════════════════════════════════════════════════════════
615
+
616
+ describe('DiscordChannel — connect', () => {
617
+ /**
618
+ * Build a mock discord.js Client that supports once/on/login.
619
+ * login() triggers the 'ready' event on the next microtask by default.
620
+ */
621
+ /**
622
+ * Build a mock discord.js Client that supports once/on/login.
623
+ *
624
+ * Events.ClientReady = 'clientReady', Events.Error = 'error',
625
+ * Events.MessageCreate = 'messageCreate', etc.
626
+ * login() triggers the 'clientReady' event on the next microtask by default.
627
+ */
628
+ function buildMockClient(
629
+ opts: { loginBehavior?: 'ready' | 'error' | 'throw'; errorMessage?: string } = {},
630
+ ) {
631
+ const { loginBehavior = 'ready', errorMessage = 'Connection failed' } = opts
632
+ const onceListeners: Record<string, Function> = {}
633
+ const onListeners: string[] = []
634
+
635
+ const client = {
636
+ once: mock((event: string, handler: Function) => {
637
+ onceListeners[event] = handler
638
+ }),
639
+ on: mock((event: string, _fn: Function) => {
640
+ onListeners.push(event)
641
+ }),
642
+ login: mock(async (token: string) => {
643
+ if (loginBehavior === 'throw') {
644
+ throw new Error(errorMessage)
645
+ }
646
+ // Use queueMicrotask to fire event after login() returns
647
+ // but before the Promise chain resolves
648
+ queueMicrotask(() => {
649
+ if (loginBehavior === 'ready') {
650
+ // Events.ClientReady = 'clientReady' in discord.js
651
+ onceListeners['clientReady']?.({
652
+ user: { tag: 'Bot#0001' },
653
+ guilds: { cache: { size: 1 } },
654
+ })
655
+ } else if (loginBehavior === 'error') {
656
+ // Events.Error = 'error' in discord.js
657
+ onceListeners['error']?.(new Error(errorMessage))
658
+ }
659
+ })
660
+ }),
661
+ }
662
+
663
+ return { client, onceListeners, onListeners }
664
+ }
665
+
666
+ it('calls client.login with the configured token', async () => {
667
+ const adapter = createAdapter({ token: 'my-test-token' })
668
+ const { client } = buildMockClient()
669
+ ;(adapter as any).client = client
670
+
671
+ await adapter.connect()
672
+
673
+ expect(client.login).toHaveBeenCalledWith('my-test-token')
674
+ })
675
+
676
+ it('sets connected=true after ClientReady fires', async () => {
677
+ const adapter = createAdapter()
678
+ const { client } = buildMockClient()
679
+ ;(adapter as any).client = client
680
+
681
+ await adapter.connect()
682
+
683
+ expect(adapter.isConnected()).toBe(true)
684
+ })
685
+
686
+ it('rejects when client.login throws', async () => {
687
+ const adapter = createAdapter()
688
+ const { client } = buildMockClient({ loginBehavior: 'throw', errorMessage: 'Invalid token' })
689
+ ;(adapter as any).client = client
690
+
691
+ await expect(adapter.connect()).rejects.toThrow('Invalid token')
692
+ })
693
+
694
+ it('rejects when Error event fires before ClientReady', async () => {
695
+ const adapter = createAdapter()
696
+ const { client } = buildMockClient({
697
+ loginBehavior: 'error',
698
+ errorMessage: 'Gateway unavailable',
699
+ })
700
+ ;(adapter as any).client = client
701
+
702
+ await expect(adapter.connect()).rejects.toThrow('Gateway unavailable')
703
+ })
704
+
705
+ it('calls setupMessageListener and setupLifecycleListeners on ready', async () => {
706
+ const adapter = createAdapter()
707
+ const { client, onListeners } = buildMockClient()
708
+ ;(adapter as any).client = client
709
+
710
+ await adapter.connect()
711
+
712
+ // setupMessageListener registers on messageCreate
713
+ // setupLifecycleListeners registers on error, warn, shardDisconnect, shardReconnecting, shardResume, shardReady
714
+ expect(onListeners).toContain('messageCreate')
715
+ expect(onListeners).toContain('error')
716
+ expect(onListeners).toContain('warn')
717
+ expect(onListeners).toContain('shardDisconnect')
718
+ expect(onListeners).toContain('shardReconnecting')
719
+ expect(onListeners).toContain('shardResume')
720
+ expect(onListeners).toContain('shardReady')
721
+ })
722
+ })
723
+
724
+ // ═══════════════════════════════════════════════════════════════════
725
+ // setupMessageListener — Message filtering logic
726
+ // ═══════════════════════════════════════════════════════════════════
727
+
728
+ describe('DiscordChannel — message filtering (setupMessageListener)', () => {
729
+ function setupAdapterWithListener(overrides: Partial<DiscordChannelOptions> = {}) {
730
+ const adapter = createAdapter(overrides)
731
+ const handler = mock(async (_msg: any) => {})
732
+ adapter.onMessage(handler)
733
+
734
+ // Manually call setupMessageListener
735
+ const messageListeners: Record<string, Function> = {}
736
+ ;(adapter as any).client = {
737
+ on: mock((event: string, fn: Function) => {
738
+ messageListeners[event] = fn
739
+ }),
740
+ }
741
+ ;(adapter as any).setupMessageListener()
742
+
743
+ return { adapter, handler, fireMessage: messageListeners['messageCreate'] }
744
+ }
745
+
746
+ function fakeMessage(
747
+ overrides: Partial<{
748
+ bot: boolean
749
+ guildId: string
750
+ channelId: string
751
+ authorId: string
752
+ content: string
753
+ attachmentSize: number
754
+ }> = {},
755
+ ) {
756
+ return {
757
+ id: 'msg_test_123',
758
+ author: {
759
+ bot: overrides.bot ?? false,
760
+ id: overrides.authorId ?? 'user_1',
761
+ displayName: 'TestUser',
762
+ username: 'testuser',
763
+ },
764
+ guildId: overrides.guildId ?? 'guild_123',
765
+ channelId: overrides.channelId ?? 'channel_1',
766
+ content: overrides.content ?? 'Hello!',
767
+ attachments: {
768
+ size: overrides.attachmentSize ?? 0,
769
+ map: (fn: any) => [],
770
+ },
771
+ member: { displayName: 'TestMember' },
772
+ reference: undefined,
773
+ createdAt: new Date(),
774
+ }
775
+ }
776
+
777
+ it('ignores bot messages', async () => {
778
+ const { handler, fireMessage } = setupAdapterWithListener()
779
+
780
+ await fireMessage(fakeMessage({ bot: true }))
781
+
782
+ expect(handler).not.toHaveBeenCalled()
783
+ })
784
+
785
+ it('ignores messages from wrong guild', async () => {
786
+ const { handler, fireMessage } = setupAdapterWithListener({ guildId: 'guild_123' })
787
+
788
+ await fireMessage(fakeMessage({ guildId: 'guild_999' }))
789
+
790
+ expect(handler).not.toHaveBeenCalled()
791
+ })
792
+
793
+ it('passes messages from the correct guild', async () => {
794
+ const { handler, fireMessage } = setupAdapterWithListener({ guildId: 'guild_123' })
795
+
796
+ await fireMessage(fakeMessage({ guildId: 'guild_123' }))
797
+
798
+ expect(handler).toHaveBeenCalledTimes(1)
799
+ })
800
+
801
+ it('filters by allowedChannels when set', async () => {
802
+ const { handler, fireMessage } = setupAdapterWithListener({
803
+ allowedChannels: ['channel_A', 'channel_B'],
804
+ })
805
+
806
+ // Blocked channel
807
+ await fireMessage(fakeMessage({ channelId: 'channel_X' }))
808
+ expect(handler).not.toHaveBeenCalled()
809
+
810
+ // Allowed channel
811
+ await fireMessage(fakeMessage({ channelId: 'channel_A' }))
812
+ expect(handler).toHaveBeenCalledTimes(1)
813
+ })
814
+
815
+ it('allows all channels when allowedChannels is empty', async () => {
816
+ const { handler, fireMessage } = setupAdapterWithListener({
817
+ allowedChannels: [],
818
+ })
819
+
820
+ await fireMessage(fakeMessage({ channelId: 'any_channel' }))
821
+ expect(handler).toHaveBeenCalledTimes(1)
822
+ })
823
+
824
+ it('filters by allowedUsers when set', async () => {
825
+ const { handler, fireMessage } = setupAdapterWithListener({
826
+ allowedUsers: ['user_mars'],
827
+ })
828
+
829
+ // Blocked user
830
+ await fireMessage(fakeMessage({ authorId: 'user_random' }))
831
+ expect(handler).not.toHaveBeenCalled()
832
+
833
+ // Allowed user
834
+ await fireMessage(fakeMessage({ authorId: 'user_mars' }))
835
+ expect(handler).toHaveBeenCalledTimes(1)
836
+ })
837
+
838
+ it('allows all users when allowedUsers is empty', async () => {
839
+ const { handler, fireMessage } = setupAdapterWithListener({
840
+ allowedUsers: [],
841
+ })
842
+
843
+ await fireMessage(fakeMessage({ authorId: 'any_user' }))
844
+ expect(handler).toHaveBeenCalledTimes(1)
845
+ })
846
+
847
+ it('ignores empty messages with no attachments', async () => {
848
+ const { handler, fireMessage } = setupAdapterWithListener()
849
+
850
+ await fireMessage(fakeMessage({ content: '', attachmentSize: 0 }))
851
+ expect(handler).not.toHaveBeenCalled()
852
+
853
+ await fireMessage(fakeMessage({ content: ' ', attachmentSize: 0 }))
854
+ expect(handler).not.toHaveBeenCalled()
855
+ })
856
+
857
+ it('accepts empty content when attachments are present', async () => {
858
+ const { handler, fireMessage } = setupAdapterWithListener()
859
+
860
+ await fireMessage(fakeMessage({ content: '', attachmentSize: 1 }))
861
+ expect(handler).toHaveBeenCalledTimes(1)
862
+ })
863
+
864
+ it('builds correct IncomingMessage from Discord message', async () => {
865
+ const { handler, fireMessage } = setupAdapterWithListener()
866
+
867
+ const msg = fakeMessage({ content: 'Test message', authorId: 'user_42' })
868
+ msg.reference = { messageId: 'reply_ref_123' } as any
869
+
870
+ await fireMessage(msg)
871
+
872
+ expect(handler).toHaveBeenCalledTimes(1)
873
+ const incoming = handler.mock.calls[0]![0] as any
874
+ expect(incoming.id).toBe('msg_test_123')
875
+ expect(incoming.sender.id).toBe('user_42')
876
+ expect(incoming.sender.name).toBe('TestMember')
877
+ expect(incoming.sender.username).toBe('testuser')
878
+ expect(incoming.text).toBe('Test message')
879
+ expect(incoming.replyTo).toBe('reply_ref_123')
880
+ expect(incoming.raw).toBe(msg)
881
+ })
882
+
883
+ it('catches errors from message handler without crashing', async () => {
884
+ const adapter = createAdapter()
885
+ const errorHandler = mock(async () => {
886
+ throw new Error('Handler exploded')
887
+ })
888
+ adapter.onMessage(errorHandler)
889
+
890
+ const messageListeners: Record<string, Function> = {}
891
+ ;(adapter as any).client = {
892
+ on: mock((event: string, fn: Function) => {
893
+ messageListeners[event] = fn
894
+ }),
895
+ }
896
+ ;(adapter as any).setupMessageListener()
897
+
898
+ // Should not throw even though handler errors
899
+ await messageListeners['messageCreate'](fakeMessage())
900
+
901
+ expect(errorHandler).toHaveBeenCalledTimes(1)
902
+ })
903
+
904
+ it('does nothing when no handler is registered', async () => {
905
+ const adapter = createAdapter()
906
+ // Do NOT register any handler via onMessage()
907
+
908
+ const messageListeners: Record<string, Function> = {}
909
+ ;(adapter as any).client = {
910
+ on: mock((event: string, fn: Function) => {
911
+ messageListeners[event] = fn
912
+ }),
913
+ }
914
+ ;(adapter as any).setupMessageListener()
915
+
916
+ // Should not throw when no handler is set
917
+ await messageListeners['messageCreate'](fakeMessage())
918
+ })
919
+ })