agent-messenger 2.38.0 → 2.39.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.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/discordbot/client-message.d.ts +15 -0
  4. package/dist/src/platforms/discordbot/client-message.d.ts.map +1 -0
  5. package/dist/src/platforms/discordbot/client-message.js +70 -0
  6. package/dist/src/platforms/discordbot/client-message.js.map +1 -0
  7. package/dist/src/platforms/discordbot/client-threads.d.ts +10 -0
  8. package/dist/src/platforms/discordbot/client-threads.d.ts.map +1 -0
  9. package/dist/src/platforms/discordbot/client-threads.js +31 -0
  10. package/dist/src/platforms/discordbot/client-threads.js.map +1 -0
  11. package/dist/src/platforms/discordbot/client.d.ts +14 -2
  12. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  13. package/dist/src/platforms/discordbot/client.js +21 -28
  14. package/dist/src/platforms/discordbot/client.js.map +1 -1
  15. package/dist/src/platforms/discordbot/commands/file.d.ts +14 -10
  16. package/dist/src/platforms/discordbot/commands/file.d.ts.map +1 -1
  17. package/dist/src/platforms/discordbot/commands/file.js +58 -24
  18. package/dist/src/platforms/discordbot/commands/file.js.map +1 -1
  19. package/dist/src/platforms/discordbot/commands/message.d.ts +3 -9
  20. package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
  21. package/dist/src/platforms/discordbot/commands/message.js +9 -51
  22. package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
  23. package/dist/src/platforms/discordbot/commands/reaction.d.ts +9 -0
  24. package/dist/src/platforms/discordbot/commands/reaction.d.ts.map +1 -1
  25. package/dist/src/platforms/discordbot/commands/reaction.js +39 -0
  26. package/dist/src/platforms/discordbot/commands/reaction.js.map +1 -1
  27. package/dist/src/platforms/discordbot/commands/shared.d.ts +36 -0
  28. package/dist/src/platforms/discordbot/commands/shared.d.ts.map +1 -1
  29. package/dist/src/platforms/discordbot/commands/shared.js +36 -0
  30. package/dist/src/platforms/discordbot/commands/shared.js.map +1 -1
  31. package/dist/src/platforms/discordbot/commands/thread.d.ts +14 -0
  32. package/dist/src/platforms/discordbot/commands/thread.d.ts.map +1 -1
  33. package/dist/src/platforms/discordbot/commands/thread.js +40 -0
  34. package/dist/src/platforms/discordbot/commands/thread.js.map +1 -1
  35. package/dist/src/platforms/discordbot/index.d.ts +1 -1
  36. package/dist/src/platforms/discordbot/index.d.ts.map +1 -1
  37. package/dist/src/platforms/discordbot/index.js.map +1 -1
  38. package/dist/src/platforms/discordbot/types.d.ts +68 -17
  39. package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
  40. package/dist/src/platforms/discordbot/types.js +20 -16
  41. package/dist/src/platforms/discordbot/types.js.map +1 -1
  42. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  43. package/dist/src/platforms/kakaotalk/client.js +4 -32
  44. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  45. package/dist/src/platforms/kakaotalk/sync-state-store.d.ts +11 -0
  46. package/dist/src/platforms/kakaotalk/sync-state-store.d.ts.map +1 -0
  47. package/dist/src/platforms/kakaotalk/sync-state-store.js +89 -0
  48. package/dist/src/platforms/kakaotalk/sync-state-store.js.map +1 -0
  49. package/dist/src/platforms/slack/cli.d.ts.map +1 -1
  50. package/dist/src/platforms/slack/cli.js +5 -0
  51. package/dist/src/platforms/slack/cli.js.map +1 -1
  52. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  53. package/dist/src/platforms/slack/commands/auth.js +9 -3
  54. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  55. package/dist/src/platforms/slack/credential-manager.d.ts +2 -0
  56. package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
  57. package/dist/src/platforms/slack/credential-manager.js +17 -3
  58. package/dist/src/platforms/slack/credential-manager.js.map +1 -1
  59. package/docs/content/docs/cli/discordbot.mdx +60 -27
  60. package/docs/content/docs/cli/slack.mdx +8 -1
  61. package/docs/content/docs/sdk/discordbot.mdx +101 -3
  62. package/e2e/README.md +3 -1
  63. package/e2e/config.ts +1 -1
  64. package/e2e/discordbot.e2e.test.ts +206 -11
  65. package/package.json +1 -1
  66. package/skills/agent-channeltalk/SKILL.md +1 -1
  67. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  68. package/skills/agent-discord/SKILL.md +1 -1
  69. package/skills/agent-discordbot/SKILL.md +57 -11
  70. package/skills/agent-discordbot/references/authentication.md +13 -11
  71. package/skills/agent-discordbot/references/common-patterns.md +36 -8
  72. package/skills/agent-imessage/SKILL.md +1 -1
  73. package/skills/agent-instagram/SKILL.md +1 -1
  74. package/skills/agent-kakaotalk/SKILL.md +1 -1
  75. package/skills/agent-line/SKILL.md +1 -1
  76. package/skills/agent-slack/SKILL.md +9 -2
  77. package/skills/agent-slack/references/authentication.md +12 -0
  78. package/skills/agent-slack/references/common-patterns.md +18 -0
  79. package/skills/agent-slackbot/SKILL.md +1 -1
  80. package/skills/agent-teams/SKILL.md +1 -1
  81. package/skills/agent-telegram/SKILL.md +1 -1
  82. package/skills/agent-telegrambot/SKILL.md +1 -1
  83. package/skills/agent-webex/SKILL.md +1 -1
  84. package/skills/agent-webexbot/SKILL.md +1 -1
  85. package/skills/agent-wechatbot/SKILL.md +1 -1
  86. package/skills/agent-whatsapp/SKILL.md +1 -1
  87. package/skills/agent-whatsappbot/SKILL.md +1 -1
  88. package/src/platforms/discordbot/client-message.ts +91 -0
  89. package/src/platforms/discordbot/client-threads.test.ts +177 -0
  90. package/src/platforms/discordbot/client-threads.ts +69 -0
  91. package/src/platforms/discordbot/client.test.ts +277 -1
  92. package/src/platforms/discordbot/client.ts +60 -40
  93. package/src/platforms/discordbot/commands/file.test.ts +242 -90
  94. package/src/platforms/discordbot/commands/file.ts +78 -36
  95. package/src/platforms/discordbot/commands/message.test.ts +183 -26
  96. package/src/platforms/discordbot/commands/message.ts +13 -61
  97. package/src/platforms/discordbot/commands/reaction.test.ts +73 -1
  98. package/src/platforms/discordbot/commands/reaction.ts +46 -0
  99. package/src/platforms/discordbot/commands/shared.test.ts +98 -0
  100. package/src/platforms/discordbot/commands/shared.ts +67 -0
  101. package/src/platforms/discordbot/commands/thread.test.ts +58 -1
  102. package/src/platforms/discordbot/commands/thread.ts +58 -0
  103. package/src/platforms/discordbot/index.ts +3 -0
  104. package/src/platforms/discordbot/types.test.ts +46 -0
  105. package/src/platforms/discordbot/types.ts +44 -19
  106. package/src/platforms/kakaotalk/client-listener-integration.test.ts +21 -3
  107. package/src/platforms/kakaotalk/client.test.ts +42 -4
  108. package/src/platforms/kakaotalk/client.ts +4 -39
  109. package/src/platforms/kakaotalk/sync-state-store.test.ts +197 -0
  110. package/src/platforms/kakaotalk/sync-state-store.ts +99 -0
  111. package/src/platforms/slack/cli.test.ts +111 -1
  112. package/src/platforms/slack/cli.ts +5 -0
  113. package/src/platforms/slack/commands/auth.ts +14 -3
  114. package/src/platforms/slack/credential-manager.ts +21 -3
  115. package/src/platforms/slack/credential-manager.workspace-select.test.ts +70 -0
