kimaki 0.4.39 → 0.4.40

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 (51) hide show
  1. package/dist/cli.js +19 -21
  2. package/dist/commands/abort.js +1 -1
  3. package/dist/commands/add-project.js +2 -2
  4. package/dist/commands/agent.js +2 -2
  5. package/dist/commands/fork.js +2 -2
  6. package/dist/commands/model.js +2 -2
  7. package/dist/commands/remove-project.js +2 -2
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/session.js +4 -4
  10. package/dist/commands/share.js +1 -1
  11. package/dist/commands/undo-redo.js +2 -2
  12. package/dist/commands/worktree.js +180 -0
  13. package/dist/database.js +49 -1
  14. package/dist/discord-bot.js +29 -4
  15. package/dist/discord-utils.js +36 -0
  16. package/dist/errors.js +86 -87
  17. package/dist/genai-worker.js +1 -1
  18. package/dist/interaction-handler.js +6 -2
  19. package/dist/markdown.js +5 -1
  20. package/dist/message-formatting.js +2 -2
  21. package/dist/opencode.js +4 -4
  22. package/dist/session-handler.js +2 -2
  23. package/dist/tools.js +3 -3
  24. package/dist/voice-handler.js +3 -3
  25. package/dist/voice.js +4 -4
  26. package/package.json +4 -3
  27. package/src/cli.ts +20 -30
  28. package/src/commands/abort.ts +1 -1
  29. package/src/commands/add-project.ts +2 -2
  30. package/src/commands/agent.ts +2 -2
  31. package/src/commands/fork.ts +2 -2
  32. package/src/commands/model.ts +2 -2
  33. package/src/commands/remove-project.ts +2 -2
  34. package/src/commands/resume.ts +2 -2
  35. package/src/commands/session.ts +4 -4
  36. package/src/commands/share.ts +1 -1
  37. package/src/commands/undo-redo.ts +2 -2
  38. package/src/commands/worktree.ts +243 -0
  39. package/src/database.ts +96 -1
  40. package/src/discord-bot.ts +30 -4
  41. package/src/discord-utils.ts +50 -0
  42. package/src/errors.ts +90 -160
  43. package/src/genai-worker.ts +1 -1
  44. package/src/interaction-handler.ts +7 -2
  45. package/src/markdown.ts +5 -4
  46. package/src/message-formatting.ts +2 -2
  47. package/src/opencode.ts +4 -4
  48. package/src/session-handler.ts +2 -2
  49. package/src/tools.ts +3 -3
  50. package/src/voice-handler.ts +3 -3
  51. package/src/voice.ts +4 -4
@@ -61,7 +61,7 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
61
61
 
