@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/adapter.test.ts +919 -0
- package/src/__tests__/formatter.test.ts +219 -0
- package/src/adapter.ts +254 -0
- package/src/formatter.ts +58 -0
- package/src/index.ts +2 -0
|
@@ -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
|
+
})
|