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
@@ -1,15 +1,95 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { existsSync, rmSync } from 'node:fs'
3
+ import { mkdir, readFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { basename, join } from 'node:path'
6
+
7
+ import type { DiscordCreateMessageOptions, DiscordFile, DiscordMessage } from '../types'
8
+
9
+ const mockCreateMessage = mock(
10
+ async (channelId: string, options: DiscordCreateMessageOptions): Promise<DiscordMessage> => {
11
+ const files = options.files ?? []
12
+ const sizes = await Promise.all(files.map(async (file) => (await readFile(file.path)).length))
13
+ return {
14
+ id: 'msg-upload',
15
+ channel_id: channelId,
16
+ author: { id: 'bot-123', username: 'bot' },
17
+ content: options.content ?? '',
18
+ timestamp: new Date().toISOString(),
19
+ attachments: files.map((file, index) => ({
20
+ id: `att${index + 1}`,
21
+ filename: file.filename ?? basename(file.path),
22
+ size: sizes[index],
23
+ url: `https://cdn.discord.com/attachments/${channelId}/${file.filename ?? basename(file.path)}`,
24
+ })),
25
+ }
26
+ },
27
+ )
28
+
29
+ const mockListFiles = mock(
30
+ (_channelId: string): Promise<DiscordFile[]> =>
31
+ Promise.resolve([
32
+ { id: 'att1', filename: 'test.txt', size: 12, url: 'https://cdn.discord.com/test.txt' },
33
+ {
34
+ id: 'att2',
35
+ filename: 'report.pdf',
36
+ size: 2048,
37
+ url: 'https://cdn.discord.com/report.pdf',
38
+ content_type: 'application/pdf',
39
+ },
40
+ ]),
41
+ )
42
+
43
+ const mockFindFile = mock((_channelId: string, fileId: string): Promise<DiscordFile | undefined> => {
44
+ if (fileId === 'att1') {
45
+ return Promise.resolve({ id: 'att1', filename: 'test.txt', size: 12, url: 'https://cdn.discord.com/test.txt' })
46
+ }
47
+ return Promise.resolve(undefined)
48
+ })
49
+
50
+ const mockResolveChannel = mock((_guildId: string, channel: string): Promise<string> => {
51
+ if (channel === 'general') return Promise.resolve('ch-general')
52
+ if (/^\d+$/.test(channel)) return Promise.resolve(channel)
53
+ return Promise.reject(new Error(`Channel not found: "${channel}". Use channel ID or exact channel name.`))
54
+ })
55
+
56
+ const mockResolveThread = mock((_guildId: string, _parentChannelId: string, thread: string): Promise<string> => {
57
+ if (thread === 'release-discussion') return Promise.resolve('thread-1')
58
+ return Promise.reject(new Error(`Thread not found: "${thread}".`))
59
+ })
60
+
61
+ mock.module('../client', () => ({
62
+ DiscordBotClient: class MockDiscordBotClient {
63
+ async login(_credentials?: unknown) {
64
+ return this
65
+ }
66
+ createMessage = mockCreateMessage
67
+ listFiles = mockListFiles
68
+ findFile = mockFindFile
69
+ resolveChannel = mockResolveChannel
70
+ resolveThread = mockResolveThread
71
+ },
72
+ }))
2
73
 
3
74
  import { DiscordBotCredentialManager } from '../credential-manager'
4
- import { listAction, uploadAction } from './file'
75
+ import { infoAction, listAction, uploadAction } from './file'
5
76
  import type { BotOption } from './shared'
6
77
 
7
78
  describe('file commands', () => {
8
79
  let mockCredManager: DiscordBotCredentialManager
9
80
  let options: BotOption
10
- const originalFetch = globalThis.fetch
81
+ let tempDir: string
82
+ let fileA: string
83
+ let fileB: string
84
+
85
+ beforeEach(async () => {
86
+ tempDir = join(tmpdir(), `discordbot-file-test-${Date.now()}`)
87
+ await mkdir(tempDir, { recursive: true })
88
+ fileA = join(tempDir, 'a.txt')
89
+ fileB = join(tempDir, 'b.txt')
90
+ await Bun.write(fileA, 'file-a')
91
+ await Bun.write(fileB, 'file-b')
11
92
 
12
- beforeEach(() => {
13
93
  mockCredManager = {
14
94
  getCurrentServer: mock(async () => 'server-123'),
15
95
  getCredentials: mock(async () => ({
@@ -19,47 +99,11 @@ describe('file commands', () => {
19
99
  })),
20
100
  } as unknown as DiscordBotCredentialManager
21
101
 
22
- ;(globalThis as Record<string, unknown>).fetch = async (url: string | URL | Request): Promise<Response> => {
23
- const urlStr = url.toString()
24
-
25
- if (urlStr.includes('/guilds/') && urlStr.includes('/channels')) {
26
- return new Response(JSON.stringify([{ id: 'ch-general', guild_id: 'server-123', name: 'general', type: 0 }]), {
27
- status: 200,
28
- headers: {
29
- 'Content-Type': 'application/json',
30
- 'X-RateLimit-Remaining': '10',
31
- 'X-RateLimit-Reset': String(Date.now() / 1000 + 60),
32
- 'X-RateLimit-Bucket': 'test-bucket',
33
- },
34
- })
35
- }
36
-
37
- if (urlStr.includes('/channels/') && urlStr.includes('/messages')) {
38
- return new Response(
39
- JSON.stringify([
40
- {
41
- id: 'msg1',
42
- channel_id: 'ch-general',
43
- author: { id: 'bot-123', username: 'bot' },
44
- content: '',
45
- timestamp: new Date().toISOString(),
46
- attachments: [{ id: 'att1', filename: 'test.txt', size: 12, url: 'https://cdn.discord.com/test.txt' }],
47
- },
48
- ]),
49
- {
50
- status: 200,
51
- headers: {
52
- 'Content-Type': 'application/json',
53
- 'X-RateLimit-Remaining': '10',
54
- 'X-RateLimit-Reset': String(Date.now() / 1000 + 60),
55
- 'X-RateLimit-Bucket': 'test-bucket',
56
- },
57
- },
58
- )
59
- }
60
-
61
- return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404 })
62
- }
102
+ mockCreateMessage.mockClear()
103
+ mockListFiles.mockClear()
104
+ mockFindFile.mockClear()
105
+ mockResolveChannel.mockClear()
106
+ mockResolveThread.mockClear()
63
107
 
64
108
  options = {
65
109
  _credManager: mockCredManager,
@@ -67,78 +111,186 @@ describe('file commands', () => {
67
111
  })
68
112
 
69
113
  afterEach(() => {
70
- globalThis.fetch = originalFetch
114
+ if (existsSync(tempDir)) {
115
+ rmSync(tempDir, { recursive: true, force: true })
116
+ }
71
117
  })
72
118
 
73
119
  describe('uploadAction', () => {
74
- it('uploads file successfully', async () => {
75
- const result = await uploadAction('general', './test.txt', {
76
- ...options,
77
- _credManager: mockCredManager,
120
+ it('uploads a file and returns file, files, message_id, and channel_id', async () => {
121
+ const result = await uploadAction('general', [fileA], options)
122
+
123
+ expect(result.error).toBeUndefined()
124
+ expect(result.success).toBe(true)
125
+ expect(result.message_id).toBe('msg-upload')
126
+ expect(result.channel_id).toBe('ch-general')
127
+ expect(result.files).toHaveLength(1)
128
+ expect(result.file).toEqual(result.files?.[0])
129
+ expect(result.file?.filename).toBe('a.txt')
130
+ expect(result.file?.size).toBe(6)
131
+ expect(mockCreateMessage).toHaveBeenCalledWith('ch-general', {
132
+ content: undefined,
133
+ files: [{ path: fileA, filename: undefined }],
134
+ reply_to: undefined,
78
135
  })
136
+ })
137
+
138
+ it('uploads multiple files in one message', async () => {
139
+ const result = await uploadAction('general', [fileA, fileB], options)
79
140
 
80
- expect(result).toBeDefined()
141
+ expect(result.error).toBeUndefined()
142
+ expect(result.files).toHaveLength(2)
143
+ expect(result.files?.map((file) => file.filename)).toEqual(['a.txt', 'b.txt'])
144
+ expect(result.file).toEqual(result.files?.[0])
145
+ expect(mockCreateMessage).toHaveBeenCalledWith('ch-general', {
146
+ content: undefined,
147
+ files: [
148
+ { path: fileA, filename: undefined },
149
+ { path: fileB, filename: undefined },
150
+ ],
151
+ reply_to: undefined,
152
+ })
81
153
  })
82
154
 
83
- it('returns error when channel resolution fails', async () => {
84
- const result = await uploadAction('nonexistent', './test.txt', options)
85
- expect(result.error).toBeDefined()
155
+ it('sends text with the files', async () => {
156
+ const result = await uploadAction('general', [fileA], { ...options, text: 'see attached' })
157
+
158
+ expect(result.error).toBeUndefined()
159
+ expect(mockCreateMessage).toHaveBeenCalledWith('ch-general', {
160
+ content: 'see attached',
161
+ files: [{ path: fileA, filename: undefined }],
162
+ reply_to: undefined,
163
+ })
86
164
  })
87
165
 
88
- it('returns error when file does not exist', async () => {
89
- const result = await uploadAction('general', '/nonexistent/file.txt', {
90
- ...options,
91
- _credManager: mockCredManager,
166
+ it('uploads into a thread by name', async () => {
167
+ const result = await uploadAction('general', [fileA], { ...options, thread: 'release-discussion' })
168
+
169
+ expect(result.error).toBeUndefined()
170
+ expect(mockResolveThread).toHaveBeenCalledWith('server-123', 'ch-general', 'release-discussion')
171
+ expect(mockCreateMessage).toHaveBeenCalledWith('thread-1', {
172
+ content: undefined,
173
+ files: [{ path: fileA, filename: undefined }],
174
+ reply_to: undefined,
92
175
  })
176
+ expect(result.channel_id).toBe('thread-1')
177
+ })
178
+
179
+ it('replies to a message when --reply is given', async () => {
180
+ const result = await uploadAction('general', [fileA], { ...options, reply: 'msg1' })
181
+
182
+ expect(result.error).toBeUndefined()
183
+ expect(mockCreateMessage).toHaveBeenCalledWith('ch-general', {
184
+ content: undefined,
185
+ files: [{ path: fileA, filename: undefined }],
186
+ reply_to: 'msg1',
187
+ })
188
+ })
189
+
190
+ it('rejects --filename with multiple files before any client call', async () => {
191
+ const result = await uploadAction('general', [fileA, fileB], { ...options, filename: 'x.txt' })
192
+
193
+ expect(result.error).toBe('--filename requires exactly one file')
194
+ expect(mockResolveChannel).not.toHaveBeenCalled()
195
+ expect(mockCreateMessage).not.toHaveBeenCalled()
196
+ })
197
+
198
+ it('renames the uploaded part with --filename', async () => {
199
+ const result = await uploadAction('general', [fileA], { ...options, filename: 'renamed.txt' })
200
+
201
+ expect(result.error).toBeUndefined()
202
+ expect(result.file?.filename).toBe('renamed.txt')
203
+ expect(mockCreateMessage).toHaveBeenCalledWith('ch-general', {
204
+ content: undefined,
205
+ files: [{ path: fileA, filename: 'renamed.txt' }],
206
+ reply_to: undefined,
207
+ })
208
+ })
209
+
210
+ it('returns an error when a file does not exist', async () => {
211
+ const result = await uploadAction('general', [join(tempDir, 'missing.txt')], options)
212
+
213
+ expect(result.error).toContain('ENOENT')
214
+ })
215
+
216
+ it('returns an error when more than 10 files are given', async () => {
217
+ mockCreateMessage.mockImplementationOnce(() =>
218
+ Promise.reject(new Error('Discord allows at most 10 files per message')),
219
+ )
220
+ const paths = Array.from({ length: 11 }, (_, i) => join(tempDir, `f${i}.txt`))
221
+ const result = await uploadAction('general', paths, options)
222
+
223
+ expect(result.error).toContain('10')
224
+ })
225
+
226
+ it('returns error when channel resolution fails', async () => {
227
+ const result = await uploadAction('nonexistent', [fileA], options)
228
+
93
229
  expect(result.error).toBeDefined()
230
+ expect(mockCreateMessage).not.toHaveBeenCalled()
94
231
  })
95
232
  })
96
233
 
97
- describe('listAction', () => {
98
- it('lists files successfully', async () => {
99
- const result = await listAction('general', {
100
- ...options,
101
- _credManager: mockCredManager,
102
- })
234
+ describe('infoAction', () => {
235
+ it('returns file info for an existing file without listing the channel', async () => {
236
+ const result = await infoAction('general', 'att1', options)
103
237
 
104
- expect(result).toBeDefined()
238
+ expect(result.error).toBeUndefined()
239
+ expect(result.id).toBe('att1')
240
+ expect(result.filename).toBe('test.txt')
241
+ expect(result.size).toBe(12)
242
+ expect(result.url).toBe('https://cdn.discord.com/test.txt')
243
+ expect(result.content_type).toBeNull()
244
+ expect(mockFindFile).toHaveBeenCalledWith('ch-general', 'att1')
245
+ expect(mockListFiles).not.toHaveBeenCalled()
246
+ })
247
+
248
+ it('returns error when file is not found', async () => {
249
+ const result = await infoAction('general', 'nope', options)
250
+
251
+ expect(result.error).toBe('File not found: nope')
252
+ expect(mockFindFile).toHaveBeenCalledWith('ch-general', 'nope')
105
253
  })
106
254
 
107
255
  it('returns error when channel resolution fails', async () => {
108
- const result = await listAction('nonexistent', options)
256
+ const result = await infoAction('nonexistent', 'att1', options)
257
+
109
258
  expect(result.error).toBeDefined()
259
+ expect(mockFindFile).not.toHaveBeenCalled()
260
+ expect(mockListFiles).not.toHaveBeenCalled()
110
261
  })
111
262
  })
112
263
 
113
- describe('action result structure', () => {
114
- it('returns success result with file info for uploadAction', async () => {
115
- const result = await uploadAction('general', './test.txt', {
116
- ...options,
117
- _credManager: mockCredManager,
118
- })
264
+ describe('listAction', () => {
265
+ it('lists files in channel', async () => {
266
+ const result = await listAction('general', options)
119
267
 
120
- if (!result.error) {
121
- expect(result.success).toBe(true)
122
- expect(result.file).toBeDefined()
123
- if (result.file) {
124
- expect(result.file.id).toBeDefined()
125
- expect(result.file.filename).toBeDefined()
126
- expect(result.file.size).toBeDefined()
127
- expect(result.file.url).toBeDefined()
128
- }
129
- }
268
+ expect(result.error).toBeUndefined()
269
+ expect(result.success).toBe(true)
270
+ expect(mockListFiles).toHaveBeenCalledWith('ch-general')
271
+ expect(mockFindFile).not.toHaveBeenCalled()
272
+ expect(result.files).toEqual([
273
+ {
274
+ id: 'att1',
275
+ filename: 'test.txt',
276
+ size: 12,
277
+ url: 'https://cdn.discord.com/test.txt',
278
+ content_type: null,
279
+ },
280
+ {
281
+ id: 'att2',
282
+ filename: 'report.pdf',
283
+ size: 2048,
284
+ url: 'https://cdn.discord.com/report.pdf',
285
+ content_type: 'application/pdf',
286
+ },
287
+ ])
130
288
  })
131
289
 
132
- it('returns success result with files array for listAction', async () => {
133
- const result = await listAction('general', {
134
- ...options,
135
- _credManager: mockCredManager,
136
- })
290
+ it('returns error when channel resolution fails', async () => {
291
+ const result = await listAction('nonexistent', options)
137
292
 
138
- if (!result.error) {
139
- expect(result.success).toBe(true)
140
- expect(Array.isArray(result.files)).toBe(true)
141
- }
293
+ expect(result.error).toBeDefined()
142
294
  })
143
295
  })
144
296
  })
@@ -5,48 +5,60 @@ import { Command } from 'commander'
5
5
  import { handleError } from '@/shared/utils/error-handler'
6
6
  import { formatOutput } from '@/shared/utils/output'
7
7
 
8
- import type { DiscordFile } from '../types'
9
- import type { BotOption } from './shared'
10
- import { getClient, getCurrentServer } from './shared'
11
-
12
- interface FileOutput {
13
- id: string
14
- filename: string
15
- size: number
16
- url: string
17
- content_type?: string | null
8
+ import type { AttachmentOutput, BotOption, SendTargetOption } from './shared'
9
+ import { getClient, getCurrentServer, resolveSendTarget, toAttachmentOutput } from './shared'
10
+
11
+ export interface UploadOption extends SendTargetOption {
12
+ text?: string
13
+ filename?: string
18
14
  }
19
15
 
20
16
  interface UploadActionResult {
21
17
  success?: boolean
22
18
  error?: string
23
- file?: FileOutput
19
+ file?: AttachmentOutput
20
+ files?: AttachmentOutput[]
21
+ message_id?: string
22
+ channel_id?: string
24
23
  }
25
24
 
26
25
  interface ListActionResult {
27
26
  success?: boolean
28
27
  error?: string
29
- files?: FileOutput[]
28
+ files?: AttachmentOutput[]
30
29
  }
31
30
 
32
- export async function uploadAction(channel: string, filePath: string, options: BotOption): Promise<UploadActionResult> {
31
+ type InfoActionResult = AttachmentOutput | { error: string }
32
+
33
+ export async function uploadAction(
34
+ channel: string,
35
+ filePaths: string[],
36
+ options: UploadOption,
37
+ ): Promise<UploadActionResult> {
33
38
  try {
39
+ if (options.filename !== undefined && filePaths.length > 1) {
40
+ return { error: '--filename requires exactly one file' }
41
+ }
42
+
34
43
  const serverId = await getCurrentServer(options)
35
44
  const client = await getClient(options)
36
- const channelId = await client.resolveChannel(serverId, channel)
45
+ const { channelId, threadId } = await resolveSendTarget(client, serverId, channel, options.thread)
37
46
 
38
- const resolvedPath = resolve(filePath)
39
- const file = await client.uploadFile(channelId, resolvedPath)
47
+ const message = await client.createMessage(threadId ?? channelId, {
48
+ content: options.text,
49
+ files: filePaths.map((filePath) => ({ path: resolve(filePath), filename: options.filename })),
50
+ reply_to: options.reply,
51
+ })
40
52
 
41
- const output: FileOutput = {
42
- id: file.id,
43
- filename: file.filename,
44
- size: file.size,
45
- url: file.url,
46
- content_type: file.content_type || null,
47
- }
53
+ const files = toAttachmentOutput(message.attachments)
48
54
 
49
- return { success: true, file: output }
55
+ return {
56
+ success: true,
57
+ file: files[0],
58
+ files,
59
+ message_id: message.id,
60
+ channel_id: message.channel_id,
61
+ }
50
62
  } catch (error) {
51
63
  return { error: (error as Error).message }
52
64
  }
@@ -60,15 +72,24 @@ export async function listAction(channel: string, options: BotOption): Promise<L
60
72
 
61
73
  const files = await client.listFiles(channelId)
62
74
 
63
- const output = files.map((file: DiscordFile) => ({
64
- id: file.id,
65
- filename: file.filename,
66
- size: file.size,
67
- url: file.url,
68
- content_type: file.content_type || null,
69
- }))
75
+ return { success: true, files: toAttachmentOutput(files) }
76
+ } catch (error) {
77
+ return { error: (error as Error).message }
78
+ }
79
+ }
70
80
 
71
- return { success: true, files: output }
81
+ export async function infoAction(channel: string, fileId: string, options: BotOption): Promise<InfoActionResult> {
82
+ try {
83
+ const serverId = await getCurrentServer(options)
84
+ const client = await getClient(options)
85
+ const channelId = await client.resolveChannel(serverId, channel)
86
+
87
+ const file = await client.findFile(channelId, fileId)
88
+ if (!file) {
89
+ return { error: `File not found: ${fileId}` }
90
+ }
91
+
92
+ return toAttachmentOutput([file])[0]
72
93
  } catch (error) {
73
94
  return { error: (error as Error).message }
74
95
  }
@@ -78,15 +99,36 @@ export const fileCommand = new Command('file')
78
99
  .description('File commands')
79
100
  .addCommand(
80
101
  new Command('upload')
81
- .description('Upload file to channel')
102
+ .description('Upload one or more files to channel')
103
+ .argument('<channel>', 'Channel ID or name')
104
+ .argument('<file-path...>', 'One or more file paths (max 10)')
105
+ .option('--text <text>', 'Message text sent with the file(s)')
106
+ .option('--thread <id|name>', 'Post into this thread')
107
+ .option('--reply <message-id>', 'Reply to a message')
108
+ .option('--filename <name>', 'Override the filename (single file only)')
109
+ .option('--server <id>', 'Use specific server')
110
+ .option('--bot <id>', 'Use specific bot')
111
+ .option('--pretty', 'Pretty print JSON output')
112
+ .action(async (channelArg: string, filePathArgs: string[], opts: UploadOption & BotOption) => {
113
+ try {
114
+ const result = await uploadAction(channelArg, filePathArgs, opts)
115
+ console.log(formatOutput(result, opts.pretty))
116
+ } catch (error) {
117
+ handleError(error as Error)
118
+ }
119
+ }),
120
+ )
121
+ .addCommand(
122
+ new Command('info')
123
+ .description('Get file info')
82
124
  .argument('<channel>', 'Channel ID or name')
83
- .argument('<file-path>', 'File path')
125
+ .argument('<file-id>', 'File ID')
84
126
  .option('--server <id>', 'Use specific server')
85
127
  .option('--bot <id>', 'Use specific bot')
86
128
  .option('--pretty', 'Pretty print JSON output')
87
- .action(async (channelArg: string, filePathArg: string, options: BotOption) => {
129
+ .action(async (channelArg: string, fileIdArg: string, options: BotOption) => {
88
130
  try {
89
- const result = await uploadAction(channelArg, filePathArg, options)
131
+ const result = await infoAction(channelArg, fileIdArg, options)
90
132
  console.log(formatOutput(result, options.pretty))
91
133
  } catch (error) {
92
134
  handleError(error as Error)