62
62
  try {
63
63
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
64
- if (errore.isError(getClient)) {
64
+ if (getClient instanceof Error) {
65
65
  await command.editReply(getClient.message)
66
66
  return
67
67
  }
@@ -173,7 +173,7 @@ export async function handleResumeAutocomplete({
173
173
 
174
174
  try {
175
175
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
176
- if (errore.isError(getClient)) {
176
+ if (getClient instanceof Error) {
177
177
  await interaction.respond([])
178
178
  return
179
179
  }
@@ -1,4 +1,4 @@
1
- // /session command - Start a new OpenCode session.
1
+ // /new-session command - Start a new OpenCode session.
2
2
 
3
3
  import { ChannelType, type TextChannel } from 'discord.js'
4
4
  import fs from 'node:fs'
@@ -59,7 +59,7 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
59
59
 
60
60
  try {
61
61
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
62
- if (errore.isError(getClient)) {
62
+ if (getClient instanceof Error) {
63
63
  await command.editReply(getClient.message)
64
64
  return
65
65
  }
@@ -133,7 +133,7 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
133
133
 
134
134
  try {
135
135
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
136
- if (errore.isError(getClient)) {
136
+ if (getClient instanceof Error) {
137
137
  await interaction.respond([])
138
138
  return
139
139
  }
@@ -216,7 +216,7 @@ export async function handleSessionAutocomplete({
216
216
 
217
217
  try {
218
218
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
219
- if (errore.isError(getClient)) {
219
+ if (getClient instanceof Error) {
220
220
  await interaction.respond([])
221
221
  return
222
222
  }
@@ -65,7 +65,7 @@ export async function handleShareCommand({ command }: CommandContext): Promise<v
65
65
  const sessionId = row.session_id
66
66
 
67
67
  const getClient = await initializeOpencodeForDirectory(directory)
68
- if (errore.isError(getClient)) {
68
+ if (getClient instanceof Error) {
69
69
  await command.reply({
70
70
  content: `Failed to share session: ${getClient.message}`,
71
71
  ephemeral: true,
@@ -67,7 +67,7 @@ export async function handleUndoCommand({ command }: CommandContext): Promise<vo
67
67
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
68
68
 
69
69
  const getClient = await initializeOpencodeForDirectory(directory)
70
- if (errore.isError(getClient)) {
70
+ if (getClient instanceof Error) {
71
71
  await command.editReply(`Failed to undo: ${getClient.message}`)
72
72
  return
73
73
  }
@@ -174,7 +174,7 @@ export async function handleRedoCommand({ command }: CommandContext): Promise<vo
174
174
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
175
175
 
176
176
  const getClient = await initializeOpencodeForDirectory(directory)
177
- if (errore.isError(getClient)) {
177
+ if (getClient instanceof Error) {
178
178
  await command.editReply(`Failed to redo: ${getClient.message}`)
179
179
  return
180
180
  }
@@ -0,0 +1,243 @@
1
+ // Worktree management command: /new-worktree
2
+ // Uses OpenCode SDK v2 to create worktrees with kimaki- prefix
3
+ // Creates thread immediately, then worktree in background so user can type
4
+
5
+ import { ChannelType, type TextChannel, type ThreadChannel, type Message } from 'discord.js'
6
+ import fs from 'node:fs'
7
+ import type { CommandContext } from './types.js'
8
+ import {
9
+ createPendingWorktree,
10
+ setWorktreeReady,
11
+ setWorktreeError,
12
+ } from '../database.js'
13
+ import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js'
14
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
15
+ import { extractTagsArrays } from '../xml.js'
16
+ import { createLogger } from '../logger.js'
17
+ import * as errore from 'errore'
18
+
19
+ const logger = createLogger('WORKTREE')
20
+
21
+ class WorktreeError extends Error {
22
+ constructor(message: string, options?: { cause?: unknown }) {
23
+ super(message, options)
24
+ this.name = 'WorktreeError'
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
30
+ * "My Feature" → "kimaki-my-feature"
31
+ */
32
+ function formatWorktreeName(name: string): string {
33
+ const formatted = name
34
+ .toLowerCase()
35
+ .trim()
36
+ .replace(/\s+/g, '-')
37
+ .replace(/[^a-z0-9-]/g, '')
38
+
39
+ return `kimaki-${formatted}`
40
+ }
41
+
42
+ /**
43
+ * Get project directory from channel topic.
44
+ */
45
+ function getProjectDirectoryFromChannel(
46
+ channel: TextChannel,
47
+ appId: string,
48
+ ): string | WorktreeError {
49
+ if (!channel.topic) {
50
+ return new WorktreeError('This channel has no topic configured')
51
+ }
52
+
53
+ const extracted = extractTagsArrays({
54
+ xml: channel.topic,
55
+ tags: ['kimaki.directory', 'kimaki.app'],
56
+ })
57
+
58
+ const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
59
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
60
+
61
+ if (channelAppId && channelAppId !== appId) {
62
+ return new WorktreeError('This channel is not configured for this bot')
63
+ }
64
+
65
+ if (!projectDirectory) {
66
+ return new WorktreeError('This channel is not configured with a project directory')
67
+ }
68
+
69
+ if (!fs.existsSync(projectDirectory)) {
70
+ return new WorktreeError(`Directory does not exist: ${projectDirectory}`)
71
+ }
72
+
73
+ return projectDirectory
74
+ }
75
+
76
+ /**
77
+ * Create worktree in background and update starter message when done.
78
+ */
79
+ async function createWorktreeInBackground({
80
+ thread,
81
+ starterMessage,
82
+ worktreeName,
83
+ projectDirectory,
84
+ clientV2,
85
+ }: {
86
+ thread: ThreadChannel
87
+ starterMessage: Message
88
+ worktreeName: string
89
+ projectDirectory: string
90
+ clientV2: ReturnType<typeof getOpencodeClientV2> & {}
91
+ }): Promise<void> {
92
+ // Create worktree using SDK v2
93
+ logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`)
94
+ const worktreeResult = await errore.tryAsync({
95
+ try: async () => {
96
+ const response = await clientV2.worktree.create({
97
+ directory: projectDirectory,
98
+ worktreeCreateInput: {
99
+ name: worktreeName,
100
+ },
101
+ })
102
+
103
+ if (response.error) {
104
+ throw new Error(`SDK error: ${JSON.stringify(response.error)}`)
105
+ }
106
+
107
+ if (!response.data) {
108
+ throw new Error('No worktree data returned from SDK')
109
+ }
110
+
111
+ return response.data
112
+ },
113
+ catch: (e) => new WorktreeError('Failed to create worktree', { cause: e }),
114
+ })
115
+
116
+ if (errore.isError(worktreeResult)) {
117
+ const errorMsg = worktreeResult.message
118
+ logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause)
119
+ setWorktreeError({ threadId: thread.id, errorMessage: errorMsg })
120
+ await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`)
121
+ return
122
+ }
123
+
124
+ // Success - update database and edit starter message
125
+ setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory })
126
+ await starterMessage.edit(
127
+ `🌳 **Worktree: ${worktreeName}**\n` +
128
+ `📁 \`${worktreeResult.directory}\`\n` +
129
+ `🌿 Branch: \`${worktreeResult.branch}\``
130
+ )
131
+ }
132
+
133
+ export async function handleNewWorktreeCommand({
134
+ command,
135
+ appId,
136
+ }: CommandContext): Promise<void> {
137
+ await command.deferReply({ ephemeral: false })
138
+
139
+ const rawName = command.options.getString('name', true)
140
+ const worktreeName = formatWorktreeName(rawName)
141
+
142
+ if (worktreeName === 'kimaki-') {
143
+ await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.')
144
+ return
145
+ }
146
+
147
+ const channel = command.channel
148
+
149
+ if (!channel || channel.type !== ChannelType.GuildText) {
150
+ await command.editReply('This command can only be used in text channels')
151
+ return
152
+ }
153
+
154
+ const textChannel = channel as TextChannel
155
+
156
+ const projectDirectory = getProjectDirectoryFromChannel(textChannel, appId)
157
+ if (errore.isError(projectDirectory)) {
158
+ await command.editReply(projectDirectory.message)
159
+ return
160
+ }
161
+
162
+ // Initialize opencode and check if worktree already exists
163
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
164
+ if (errore.isError(getClient)) {
165
+ await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`)
166
+ return
167
+ }
168
+
169
+ const clientV2 = getOpencodeClientV2(projectDirectory)
170
+ if (!clientV2) {
171
+ await command.editReply('Failed to get OpenCode client')
172
+ return
173
+ }
174
+
175
+ // Check if worktree with this name already exists
176
+ // SDK returns array of directory paths like "~/.opencode/worktree/abc/kimaki-my-feature"
177
+ const listResult = await errore.tryAsync({
178
+ try: async () => {
179
+ const response = await clientV2.worktree.list({ directory: projectDirectory })
180
+ return response.data || []
181
+ },
182
+ catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
183
+ })
184
+
185
+ if (errore.isError(listResult)) {
186
+ await command.editReply(listResult.message)
187
+ return
188
+ }
189
+
190
+ // Check if any worktree path ends with our name
191
+ const existingWorktree = listResult.find((dir) => dir.endsWith(`/${worktreeName}`))
192
+ if (existingWorktree) {
193
+ await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktree}\``)
194
+ return
195
+ }
196
+
197
+ // Create thread immediately so user can start typing
198
+ const result = await errore.tryAsync({
199
+ try: async () => {
200
+ const starterMessage = await textChannel.send({
201
+ content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
202
+ flags: SILENT_MESSAGE_FLAGS,
203
+ })
204
+
205
+ const thread = await starterMessage.startThread({
206
+ name: `worktree: ${worktreeName}`,
207
+ autoArchiveDuration: 1440,
208
+ reason: 'Worktree session',
209
+ })
210
+
211
+ return { thread, starterMessage }
212
+ },
213
+ catch: (e) => new WorktreeError('Failed to create thread', { cause: e }),
214
+ })
215
+
216
+ if (errore.isError(result)) {
217
+ logger.error('[NEW-WORKTREE] Error:', result.cause)
218
+ await command.editReply(result.message)
219
+ return
220
+ }
221
+
222
+ const { thread, starterMessage } = result
223
+
224
+ // Store pending worktree in database
225
+ createPendingWorktree({
226
+ threadId: thread.id,
227
+ worktreeName,
228
+ projectDirectory,
229
+ })
230
+
231
+ await command.editReply(`Creating worktree in ${thread.toString()}`)
232
+
233
+ // Create worktree in background (don't await)
234
+ createWorktreeInBackground({
235
+ thread,
236
+ starterMessage,
237
+ worktreeName,
238
+ projectDirectory,
239
+ clientV2,
240
+ }).catch((e) => {
241
+ logger.error('[NEW-WORKTREE] Background error:', e)
242
+ })
243
+ }
package/src/database.ts CHANGED
@@ -23,7 +23,7 @@ export function getDatabase(): Database.Database {
23
23
  },
24
24
  catch: (e) => e as Error,
25
25
  })
26
- if (errore.isError(mkdirError)) {
26
+ if (mkdirError instanceof Error) {
27
27
  dbLogger.error(`Failed to create data directory ${dataDir}:`, mkdirError.message)
28
28
  }
29
29
 
@@ -90,6 +90,20 @@ export function getDatabase(): Database.Database {
90
90
  )
91
91
  `)
92
92
 
93
+ // Track worktrees created for threads (for /new-worktree command)
94
+ // status: 'pending' while creating, 'ready' when done, 'error' if failed
95
+ db.exec(`
96
+ CREATE TABLE IF NOT EXISTS thread_worktrees (
97
+ thread_id TEXT PRIMARY KEY,
98
+ worktree_name TEXT NOT NULL,
99
+ worktree_directory TEXT,
100
+ project_directory TEXT NOT NULL,
101
+ status TEXT DEFAULT 'pending',
102
+ error_message TEXT,
103
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
104
+ )
105
+ `)
106
+
93
107
  runModelMigrations(db)
94
108
  }
95
109
 
@@ -243,6 +257,87 @@ export function setSessionAgent(sessionId: string, agentName: string): void {
243
257
  )
244
258
  }
245
259
 
260
+ // Worktree status types
261
+ export type WorktreeStatus = 'pending' | 'ready' | 'error'
262
+
263
+ export type ThreadWorktree = {
264
+ thread_id: string
265
+ worktree_name: string
266
+ worktree_directory: string | null
267
+ project_directory: string
268
+ status: WorktreeStatus
269
+ error_message: string | null
270
+ }
271
+
272
+ /**
273
+ * Get the worktree info for a thread.
274
+ */
275
+ export function getThreadWorktree(threadId: string): ThreadWorktree | undefined {
276
+ const db = getDatabase()
277
+ return db.prepare('SELECT * FROM thread_worktrees WHERE thread_id = ?').get(threadId) as
278
+ | ThreadWorktree
279
+ | undefined
280
+ }
281
+
282
+ /**
283
+ * Create a pending worktree entry for a thread.
284
+ */
285
+ export function createPendingWorktree({
286
+ threadId,
287
+ worktreeName,
288
+ projectDirectory,
289
+ }: {
290
+ threadId: string
291
+ worktreeName: string
292
+ projectDirectory: string
293
+ }): void {
294
+ const db = getDatabase()
295
+ db.prepare(
296
+ `INSERT OR REPLACE INTO thread_worktrees (thread_id, worktree_name, project_directory, status) VALUES (?, ?, ?, 'pending')`,
297
+ ).run(threadId, worktreeName, projectDirectory)
298
+ }
299
+
300
+ /**
301
+ * Mark a worktree as ready with its directory.
302
+ */
303
+ export function setWorktreeReady({
304
+ threadId,
305
+ worktreeDirectory,
306
+ }: {
307
+ threadId: string
308
+ worktreeDirectory: string
309
+ }): void {
310
+ const db = getDatabase()
311
+ db.prepare(
312
+ `UPDATE thread_worktrees SET worktree_directory = ?, status = 'ready' WHERE thread_id = ?`,
313
+ ).run(worktreeDirectory, threadId)
314
+ }
315
+
316
+ /**
317
+ * Mark a worktree as failed with error message.
318
+ */
319
+ export function setWorktreeError({
320
+ threadId,
321
+ errorMessage,
322
+ }: {
323
+ threadId: string
324
+ errorMessage: string
325
+ }): void {
326
+ const db = getDatabase()
327
+ db.prepare(`UPDATE thread_worktrees SET status = 'error', error_message = ? WHERE thread_id = ?`).run(
328
+ errorMessage,
329
+ threadId,
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Delete the worktree info for a thread.
335
+ */
336
+ export function deleteThreadWorktree(threadId: string): void {
337
+ const db = getDatabase()
338
+ db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId)
339
+ }
340
+
246
341
  export function closeDatabase(): void {
247
342
  if (db) {
248
343
  db.close()
@@ -2,7 +2,7 @@
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
4
 
5
- import { getDatabase, closeDatabase } from './database.js'
5
+ import { getDatabase, closeDatabase, getThreadWorktree } from './database.js'
6
6
  import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
7
7
  import {
8
8
  escapeBackticksInCodeBlocks,
@@ -58,7 +58,10 @@ import { extractTagsArrays } from './xml.js'
58
58
  import { createLogger } from './logger.js'
59
59
  import { setGlobalDispatcher, Agent } from 'undici'
60
60
 
61
- setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
61
+ // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
62
+ // Each session's event.subscribe() holds a connection; without enough connections,
63
+ // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
64
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }))
62
65
 
63
66
  const discordLogger = createLogger('DISCORD')
64
67
  const voiceLogger = createLogger('VOICE')
@@ -154,7 +157,7 @@ export async function startDiscordBot({
154
157
  try: () => message.fetch(),
155
158
  catch: (e) => e as Error,
156
159
  })
157
- if (errore.isError(fetched)) {
160
+ if (fetched instanceof Error) {
158
161
  discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message)
159
162
  return
160
163
  }
@@ -204,6 +207,29 @@ export async function startDiscordBot({
204
207
  channelAppId = extracted['kimaki.app']?.[0]?.trim()
205
208
  }
206
209
 
210
+ // Check if this thread is a worktree thread
211
+ const worktreeInfo = getThreadWorktree(thread.id)
212
+ if (worktreeInfo) {
213
+ if (worktreeInfo.status === 'pending') {
214
+ await message.reply({
215
+ content: '⏳ Worktree is still being created. Please wait...',
216
+ flags: SILENT_MESSAGE_FLAGS,
217
+ })
218
+ return
219
+ }
220
+ if (worktreeInfo.status === 'error') {
221
+ await message.reply({
222
+ content: `❌ Worktree creation failed: ${worktreeInfo.error_message}`,
223
+ flags: SILENT_MESSAGE_FLAGS,
224
+ })
225
+ return
226
+ }
227
+ if (worktreeInfo.worktree_directory) {
228
+ projectDirectory = worktreeInfo.worktree_directory
229
+ discordLogger.log(`Using worktree directory: ${projectDirectory}`)
230
+ }
231
+ }
232
+
207
233
  if (channelAppId && channelAppId !== currentAppId) {
208
234
  voiceLogger.log(
209
235
  `[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
@@ -259,7 +285,7 @@ export async function startDiscordBot({
259
285
  if (projectDirectory) {
260
286
  try {
261
287
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
262
- if (errore.isError(getClient)) {
288
+ if (getClient instanceof Error) {
263
289
  voiceLogger.error(`[SESSION] Failed to initialize OpenCode client:`, getClient.message)
264
290
  throw new Error(getClient.message)
265
291
  }
@@ -9,6 +9,9 @@ import { formatMarkdownTables } from './format-tables.js'
9
9
  import { limitHeadingDepth } from './limit-heading-depth.js'
10
10
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
11
11
  import { createLogger } from './logger.js'
12
+ import mime from 'mime'
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
12
15
 
13
16
  const discordLogger = createLogger('DISCORD')
14
17
 
@@ -302,3 +305,50 @@ export function getKimakiMetadata(textChannel: TextChannel | null): {
302
305
 
303
306
  return { projectDirectory, channelAppId }
304
307
  }
308
+
309
+ /**
310
+ * Upload files to a Discord thread/channel in a single message.
311
+ * Sending all files in one message causes Discord to display images in a grid layout.
312
+ */
313
+ export async function uploadFilesToDiscord({
314
+ threadId,
315
+ botToken,
316
+ files,
317
+ }: {
318
+ threadId: string
319
+ botToken: string
320
+ files: string[]
321
+ }): Promise<void> {
322
+ if (files.length === 0) {
323
+ return
324
+ }
325
+
326
+ // Build attachments array for all files
327
+ const attachments = files.map((file, index) => ({
328
+ id: index,
329
+ filename: path.basename(file),
330
+ }))
331
+
332
+ const formData = new FormData()
333
+ formData.append('payload_json', JSON.stringify({ attachments }))
334
+
335
+ // Append each file with its array index, with correct MIME type for grid display
336
+ files.forEach((file, index) => {
337
+ const buffer = fs.readFileSync(file)
338
+ const mimeType = mime.getType(file) || 'application/octet-stream'
339
+ formData.append(`files[${index}]`, new Blob([buffer], { type: mimeType }), path.basename(file))
340
+ })
341
+
342
+ const response = await fetch(`https://discord.com/api/v10/channels/${threadId}/messages`, {
343
+ method: 'POST',
344
+ headers: {
345
+ Authorization: `Bot ${botToken}`,
346
+ },
347
+ body: formData,
348
+ })
349
+
350
+ if (!response.ok) {
351
+ const error = await response.text()
352
+ throw new Error(`Discord API error: ${response.status} - ${error}`)
353
+ }
354
+ }