@@ -47,6 +47,21 @@ describe('DiscordBotClient', () => {
47
47
  )
48
48
  }
49
49
 
50
+ const messagePage = (startIndex: number, count: number) =>
51
+ Array.from({ length: count }, (_, offset) => {
52
+ const index = startIndex + offset
53
+ return {
54
+ id: `msg${index}`,
55
+ channel_id: 'ch1',
56
+ author: { id: '123', username: 'user1' },
57
+ content: '',
58
+ timestamp: '2024-01-01T00:00:00.000Z',
59
+ attachments: [
60
+ { id: `att${index}`, filename: `file${index}.txt`, size: 10, url: `https://example.com/file${index}.txt` },
61
+ ],
62
+ }
63
+ })
64
+
50
65
  describe('constructor', () => {
51
66
  it('requires token', async () => {
52
67
  await expect(new DiscordBotClient().login({ token: '' })).rejects.toThrow(DiscordBotError)
@@ -89,6 +104,35 @@ describe('DiscordBotClient', () => {
89
104
  })
90
105
  })
91
106
 
107
+ describe('oversized upload errors', () => {
108
+ it('maps HTTP 413 with an empty response body', async () => {
109
+ fetchResponses.push(new Response(null, { status: 413 }))
110
+
111
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
112
+ await expect(client.testAuth()).rejects.toMatchObject({
113
+ code: 'http_413',
114
+ message: 'File(s) too large for this server upload limit (Discord error 40005)',
115
+ })
116
+ })
117
+
118
+ it('maps Discord error 40005 to the actionable upload message', async () => {
119
+ mockResponse({ code: 40005, message: 'Request entity too large' }, 400)
120
+
121
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
122
+ await expect(client.testAuth()).rejects.toMatchObject({
123
+ code: '40005',
124
+ message: 'File(s) too large for this server upload limit (Discord error 40005)',
125
+ })
126
+ })
127
+
128
+ it('preserves unrelated permission errors', async () => {
129
+ mockResponse({ code: 50013, message: 'Missing Permissions' }, 403)
130
+
131
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
132
+ await expect(client.testAuth()).rejects.toMatchObject({ code: '50013', message: 'Missing Permissions' })
133
+ })
134
+ })
135
+
92
136
  describe('listGuilds', () => {
93
137
  it('returns list of guilds', async () => {
94
138
  mockResponse([
@@ -165,7 +209,8 @@ describe('DiscordBotClient', () => {
165
209
  const client = await new DiscordBotClient().login({ token: 'bot-token' })
166
210
  await client.sendMessage('ch1', 'Thread reply', { thread_id: 'thread123' })
167
211
 
168
- expect(fetchCalls[0].options?.body).toBe(JSON.stringify({ content: 'Thread reply', thread_id: 'thread123' }))
212
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/thread123/messages')
213
+ expect(fetchCalls[0].options?.body).toBe(JSON.stringify({ content: 'Thread reply' }))
169
214
  })
170
215
 
171
216
  it('includes message_reference when reply_to is provided', async () => {
@@ -186,6 +231,179 @@ describe('DiscordBotClient', () => {
186
231
  })
187
232
  })
188
233
 
234
+ describe('createMessage', () => {
235
+ it('sends files with text as multipart form data', async () => {
236
+ const { tmpdir } = await import('node:os')
237
+ const { join } = await import('node:path')
238
+ const tempFile = join(tmpdir(), 'report.pdf')
239
+ await Bun.write(tempFile, 'report contents')
240
+
241
+ mockResponse({
242
+ id: 'msg1',
243
+ channel_id: 'ch1',
244
+ author: { id: '123', username: 'bot' },
245
+ content: 'see attached',
246
+ timestamp: '2024-01-01T00:00:00.000Z',
247
+ attachments: [{ id: 'att1', filename: 'report.pdf', size: 15, url: 'https://example.com/report.pdf' }],
248
+ })
249
+
250
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
251
+ await client.createMessage('ch1', { content: 'see attached', files: [{ path: tempFile }] })
252
+
253
+ const body = fetchCalls[0].options?.body
254
+ expect(body).toBeInstanceOf(FormData)
255
+ const formData = body as FormData
256
+ expect(JSON.parse(formData.get('payload_json') as string)).toEqual({
257
+ content: 'see attached',
258
+ attachments: [{ id: 0, filename: 'report.pdf' }],
259
+ })
260
+ expect((formData.get('files[0]') as File).name).toBe('report.pdf')
261
+ const headers = fetchCalls[0].options?.headers as Record<string, string> | undefined
262
+ expect(headers?.['Content-Type']).toBeUndefined()
263
+ })
264
+
265
+ it('preserves indexed multipart wire format for multiple files', async () => {
266
+ const { tmpdir } = await import('node:os')
267
+ const { join } = await import('node:path')
268
+ const firstPath = join(tmpdir(), 'a.txt')
269
+ const secondPath = join(tmpdir(), 'b.txt')
270
+ await Bun.write(firstPath, 'alpha')
271
+ await Bun.write(secondPath, 'bravo!')
272
+ mockResponse({ attachments: [] })
273
+
274
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
275
+ await client.createMessage('ch1', {
276
+ content: 'two files',
277
+ files: [{ path: firstPath }, { path: secondPath }],
278
+ })
279
+
280
+ const body = fetchCalls[0].options?.body
281
+ expect(body).toBeInstanceOf(FormData)
282
+ const formData = body as FormData
283
+ expect(JSON.parse(formData.get('payload_json') as string)).toEqual({
284
+ content: 'two files',
285
+ attachments: [
286
+ { id: 0, filename: 'a.txt' },
287
+ { id: 1, filename: 'b.txt' },
288
+ ],
289
+ })
290
+ expect((formData.get('files[0]') as File).name).toBe('a.txt')
291
+ expect((formData.get('files[0]') as File).size).toBe(5)
292
+ expect((formData.get('files[1]') as File).name).toBe('b.txt')
293
+ expect((formData.get('files[1]') as File).size).toBe(6)
294
+ const headers = fetchCalls[0].options?.headers as Record<string, string> | undefined
295
+ expect(headers?.['Content-Type']).toBeUndefined()
296
+ })
297
+
298
+ it('normalizes Windows-style implicit filenames', async () => {
299
+ const { tmpdir } = await import('node:os')
300
+ const { join } = await import('node:path')
301
+ const tempFile = join(tmpdir(), 'C:\\dir\\x.txt')
302
+ await Bun.write(tempFile, 'content')
303
+ mockResponse({ attachments: [{ id: 'att1', filename: 'x.txt', size: 7, url: 'https://example.com' }] })
304
+
305
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
306
+ await client.createMessage('ch1', { files: [{ path: tempFile }] })
307
+
308
+ const formData = fetchCalls[0].options?.body as FormData
309
+ expect(JSON.parse(formData.get('payload_json') as string).attachments[0].filename).toBe('x.txt')
310
+ expect((formData.get('files[0]') as File).name).toBe('x.txt')
311
+ })
312
+
313
+ it('uses a filename override for multipart parts', async () => {
314
+ const { tmpdir } = await import('node:os')
315
+ const { join } = await import('node:path')
316
+ const tempFile = join(tmpdir(), 'test-discordbot-create-message-original.txt')
317
+ await Bun.write(tempFile, 'content')
318
+ mockResponse({ attachments: [{ id: 'att1', filename: 'renamed.txt', size: 7, url: 'https://example.com' }] })
319
+
320
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
321
+ await client.createMessage('ch1', { files: [{ path: tempFile, filename: 'renamed.txt' }] })
322
+
323
+ const formData = fetchCalls[0].options?.body as FormData
324
+ expect(JSON.parse(formData.get('payload_json') as string).attachments[0].filename).toBe('renamed.txt')
325
+ expect((formData.get('files[0]') as File).name).toBe('renamed.txt')
326
+ })
327
+
328
+ it('rejects invalid filenames before fetching', async () => {
329
+ const invalidFilenames = ['../evil', 'a/b', 'a\\b', '.', '..', '']
330
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
331
+
332
+ for (const filename of invalidFilenames) {
333
+ await expect(
334
+ client.createMessage('ch1', { content: 'x', files: [{ path: '/tmp/file', filename }] }),
335
+ ).rejects.toMatchObject({ code: 'invalid_filename', message: 'Invalid filename' })
336
+ }
337
+ expect(fetchCalls).toHaveLength(0)
338
+ })
339
+
340
+ it('rejects more than 10 files before fetching', async () => {
341
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
342
+ const files = Array.from({ length: 11 }, (_, index) => ({ path: `/tmp/file-${index}` }))
343
+
344
+ await expect(client.createMessage('ch1', { files })).rejects.toMatchObject({
345
+ code: 'too_many_files',
346
+ message: 'Discord allows at most 10 files per message',
347
+ })
348
+ expect(fetchCalls).toHaveLength(0)
349
+ })
350
+
351
+ it('rejects an empty message before fetching', async () => {
352
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
353
+
354
+ await expect(client.createMessage('ch1', { content: ' ' })).rejects.toMatchObject({
355
+ code: 'empty_message',
356
+ message: 'Message must have content or at least one file',
357
+ })
358
+ expect(fetchCalls).toHaveLength(0)
359
+ })
360
+
361
+ it('propagates missing file errors', async () => {
362
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
363
+
364
+ await expect(
365
+ client.createMessage('ch1', { content: 'x', files: [{ path: '/tmp/does-not-exist-ulw' }] }),
366
+ ).rejects.toThrow(/ENOENT/)
367
+ expect(fetchCalls).toHaveLength(0)
368
+ })
369
+
370
+ it('posts file messages into the requested thread', async () => {
371
+ const { tmpdir } = await import('node:os')
372
+ const { join } = await import('node:path')
373
+ const tempFile = join(tmpdir(), 'test-discordbot-thread-file.txt')
374
+ await Bun.write(tempFile, 'content')
375
+ mockResponse({
376
+ attachments: [{ id: 'att1', filename: 'test-discordbot-thread-file.txt', size: 7, url: 'https://example.com' }],
377
+ })
378
+
379
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
380
+ await client.createMessage('parent', { content: 'x', thread_id: 'thread123', files: [{ path: tempFile }] })
381
+
382
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/thread123/messages')
383
+ })
384
+
385
+ it('sends replies and files together', async () => {
386
+ const { tmpdir } = await import('node:os')
387
+ const { join } = await import('node:path')
388
+ const tempFile = join(tmpdir(), 'test-discordbot-reply-file.txt')
389
+ await Bun.write(tempFile, 'content')
390
+ mockResponse({
391
+ attachments: [{ id: 'att1', filename: 'test-discordbot-reply-file.txt', size: 7, url: 'https://example.com' }],
392
+ })
393
+
394
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
395
+ await client.createMessage('ch1', { content: 'reply', reply_to: 'parent123', files: [{ path: tempFile }] })
396
+
397
+ const formData = fetchCalls[0].options?.body as FormData
398
+ expect(JSON.parse(formData.get('payload_json') as string)).toEqual({
399
+ content: 'reply',
400
+ message_reference: { message_id: 'parent123' },
401
+ attachments: [{ id: 0, filename: 'test-discordbot-reply-file.txt' }],
402
+ })
403
+ expect(formData.get('files[0]')).toBeTruthy()
404
+ })
405
+ })
406
+
189
407
  describe('editMessage', () => {
190
408
  it('edits message with PATCH', async () => {
191
409
  mockResponse({
@@ -355,6 +573,64 @@ describe('DiscordBotClient', () => {
355
573
  expect(files).toHaveLength(1)
356
574
  expect(files[0].filename).toBe('file1.txt')
357
575
  })
576
+
577
+ it('requests a single page even when the page is full', async () => {
578
+ mockResponse(messagePage(0, 100))
579
+
580
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
581
+ const files = await client.listFiles('ch1')
582
+
583
+ expect(files).toHaveLength(100)
584
+ expect(fetchCalls).toHaveLength(1)
585
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?limit=100')
586
+ })
587
+ })
588
+
589
+ describe('findFile', () => {
590
+ it('stops after the first page when the file is in the newest messages', async () => {
591
+ mockResponse(messagePage(0, 100))
592
+
593
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
594
+ const file = await client.findFile('ch1', 'att0')
595
+
596
+ expect(file?.filename).toBe('file0.txt')
597
+ expect(fetchCalls).toHaveLength(1)
598
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?limit=100')
599
+ })
600
+
601
+ it('follows the before cursor to reach an older page', async () => {
602
+ mockResponse(messagePage(0, 100))
603
+ mockResponse(messagePage(100, 40))
604
+
605
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
606
+ const file = await client.findFile('ch1', 'att120')
607
+
608
+ expect(file?.filename).toBe('file120.txt')
609
+ expect(fetchCalls).toHaveLength(2)
610
+ expect(fetchCalls[1].url).toBe('https://discord.com/api/v10/channels/ch1/messages?limit=100&before=msg99')
611
+ })
612
+
613
+ it('stops at the end of the channel history when the file is missing', async () => {
614
+ mockResponse(messagePage(0, 100))
615
+ mockResponse(messagePage(100, 3))
616
+
617
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
618
+ const file = await client.findFile('ch1', 'missing')
619
+
620
+ expect(file).toBeUndefined()
621
+ expect(fetchCalls).toHaveLength(2)
622
+ })
623
+
624
+ it('gives up after the bounded page budget', async () => {
625
+ for (let page = 0; page < 12; page += 1) mockResponse(messagePage(page * 100, 100))
626
+
627
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
628
+ const file = await client.findFile('ch1', 'missing')
629
+
630
+ expect(file).toBeUndefined()
631
+ expect(fetchCalls).toHaveLength(10)
632
+ expect(fetchCalls[9].url).toBe('https://discord.com/api/v10/channels/ch1/messages?limit=100&before=msg899')
633
+ })
358
634
  })
359
635
 
360
636
  describe('createThread', () => {
@@ -1,6 +1,18 @@
1
- import { readFile } from 'node:fs/promises'
2
-
3
- import type { DiscordChannel, DiscordFile, DiscordGuild, DiscordMessage, DiscordUser } from './types'
1
+ import {
2
+ createMessage as createMessageHelper,
3
+ findFile as findFileHelper,
4
+ listFiles as listFilesHelper,
5
+ uploadFile as uploadFileHelper,
6
+ } from './client-message'
7
+ import { listThreads as listThreadsHelper, resolveThread as resolveThreadHelper } from './client-threads'
8
+ import type {
9
+ DiscordChannel,
10
+ DiscordCreateMessageOptions,
11
+ DiscordFile,
12
+ DiscordGuild,
13
+ DiscordMessage,
14
+ DiscordUser,
15
+ } from './types'
4
16
  import { DiscordBotError } from './types'
5
17
 
6
18
  const BASE_URL = 'https://discord.com/api/v10'
@@ -105,6 +117,13 @@ export class DiscordBotClient {
105
117
  }
106
118
 
107
119
  private handleErrorResponse(response: Response, errorBody: Record<string, string | number>): never {
120
+ if (errorBody.code === 40005 || response.status === 413) {
121
+ throw new DiscordBotError(
122
+ 'File(s) too large for this server upload limit (Discord error 40005)',
123
+ errorBody.code?.toString() ?? 'http_413',
124
+ )
125
+ }
126
+
108
127
  throw new DiscordBotError(
109
128
  (errorBody.message as string) || `HTTP ${response.status}`,
110
129
  errorBody.code?.toString() || `http_${response.status}`,
@@ -249,19 +268,21 @@ export class DiscordBotClient {
249
268
  return this.request<DiscordChannel>('GET', `/channels/${channelId}`)
250
269
  }
251
270
 
271
+ async createMessage(channelId: string, options: DiscordCreateMessageOptions): Promise<DiscordMessage> {
272
+ return createMessageHelper(
273
+ (method, path, body) => this.request(method, path, body),
274
+ (path, formData) => this.requestFormData(path, formData),
275
+ channelId,
276
+ options,
277
+ )
278
+ }
279
+
252
280
  async sendMessage(
253
281
  channelId: string,
254
282
  content: string,
255
283
  options?: { thread_id?: string; reply_to?: string },
256
284
  ): Promise<DiscordMessage> {
257
- const body: Record<string, unknown> = { content }
258
- if (options?.thread_id) {
259
- body.thread_id = options.thread_id
260
- }
261
- if (options?.reply_to) {
262
- body.message_reference = { message_id: options.reply_to }
263
- }
264
- return this.request<DiscordMessage>('POST', `/channels/${channelId}/messages`, body)
285
+ return this.createMessage(channelId, { content, thread_id: options?.thread_id, reply_to: options?.reply_to })
265
286
  }
266
287
 
267
288
  async getMessages(channelId: string, limit: number = 50): Promise<DiscordMessage[]> {
@@ -302,39 +323,25 @@ export class DiscordBotClient {
302
323
  return this.request<DiscordUser>('GET', `/users/${userId}`)
303
324
  }
304
325
 
305
- async uploadFile(channelId: string, filePath: string): Promise<DiscordFile> {
306
- const fileBuffer = await readFile(filePath)
307
- const filename = filePath.split('/').pop() || 'file'
308
-
309
- const formData = new FormData()
310
- formData.append('files[0]', new Blob([fileBuffer]), filename)
311
-
312
- interface MessageWithAttachments extends DiscordMessage {
313
- attachments: DiscordFile[]
314
- }
315
- const message = await this.requestFormData<MessageWithAttachments>(`/channels/${channelId}/messages`, formData)
316
-
317
- const first = message.attachments?.[0]
318
- if (!first) {
319
- throw new DiscordBotError('Upload succeeded but no attachments returned', 'no_attachments')
320
- }
321
-
322
- return first
326
+ async uploadFile(
327
+ channelId: string,
328
+ filePath: string,
329
+ options?: { content?: string; filename?: string; reply_to?: string; thread_id?: string },
330
+ ): Promise<DiscordFile> {
331
+ return uploadFileHelper(
332
+ (id, messageOptions) => this.createMessage(id, messageOptions),
333
+ channelId,
334
+ filePath,
335
+ options,
336
+ )
323
337
  }
324
338
 
325
339
  async listFiles(channelId: string): Promise<DiscordFile[]> {
326
- interface MessageWithAttachments extends DiscordMessage {
327
- attachments: DiscordFile[]
328
- }
329
- const messages = await this.request<MessageWithAttachments[]>('GET', `/channels/${channelId}/messages?limit=100`)
340
+ return listFilesHelper((method, path, body) => this.request(method, path, body), channelId)
341
+ }
330
342
 
331
- const files: DiscordFile[] = []
332
- for (const msg of messages) {
333
- if (msg.attachments && msg.attachments.length > 0) {
334
- files.push(...msg.attachments)
335
- }
336
- }
337
- return files
343
+ async findFile(channelId: string, fileId: string): Promise<DiscordFile | undefined> {
344
+ return findFileHelper((method, path, body) => this.request(method, path, body), channelId, fileId)
338
345
  }
339
346
 
340
347
  async createThread(
@@ -372,4 +379,17 @@ export class DiscordBotClient {
372
379
  }
373
380
  return found.id
374
381
  }
382
+
383
+ async listThreads(guildId: string, options?: { parentId?: string; archived?: boolean }) {
384
+ return listThreadsHelper({ request: (method, path, body) => this.request(method, path, body) }, guildId, options)
385
+ }
386
+
387
+ async resolveThread(guildId: string, parentChannelId: string, thread: string) {
388
+ return resolveThreadHelper(
389
+ { request: (method, path, body) => this.request(method, path, body) },
390
+ guildId,
391
+ parentChannelId,
392
+ thread,
393
+ )
394
+ }
375
395
  }