kimaki 0.4.39 → 0.4.41
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/dist/cli.js +108 -51
- package/dist/commands/abort.js +1 -1
- package/dist/commands/add-project.js +2 -2
- package/dist/commands/agent.js +2 -2
- package/dist/commands/fork.js +2 -2
- package/dist/commands/model.js +2 -2
- package/dist/commands/remove-project.js +2 -2
- package/dist/commands/resume.js +2 -2
- package/dist/commands/session.js +4 -4
- package/dist/commands/share.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/worktree.js +180 -0
- package/dist/database.js +49 -1
- package/dist/discord-bot.js +29 -4
- package/dist/discord-utils.js +36 -0
- package/dist/errors.js +86 -87
- package/dist/genai-worker.js +1 -1
- package/dist/interaction-handler.js +6 -2
- package/dist/markdown.js +5 -1
- package/dist/message-formatting.js +2 -2
- package/dist/opencode.js +4 -4
- package/dist/session-handler.js +2 -2
- package/dist/tools.js +3 -3
- package/dist/voice-handler.js +3 -3
- package/dist/voice.js +4 -4
- package/package.json +16 -16
- package/src/cli.ts +166 -85
- package/src/commands/abort.ts +1 -1
- package/src/commands/add-project.ts +2 -2
- package/src/commands/agent.ts +2 -2
- package/src/commands/fork.ts +2 -2
- package/src/commands/model.ts +2 -2
- package/src/commands/remove-project.ts +2 -2
- package/src/commands/resume.ts +2 -2
- package/src/commands/session.ts +4 -4
- package/src/commands/share.ts +1 -1
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/worktree.ts +243 -0
- package/src/database.ts +96 -1
- package/src/discord-bot.ts +30 -4
- package/src/discord-utils.ts +50 -0
- package/src/errors.ts +90 -160
- package/src/genai-worker.ts +1 -1
- package/src/interaction-handler.ts +7 -2
- package/src/markdown.ts +5 -4
- package/src/message-formatting.ts +2 -2
- package/src/opencode.ts +4 -4
- package/src/session-handler.ts +2 -2
- package/src/tools.ts +3 -3
- package/src/voice-handler.ts +3 -3
- package/src/voice.ts +4 -4
|
@@ -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 (
|
|
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()
|
package/src/discord-bot.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
}
|
package/src/discord-utils.ts
CHANGED
|
@@ -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
|
+
}
|