kimaki 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/bin.sh +28 -0
- package/dist/ai-tool-to-genai.js +207 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/cli.js +357 -0
- package/dist/directVoiceStreaming.js +102 -0
- package/dist/discordBot.js +1740 -0
- package/dist/genai-worker-wrapper.js +104 -0
- package/dist/genai-worker.js +293 -0
- package/dist/genai.js +224 -0
- package/dist/logger.js +10 -0
- package/dist/markdown.js +199 -0
- package/dist/markdown.test.js +232 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/plugin.js +1414 -0
- package/dist/tools.js +352 -0
- package/dist/utils.js +52 -0
- package/dist/voice.js +28 -0
- package/dist/worker-types.js +1 -0
- package/dist/xml.js +85 -0
- package/package.json +37 -56
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +251 -0
- package/src/cli.ts +551 -0
- package/src/discordBot.ts +2350 -0
- package/src/genai-worker-wrapper.ts +152 -0
- package/src/genai-worker.ts +361 -0
- package/src/genai.ts +308 -0
- package/src/logger.ts +16 -0
- package/src/markdown.test.ts +314 -0
- package/src/markdown.ts +225 -0
- package/src/openai-realtime.ts +363 -0
- package/src/tools.ts +421 -0
- package/src/utils.ts +73 -0
- package/src/voice.ts +42 -0
- package/src/worker-types.ts +60 -0
- package/src/xml.ts +112 -0
- package/bin.js +0 -3
- package/dist/bin.d.ts +0 -3
- package/dist/bin.d.ts.map +0 -1
- package/dist/bin.js +0 -4
- package/dist/bin.js.map +0 -1
- package/dist/bundle.js +0 -3124
- package/dist/cli.d.ts.map +0 -1
|
@@ -0,0 +1,2350 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createOpencodeClient,
|
|
3
|
+
type OpencodeClient,
|
|
4
|
+
type Part,
|
|
5
|
+
} from '@opencode-ai/sdk'
|
|
6
|
+
|
|
7
|
+
import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
|
|
8
|
+
|
|
9
|
+
import Database from 'better-sqlite3'
|
|
10
|
+
import {
|
|
11
|
+
ChannelType,
|
|
12
|
+
Client,
|
|
13
|
+
Events,
|
|
14
|
+
GatewayIntentBits,
|
|
15
|
+
Partials,
|
|
16
|
+
PermissionsBitField,
|
|
17
|
+
ThreadAutoArchiveDuration,
|
|
18
|
+
type Guild,
|
|
19
|
+
type Interaction,
|
|
20
|
+
type Message,
|
|
21
|
+
type TextChannel,
|
|
22
|
+
type ThreadChannel,
|
|
23
|
+
type VoiceChannel,
|
|
24
|
+
} from 'discord.js'
|
|
25
|
+
import {
|
|
26
|
+
joinVoiceChannel,
|
|
27
|
+
VoiceConnectionStatus,
|
|
28
|
+
entersState,
|
|
29
|
+
EndBehaviorType,
|
|
30
|
+
type VoiceConnection,
|
|
31
|
+
} from '@discordjs/voice'
|
|
32
|
+
import { Lexer } from 'marked'
|
|
33
|
+
import { spawn, exec, type ChildProcess } from 'node:child_process'
|
|
34
|
+
import fs, { createWriteStream } from 'node:fs'
|
|
35
|
+
import { mkdir } from 'node:fs/promises'
|
|
36
|
+
import net from 'node:net'
|
|
37
|
+
import path from 'node:path'
|
|
38
|
+
import { promisify } from 'node:util'
|
|
39
|
+
import { PassThrough, Transform, type TransformCallback } from 'node:stream'
|
|
40
|
+
import * as prism from 'prism-media'
|
|
41
|
+
import dedent from 'string-dedent'
|
|
42
|
+
import { transcribeAudio } from './voice.js'
|
|
43
|
+
import { extractTagsArrays } from './xml.js'
|
|
44
|
+
import prettyMilliseconds from 'pretty-ms'
|
|
45
|
+
import type { Session } from '@google/genai'
|
|
46
|
+
import { createLogger } from './logger.js'
|
|
47
|
+
|
|
48
|
+
const discordLogger = createLogger('DISCORD')
|
|
49
|
+
const voiceLogger = createLogger('VOICE')
|
|
50
|
+
const opencodeLogger = createLogger('OPENCODE')
|
|
51
|
+
const sessionLogger = createLogger('SESSION')
|
|
52
|
+
const dbLogger = createLogger('DB')
|
|
53
|
+
|
|
54
|
+
type StartOptions = {
|
|
55
|
+
token: string
|
|
56
|
+
appId?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Map of project directory to OpenCode server process and client
|
|
60
|
+
const opencodeServers = new Map<
|
|
61
|
+
string,
|
|
62
|
+
{
|
|
63
|
+
process: ChildProcess
|
|
64
|
+
client: OpencodeClient
|
|
65
|
+
port: number
|
|
66
|
+
}
|
|
67
|
+
>()
|
|
68
|
+
|
|
69
|
+
// Map of session ID to current AbortController
|
|
70
|
+
const abortControllers = new Map<string, AbortController>()
|
|
71
|
+
|
|
72
|
+
// Map of guild ID to voice connection and GenAI worker
|
|
73
|
+
const voiceConnections = new Map<
|
|
74
|
+
string,
|
|
75
|
+
{
|
|
76
|
+
connection: VoiceConnection
|
|
77
|
+
genAiWorker?: GenAIWorker
|
|
78
|
+
userAudioStream?: fs.WriteStream
|
|
79
|
+
}
|
|
80
|
+
>()
|
|
81
|
+
|
|
82
|
+
// Map of directory to retry count for server restarts
|
|
83
|
+
const serverRetryCount = new Map<string, number>()
|
|
84
|
+
|
|
85
|
+
let db: Database.Database | null = null
|
|
86
|
+
|
|
87
|
+
function convertToMono16k(buffer: Buffer): Buffer {
|
|
88
|
+
// Parameters
|
|
89
|
+
const inputSampleRate = 48000
|
|
90
|
+
const outputSampleRate = 16000
|
|
91
|
+
const ratio = inputSampleRate / outputSampleRate
|
|
92
|
+
const inputChannels = 2 // Stereo
|
|
93
|
+
const bytesPerSample = 2 // 16-bit
|
|
94
|
+
|
|
95
|
+
// Calculate output buffer size
|
|
96
|
+
const inputSamples = buffer.length / (bytesPerSample * inputChannels)
|
|
97
|
+
const outputSamples = Math.floor(inputSamples / ratio)
|
|
98
|
+
const outputBuffer = Buffer.alloc(outputSamples * bytesPerSample)
|
|
99
|
+
|
|
100
|
+
// Process each output sample
|
|
101
|
+
for (let i = 0; i < outputSamples; i++) {
|
|
102
|
+
// Find the corresponding input sample
|
|
103
|
+
const inputIndex = Math.floor(i * ratio) * inputChannels * bytesPerSample
|
|
104
|
+
|
|
105
|
+
// Average the left and right channels for mono conversion
|
|
106
|
+
if (inputIndex + 3 < buffer.length) {
|
|
107
|
+
const leftSample = buffer.readInt16LE(inputIndex)
|
|
108
|
+
const rightSample = buffer.readInt16LE(inputIndex + 2)
|
|
109
|
+
const monoSample = Math.round((leftSample + rightSample) / 2)
|
|
110
|
+
|
|
111
|
+
// Write to output buffer
|
|
112
|
+
outputBuffer.writeInt16LE(monoSample, i * bytesPerSample)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return outputBuffer
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Create user audio log stream for debugging
|
|
120
|
+
async function createUserAudioLogStream(
|
|
121
|
+
guildId: string,
|
|
122
|
+
channelId: string,
|
|
123
|
+
): Promise<fs.WriteStream | undefined> {
|
|
124
|
+
if (!process.env.DEBUG) return undefined
|
|
125
|
+
|
|
126
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
127
|
+
const audioDir = path.join(
|
|
128
|
+
process.cwd(),
|
|
129
|
+
'discord-audio-logs',
|
|
130
|
+
guildId,
|
|
131
|
+
channelId,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await mkdir(audioDir, { recursive: true })
|
|
136
|
+
|
|
137
|
+
// Create stream for user audio (16kHz mono s16le PCM)
|
|
138
|
+
const inputFileName = `user_${timestamp}.16.pcm`
|
|
139
|
+
const inputFilePath = path.join(audioDir, inputFileName)
|
|
140
|
+
const inputAudioStream = createWriteStream(inputFilePath)
|
|
141
|
+
voiceLogger.log(`Created user audio log: ${inputFilePath}`)
|
|
142
|
+
|
|
143
|
+
return inputAudioStream
|
|
144
|
+
} catch (error) {
|
|
145
|
+
voiceLogger.error('Failed to create audio log directory:', error)
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Set up voice handling for a connection (called once per connection)
|
|
151
|
+
async function setupVoiceHandling({
|
|
152
|
+
connection,
|
|
153
|
+
guildId,
|
|
154
|
+
channelId,
|
|
155
|
+
}: {
|
|
156
|
+
connection: VoiceConnection
|
|
157
|
+
guildId: string
|
|
158
|
+
channelId: string
|
|
159
|
+
}) {
|
|
160
|
+
voiceLogger.log(
|
|
161
|
+
`Setting up voice handling for guild ${guildId}, channel ${channelId}`,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// Check if this voice channel has an associated directory
|
|
165
|
+
const channelDirRow = getDatabase()
|
|
166
|
+
.prepare(
|
|
167
|
+
'SELECT directory FROM channel_directories WHERE channel_id = ? AND channel_type = ?',
|
|
168
|
+
)
|
|
169
|
+
.get(channelId, 'voice') as { directory: string } | undefined
|
|
170
|
+
|
|
171
|
+
if (!channelDirRow) {
|
|
172
|
+
voiceLogger.log(
|
|
173
|
+
`Voice channel ${channelId} has no associated directory, skipping setup`,
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const directory = channelDirRow.directory
|
|
179
|
+
voiceLogger.log(`Found directory for voice channel: ${directory}`)
|
|
180
|
+
|
|
181
|
+
// Get voice data
|
|
182
|
+
const voiceData = voiceConnections.get(guildId)
|
|
183
|
+
if (!voiceData) {
|
|
184
|
+
voiceLogger.error(`No voice data found for guild ${guildId}`)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Create user audio stream for debugging
|
|
189
|
+
voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
|
|
190
|
+
|
|
191
|
+
// Create GenAI worker
|
|
192
|
+
const genAiWorker = await createGenAIWorker({
|
|
193
|
+
directory,
|
|
194
|
+
guildId,
|
|
195
|
+
channelId,
|
|
196
|
+
systemMessage: dedent`
|
|
197
|
+
You are Kimaki, an AI similar to Jarvis: you help your user (an engineer) controlling his coding agent, just like Jarvis controls Ironman armor and machines. Speak fast.
|
|
198
|
+
|
|
199
|
+
You should talk like Jarvis, British accent, satirical, joking and calm. Be short and concise. Speak fast.
|
|
200
|
+
|
|
201
|
+
After tool calls give a super short summary of the assistant message, you should say what the assistant message writes.
|
|
202
|
+
|
|
203
|
+
Before starting a new session ask for confirmation if it is not clear if the user finished describing it. ask "message ready, send?"
|
|
204
|
+
|
|
205
|
+
NEVER repeat the whole tool call parameters or message.
|
|
206
|
+
|
|
207
|
+
Your job is to manage many opencode agent chat instances. Opencode is the agent used to write the code, it is similar to Claude Code.
|
|
208
|
+
|
|
209
|
+
For everything the user asks it is implicit that the user is asking for you to proxy the requests to opencode sessions.
|
|
210
|
+
|
|
211
|
+
You can
|
|
212
|
+
- start new chats on a given project
|
|
213
|
+
- read the chats to report progress to the user
|
|
214
|
+
- submit messages to the chat
|
|
215
|
+
- list files for a given projects, so you can translate imprecise user prompts to precise messages that mention filename paths using @
|
|
216
|
+
|
|
217
|
+
Common patterns
|
|
218
|
+
- to get the last session use the listChats tool
|
|
219
|
+
- when user asks you to do something you submit a new session to do it. it's implicit that you proxy requests to the agents chat!
|
|
220
|
+
- when you submit a session assume the session will take a minute or 2 to complete the task
|
|
221
|
+
|
|
222
|
+
Rules
|
|
223
|
+
- never spell files by mentioning dots, letters, etc. instead give a brief description of the filename
|
|
224
|
+
- NEVER spell hashes or IDs
|
|
225
|
+
- never read session ids or other ids
|
|
226
|
+
|
|
227
|
+
Your voice is calm and monotone, NEVER excited and goofy. But you speak without jargon or bs and do veiled short jokes.
|
|
228
|
+
You speak like you knew something other don't. You are cool and cold.
|
|
229
|
+
`,
|
|
230
|
+
onAssistantOpusPacket(packet) {
|
|
231
|
+
// Opus packets are sent at 20ms intervals from worker, play directly
|
|
232
|
+
if (connection.state.status !== VoiceConnectionStatus.Ready) {
|
|
233
|
+
voiceLogger.log('Skipping packet: connection not ready')
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
connection.setSpeaking(true)
|
|
239
|
+
connection.playOpusPacket(Buffer.from(packet))
|
|
240
|
+
} catch (error) {
|
|
241
|
+
voiceLogger.error('Error sending packet:', error)
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
onAssistantStartSpeaking() {
|
|
245
|
+
voiceLogger.log('Assistant started speaking')
|
|
246
|
+
connection.setSpeaking(true)
|
|
247
|
+
},
|
|
248
|
+
onAssistantStopSpeaking() {
|
|
249
|
+
voiceLogger.log('Assistant stopped speaking (natural finish)')
|
|
250
|
+
connection.setSpeaking(false)
|
|
251
|
+
},
|
|
252
|
+
onAssistantInterruptSpeaking() {
|
|
253
|
+
voiceLogger.log('Assistant interrupted while speaking')
|
|
254
|
+
genAiWorker.interrupt()
|
|
255
|
+
connection.setSpeaking(false)
|
|
256
|
+
},
|
|
257
|
+
onToolCallCompleted(params) {
|
|
258
|
+
const text = params.error
|
|
259
|
+
? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
|
|
260
|
+
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
|
|
261
|
+
|
|
262
|
+
genAiWorker.sendTextInput(text)
|
|
263
|
+
},
|
|
264
|
+
onError(error) {
|
|
265
|
+
voiceLogger.error('GenAI worker error:', error)
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Stop any existing GenAI worker before storing new one
|
|
270
|
+
if (voiceData.genAiWorker) {
|
|
271
|
+
voiceLogger.log('Stopping existing GenAI worker before creating new one')
|
|
272
|
+
await voiceData.genAiWorker.stop()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Send initial greeting
|
|
276
|
+
genAiWorker.sendTextInput(
|
|
277
|
+
`<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
voiceData.genAiWorker = genAiWorker
|
|
281
|
+
|
|
282
|
+
// Set up voice receiver for user input
|
|
283
|
+
const receiver = connection.receiver
|
|
284
|
+
|
|
285
|
+
// Remove all existing listeners to prevent accumulation
|
|
286
|
+
receiver.speaking.removeAllListeners('start')
|
|
287
|
+
|
|
288
|
+
// Counter to track overlapping speaking sessions
|
|
289
|
+
let speakingSessionCount = 0
|
|
290
|
+
|
|
291
|
+
receiver.speaking.on('start', (userId) => {
|
|
292
|
+
voiceLogger.log(`User ${userId} started speaking`)
|
|
293
|
+
|
|
294
|
+
// Increment session count for this new speaking session
|
|
295
|
+
speakingSessionCount++
|
|
296
|
+
const currentSessionCount = speakingSessionCount
|
|
297
|
+
voiceLogger.log(`Speaking session ${currentSessionCount} started`)
|
|
298
|
+
|
|
299
|
+
const audioStream = receiver.subscribe(userId, {
|
|
300
|
+
end: { behavior: EndBehaviorType.AfterSilence, duration: 500 },
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const decoder = new prism.opus.Decoder({
|
|
304
|
+
rate: 48000,
|
|
305
|
+
channels: 2,
|
|
306
|
+
frameSize: 960,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// Add error handler to prevent crashes from corrupted data
|
|
310
|
+
decoder.on('error', (error) => {
|
|
311
|
+
voiceLogger.error(`Opus decoder error for user ${userId}:`, error)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// Transform to downsample 48k stereo -> 16k mono
|
|
315
|
+
const downsampleTransform = new Transform({
|
|
316
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
317
|
+
try {
|
|
318
|
+
const downsampled = convertToMono16k(chunk)
|
|
319
|
+
callback(null, downsampled)
|
|
320
|
+
} catch (error) {
|
|
321
|
+
callback(error as Error)
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const framer = frameMono16khz()
|
|
327
|
+
|
|
328
|
+
const pipeline = audioStream
|
|
329
|
+
.pipe(decoder)
|
|
330
|
+
.pipe(downsampleTransform)
|
|
331
|
+
.pipe(framer)
|
|
332
|
+
|
|
333
|
+
pipeline
|
|
334
|
+
.on('data', (frame: Buffer) => {
|
|
335
|
+
// Check if a newer speaking session has started
|
|
336
|
+
if (currentSessionCount !== speakingSessionCount) {
|
|
337
|
+
voiceLogger.log(
|
|
338
|
+
`Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
|
|
339
|
+
)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!voiceData.genAiWorker) {
|
|
344
|
+
voiceLogger.warn(
|
|
345
|
+
`[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`,
|
|
346
|
+
)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
voiceLogger.debug('User audio chunk length', frame.length)
|
|
350
|
+
|
|
351
|
+
// Write to PCM file if stream exists
|
|
352
|
+
voiceData.userAudioStream?.write(frame)
|
|
353
|
+
|
|
354
|
+
// stream incrementally — low latency
|
|
355
|
+
voiceData.genAiWorker.sendRealtimeInput({
|
|
356
|
+
audio: {
|
|
357
|
+
mimeType: 'audio/pcm;rate=16000',
|
|
358
|
+
data: frame.toString('base64'),
|
|
359
|
+
},
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
.on('end', () => {
|
|
363
|
+
// Only send audioStreamEnd if this is still the current session
|
|
364
|
+
if (currentSessionCount === speakingSessionCount) {
|
|
365
|
+
voiceLogger.log(
|
|
366
|
+
`User ${userId} stopped speaking (session ${currentSessionCount})`,
|
|
367
|
+
)
|
|
368
|
+
voiceData.genAiWorker?.sendRealtimeInput({
|
|
369
|
+
audioStreamEnd: true,
|
|
370
|
+
})
|
|
371
|
+
} else {
|
|
372
|
+
voiceLogger.log(
|
|
373
|
+
`User ${userId} stopped speaking (session ${currentSessionCount}), but skipping audioStreamEnd because newer session ${speakingSessionCount} exists`,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
.on('error', (error) => {
|
|
378
|
+
voiceLogger.error(`Pipeline error for user ${userId}:`, error)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Also add error handlers to individual stream components
|
|
382
|
+
audioStream.on('error', (error) => {
|
|
383
|
+
voiceLogger.error(`Audio stream error for user ${userId}:`, error)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
downsampleTransform.on('error', (error) => {
|
|
387
|
+
voiceLogger.error(`Downsample transform error for user ${userId}:`, error)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
framer.on('error', (error) => {
|
|
391
|
+
voiceLogger.error(`Framer error for user ${userId}:`, error)
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function frameMono16khz(): Transform {
|
|
397
|
+
// Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
|
|
398
|
+
const FRAME_BYTES =
|
|
399
|
+
(100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
|
|
400
|
+
1000
|
|
401
|
+
let stash: Buffer = Buffer.alloc(0)
|
|
402
|
+
let offset = 0
|
|
403
|
+
|
|
404
|
+
return new Transform({
|
|
405
|
+
readableObjectMode: false,
|
|
406
|
+
writableObjectMode: false,
|
|
407
|
+
|
|
408
|
+
transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback) {
|
|
409
|
+
// Normalize stash so offset is always 0 before appending
|
|
410
|
+
if (offset > 0) {
|
|
411
|
+
// Drop already-consumed prefix without copying the rest twice
|
|
412
|
+
stash = stash.subarray(offset)
|
|
413
|
+
offset = 0
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Append new data (single concat per incoming chunk)
|
|
417
|
+
stash = stash.length ? Buffer.concat([stash, chunk]) : chunk
|
|
418
|
+
|
|
419
|
+
// Emit as many full 20 ms frames as we can
|
|
420
|
+
while (stash.length - offset >= FRAME_BYTES) {
|
|
421
|
+
this.push(stash.subarray(offset, offset + FRAME_BYTES))
|
|
422
|
+
offset += FRAME_BYTES
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// If everything was consumed exactly, reset to empty buffer
|
|
426
|
+
if (offset === stash.length) {
|
|
427
|
+
stash = Buffer.alloc(0)
|
|
428
|
+
offset = 0
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
cb()
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
flush(cb: TransformCallback) {
|
|
435
|
+
// We intentionally drop any trailing partial (< 20 ms) to keep framing strict.
|
|
436
|
+
// If you prefer to emit it, uncomment the next line:
|
|
437
|
+
// if (stash.length - offset > 0) this.push(stash.subarray(offset));
|
|
438
|
+
stash = Buffer.alloc(0)
|
|
439
|
+
offset = 0
|
|
440
|
+
cb()
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function getDatabase(): Database.Database {
|
|
446
|
+
if (!db) {
|
|
447
|
+
db = new Database('discord-sessions.db')
|
|
448
|
+
|
|
449
|
+
// Initialize tables
|
|
450
|
+
db.exec(`
|
|
451
|
+
CREATE TABLE IF NOT EXISTS thread_sessions (
|
|
452
|
+
thread_id TEXT PRIMARY KEY,
|
|
453
|
+
session_id TEXT NOT NULL,
|
|
454
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
455
|
+
)
|
|
456
|
+
`)
|
|
457
|
+
|
|
458
|
+
db.exec(`
|
|
459
|
+
CREATE TABLE IF NOT EXISTS part_messages (
|
|
460
|
+
part_id TEXT PRIMARY KEY,
|
|
461
|
+
message_id TEXT NOT NULL,
|
|
462
|
+
thread_id TEXT NOT NULL,
|
|
463
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
464
|
+
)
|
|
465
|
+
`)
|
|
466
|
+
|
|
467
|
+
db.exec(`
|
|
468
|
+
CREATE TABLE IF NOT EXISTS bot_tokens (
|
|
469
|
+
app_id TEXT PRIMARY KEY,
|
|
470
|
+
token TEXT NOT NULL,
|
|
471
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
472
|
+
)
|
|
473
|
+
`)
|
|
474
|
+
|
|
475
|
+
db.exec(`
|
|
476
|
+
CREATE TABLE IF NOT EXISTS channel_directories (
|
|
477
|
+
channel_id TEXT PRIMARY KEY,
|
|
478
|
+
directory TEXT NOT NULL,
|
|
479
|
+
channel_type TEXT NOT NULL,
|
|
480
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
481
|
+
)
|
|
482
|
+
`)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return db
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function getOpenPort(): Promise<number> {
|
|
489
|
+
return new Promise((resolve, reject) => {
|
|
490
|
+
const server = net.createServer()
|
|
491
|
+
server.listen(0, () => {
|
|
492
|
+
const address = server.address()
|
|
493
|
+
if (address && typeof address === 'object') {
|
|
494
|
+
const port = address.port
|
|
495
|
+
server.close(() => {
|
|
496
|
+
resolve(port)
|
|
497
|
+
})
|
|
498
|
+
} else {
|
|
499
|
+
reject(new Error('Failed to get port'))
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
server.on('error', reject)
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Send a message to a Discord thread, automatically splitting long messages
|
|
508
|
+
* @param thread - The thread channel to send to
|
|
509
|
+
* @param content - The content to send (can be longer than 2000 chars)
|
|
510
|
+
* @returns The first message sent
|
|
511
|
+
*/
|
|
512
|
+
async function sendThreadMessage(
|
|
513
|
+
thread: ThreadChannel,
|
|
514
|
+
content: string,
|
|
515
|
+
): Promise<Message> {
|
|
516
|
+
const MAX_LENGTH = 2000
|
|
517
|
+
|
|
518
|
+
// Simple case: content fits in one message
|
|
519
|
+
if (content.length <= MAX_LENGTH) {
|
|
520
|
+
return await thread.send(content)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Use marked's lexer to tokenize markdown content
|
|
524
|
+
const lexer = new Lexer()
|
|
525
|
+
const tokens = lexer.lex(content)
|
|
526
|
+
|
|
527
|
+
const chunks: string[] = []
|
|
528
|
+
let currentChunk = ''
|
|
529
|
+
|
|
530
|
+
// Process each token and add to chunks
|
|
531
|
+
for (const token of tokens) {
|
|
532
|
+
const tokenText = token.raw || ''
|
|
533
|
+
|
|
534
|
+
// If adding this token would exceed limit and we have content, flush current chunk
|
|
535
|
+
if (currentChunk && currentChunk.length + tokenText.length > MAX_LENGTH) {
|
|
536
|
+
chunks.push(currentChunk)
|
|
537
|
+
currentChunk = ''
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// If this single token is longer than MAX_LENGTH, split it
|
|
541
|
+
if (tokenText.length > MAX_LENGTH) {
|
|
542
|
+
if (currentChunk) {
|
|
543
|
+
chunks.push(currentChunk)
|
|
544
|
+
currentChunk = ''
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let remainingText = tokenText
|
|
548
|
+
while (remainingText.length > MAX_LENGTH) {
|
|
549
|
+
// Try to split at a newline if possible
|
|
550
|
+
let splitIndex = MAX_LENGTH
|
|
551
|
+
const newlineIndex = remainingText.lastIndexOf('\n', MAX_LENGTH - 1)
|
|
552
|
+
if (newlineIndex > MAX_LENGTH * 0.7) {
|
|
553
|
+
splitIndex = newlineIndex + 1
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
chunks.push(remainingText.slice(0, splitIndex))
|
|
557
|
+
remainingText = remainingText.slice(splitIndex)
|
|
558
|
+
}
|
|
559
|
+
currentChunk = remainingText
|
|
560
|
+
} else {
|
|
561
|
+
currentChunk += tokenText
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Add any remaining content
|
|
566
|
+
if (currentChunk) {
|
|
567
|
+
chunks.push(currentChunk)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Send all chunks
|
|
571
|
+
discordLogger.log(
|
|
572
|
+
`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
let firstMessage: Message | undefined
|
|
576
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
577
|
+
const chunk = chunks[i]
|
|
578
|
+
if (!chunk) continue
|
|
579
|
+
const message = await thread.send(chunk)
|
|
580
|
+
if (i === 0) firstMessage = message
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return firstMessage!
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
|
|
587
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
588
|
+
try {
|
|
589
|
+
const endpoints = [
|
|
590
|
+
`http://localhost:${port}/api/health`,
|
|
591
|
+
`http://localhost:${port}/`,
|
|
592
|
+
`http://localhost:${port}/api`,
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
for (const endpoint of endpoints) {
|
|
596
|
+
try {
|
|
597
|
+
const response = await fetch(endpoint)
|
|
598
|
+
if (response.status < 500) {
|
|
599
|
+
opencodeLogger.log(`Server ready on port `)
|
|
600
|
+
return true
|
|
601
|
+
}
|
|
602
|
+
} catch (e) {}
|
|
603
|
+
}
|
|
604
|
+
} catch (e) {}
|
|
605
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
606
|
+
}
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Server did not start on port ${port} after ${maxAttempts} seconds`,
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function processVoiceAttachment({
|
|
613
|
+
message,
|
|
614
|
+
thread,
|
|
615
|
+
projectDirectory,
|
|
616
|
+
isNewThread = false,
|
|
617
|
+
}: {
|
|
618
|
+
message: Message
|
|
619
|
+
thread: ThreadChannel
|
|
620
|
+
projectDirectory?: string
|
|
621
|
+
isNewThread?: boolean
|
|
622
|
+
}): Promise<string | null> {
|
|
623
|
+
const audioAttachment = Array.from(message.attachments.values()).find(
|
|
624
|
+
(attachment) => attachment.contentType?.startsWith('audio/'),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
if (!audioAttachment) return null
|
|
628
|
+
|
|
629
|
+
voiceLogger.log(
|
|
630
|
+
`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
await message.react('⏳')
|
|
634
|
+
await sendThreadMessage(thread, '🎤 Transcribing voice message...')
|
|
635
|
+
|
|
636
|
+
const audioResponse = await fetch(audioAttachment.url)
|
|
637
|
+
const audioBuffer = Buffer.from(await audioResponse.arrayBuffer())
|
|
638
|
+
|
|
639
|
+
voiceLogger.log(`Downloaded ${audioBuffer.length} bytes, transcribing...`)
|
|
640
|
+
|
|
641
|
+
// Get project file tree for context if directory is provided
|
|
642
|
+
let transcriptionPrompt = 'Discord voice message transcription'
|
|
643
|
+
|
|
644
|
+
if (projectDirectory) {
|
|
645
|
+
try {
|
|
646
|
+
voiceLogger.log(`Getting project file tree from ${projectDirectory}`)
|
|
647
|
+
// Use git ls-files to get tracked files, then pipe to tree
|
|
648
|
+
const execAsync = promisify(exec)
|
|
649
|
+
const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
|
|
650
|
+
cwd: projectDirectory,
|
|
651
|
+
})
|
|
652
|
+
const result = stdout
|
|
653
|
+
|
|
654
|
+
if (result) {
|
|
655
|
+
transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${result}\n\nPlease transcribe file names and paths accurately based on this context.`
|
|
656
|
+
voiceLogger.log(`Added project context to transcription prompt`)
|
|
657
|
+
}
|
|
658
|
+
} catch (e) {
|
|
659
|
+
voiceLogger.log(`Could not get project tree:`, e)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const transcription = await transcribeAudio({
|
|
664
|
+
audio: audioBuffer,
|
|
665
|
+
prompt: transcriptionPrompt,
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
voiceLogger.log(
|
|
669
|
+
`Transcription successful: "${transcription.slice(0, 50)}${transcription.length > 50 ? '...' : ''}"`,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
// Update thread name with transcribed content only for new threads
|
|
673
|
+
if (isNewThread) {
|
|
674
|
+
const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
675
|
+
if (threadName) {
|
|
676
|
+
try {
|
|
677
|
+
await Promise.race([
|
|
678
|
+
thread.setName(threadName),
|
|
679
|
+
new Promise((resolve) => setTimeout(resolve, 2000)),
|
|
680
|
+
])
|
|
681
|
+
discordLogger.log(`Updated thread name to: "${threadName}"`)
|
|
682
|
+
} catch (e) {
|
|
683
|
+
discordLogger.log(`Could not update thread name:`, e)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
await sendThreadMessage(
|
|
689
|
+
thread,
|
|
690
|
+
`📝 **Transcribed message:** ${escapeDiscordFormatting(transcription)}`,
|
|
691
|
+
)
|
|
692
|
+
return transcription
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Escape Discord formatting characters to prevent breaking code blocks and inline code
|
|
697
|
+
*/
|
|
698
|
+
function escapeDiscordFormatting(text: string): string {
|
|
699
|
+
return text
|
|
700
|
+
.replace(/```/g, '\\`\\`\\`') // Triple backticks
|
|
701
|
+
.replace(/````/g, '\\`\\`\\`\\`') // Quadruple backticks
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function escapeInlineCode(text: string): string {
|
|
705
|
+
return text
|
|
706
|
+
.replace(/``/g, '\\`\\`') // Double backticks
|
|
707
|
+
.replace(/(?<!\\)`(?!`)/g, '\\`') // Single backticks (not already escaped or part of double/triple)
|
|
708
|
+
.replace(/\|\|/g, '\\|\\|') // Double pipes (spoiler syntax)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function resolveTextChannel(
|
|
712
|
+
channel: TextChannel | ThreadChannel | null | undefined,
|
|
713
|
+
): TextChannel | null {
|
|
714
|
+
if (!channel) {
|
|
715
|
+
return null
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (channel.type === ChannelType.GuildText) {
|
|
719
|
+
return channel as TextChannel
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (
|
|
723
|
+
channel.type === ChannelType.PublicThread ||
|
|
724
|
+
channel.type === ChannelType.PrivateThread ||
|
|
725
|
+
channel.type === ChannelType.AnnouncementThread
|
|
726
|
+
) {
|
|
727
|
+
const parent = channel.parent
|
|
728
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
729
|
+
return parent as TextChannel
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return null
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function getKimakiMetadata(textChannel: TextChannel | null): {
|
|
737
|
+
projectDirectory?: string
|
|
738
|
+
channelAppId?: string
|
|
739
|
+
} {
|
|
740
|
+
if (!textChannel?.topic) {
|
|
741
|
+
return {}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const extracted = extractTagsArrays({
|
|
745
|
+
xml: textChannel.topic,
|
|
746
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
750
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
751
|
+
|
|
752
|
+
return { projectDirectory, channelAppId }
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export async function initializeOpencodeForDirectory(directory: string) {
|
|
756
|
+
// console.log(`[OPENCODE] Initializing for directory: ${directory}`)
|
|
757
|
+
|
|
758
|
+
// Check if we already have a server for this directory
|
|
759
|
+
const existing = opencodeServers.get(directory)
|
|
760
|
+
if (existing && !existing.process.killed) {
|
|
761
|
+
opencodeLogger.log(
|
|
762
|
+
`Reusing existing server on port ${existing.port} for directory: ${directory}`,
|
|
763
|
+
)
|
|
764
|
+
return () => {
|
|
765
|
+
const entry = opencodeServers.get(directory)
|
|
766
|
+
if (!entry?.client) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
`OpenCode server for directory "${directory}" is in an error state (no client available)`,
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
return entry.client
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const port = await getOpenPort()
|
|
776
|
+
// console.log(
|
|
777
|
+
// `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
|
|
778
|
+
// )
|
|
779
|
+
|
|
780
|
+
const serverProcess = spawn(
|
|
781
|
+
'opencode',
|
|
782
|
+
['serve', '--port', port.toString()],
|
|
783
|
+
{
|
|
784
|
+
stdio: 'pipe',
|
|
785
|
+
detached: false,
|
|
786
|
+
cwd: directory,
|
|
787
|
+
env: {
|
|
788
|
+
...process.env,
|
|
789
|
+
OPENCODE_PORT: port.toString(),
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
serverProcess.stdout?.on('data', (data) => {
|
|
795
|
+
opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`)
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
serverProcess.stderr?.on('data', (data) => {
|
|
799
|
+
opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`)
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
serverProcess.on('error', (error) => {
|
|
803
|
+
opencodeLogger.error(`Failed to start server on port :`, port, error)
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
serverProcess.on('exit', (code) => {
|
|
807
|
+
opencodeLogger.log(
|
|
808
|
+
`Opencode server on ${directory} exited with code:`,
|
|
809
|
+
code,
|
|
810
|
+
)
|
|
811
|
+
opencodeServers.delete(directory)
|
|
812
|
+
if (code !== 0) {
|
|
813
|
+
const retryCount = serverRetryCount.get(directory) || 0
|
|
814
|
+
if (retryCount < 5) {
|
|
815
|
+
serverRetryCount.set(directory, retryCount + 1)
|
|
816
|
+
opencodeLogger.log(
|
|
817
|
+
`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`,
|
|
818
|
+
)
|
|
819
|
+
initializeOpencodeForDirectory(directory).catch((e) => {
|
|
820
|
+
opencodeLogger.error(`Failed to restart opencode server:`, e)
|
|
821
|
+
})
|
|
822
|
+
} else {
|
|
823
|
+
opencodeLogger.error(
|
|
824
|
+
`Server for ${directory} crashed too many times (5), not restarting`,
|
|
825
|
+
)
|
|
826
|
+
}
|
|
827
|
+
} else {
|
|
828
|
+
// Reset retry count on clean exit
|
|
829
|
+
serverRetryCount.delete(directory)
|
|
830
|
+
}
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
await waitForServer(port)
|
|
834
|
+
const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` })
|
|
835
|
+
|
|
836
|
+
opencodeServers.set(directory, {
|
|
837
|
+
process: serverProcess,
|
|
838
|
+
client,
|
|
839
|
+
port,
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
return () => {
|
|
843
|
+
const entry = opencodeServers.get(directory)
|
|
844
|
+
if (!entry?.client) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`OpenCode server for directory "${directory}" is in an error state (no client available)`,
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
return entry.client
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function formatPart(part: Part): string {
|
|
854
|
+
switch (part.type) {
|
|
855
|
+
case 'text':
|
|
856
|
+
return escapeDiscordFormatting(part.text || '')
|
|
857
|
+
case 'reasoning':
|
|
858
|
+
if (!part.text?.trim()) return ''
|
|
859
|
+
return `▪︎ thinking: ${escapeDiscordFormatting(part.text || '')}`
|
|
860
|
+
case 'tool':
|
|
861
|
+
if (part.state.status === 'completed' || part.state.status === 'error') {
|
|
862
|
+
// console.log(part)
|
|
863
|
+
// Escape triple backticks so Discord does not break code blocks
|
|
864
|
+
let language = ''
|
|
865
|
+
let outputToDisplay = ''
|
|
866
|
+
if (part.tool === 'bash') {
|
|
867
|
+
outputToDisplay =
|
|
868
|
+
part.state.status === 'completed'
|
|
869
|
+
? part.state.output
|
|
870
|
+
: part.state.error
|
|
871
|
+
outputToDisplay ||= ''
|
|
872
|
+
}
|
|
873
|
+
if (part.tool === 'edit') {
|
|
874
|
+
outputToDisplay = (part.state.input?.newString as string) || ''
|
|
875
|
+
language = path.extname((part.state.input.filePath as string) || '')
|
|
876
|
+
}
|
|
877
|
+
if (part.tool === 'todowrite') {
|
|
878
|
+
const todos =
|
|
879
|
+
(part.state.input?.todos as {
|
|
880
|
+
content: string
|
|
881
|
+
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
|
882
|
+
}[]) || []
|
|
883
|
+
outputToDisplay = todos
|
|
884
|
+
.map((todo) => {
|
|
885
|
+
let statusIcon = '▢'
|
|
886
|
+
switch (todo.status) {
|
|
887
|
+
case 'pending':
|
|
888
|
+
statusIcon = '▢'
|
|
889
|
+
break
|
|
890
|
+
case 'in_progress':
|
|
891
|
+
statusIcon = '●'
|
|
892
|
+
break
|
|
893
|
+
case 'completed':
|
|
894
|
+
statusIcon = '■'
|
|
895
|
+
break
|
|
896
|
+
case 'cancelled':
|
|
897
|
+
statusIcon = '■'
|
|
898
|
+
break
|
|
899
|
+
}
|
|
900
|
+
return `\`${statusIcon}\` ${todo.content}`
|
|
901
|
+
})
|
|
902
|
+
.filter(Boolean)
|
|
903
|
+
.join('\n')
|
|
904
|
+
language = ''
|
|
905
|
+
}
|
|
906
|
+
if (part.tool === 'write') {
|
|
907
|
+
outputToDisplay = (part.state.input?.content as string) || ''
|
|
908
|
+
language = path.extname((part.state.input.filePath as string) || '')
|
|
909
|
+
}
|
|
910
|
+
outputToDisplay =
|
|
911
|
+
outputToDisplay.length > 500
|
|
912
|
+
? outputToDisplay.slice(0, 497) + `…`
|
|
913
|
+
: outputToDisplay
|
|
914
|
+
|
|
915
|
+
// Escape Discord formatting characters that could break code blocks
|
|
916
|
+
outputToDisplay = escapeDiscordFormatting(outputToDisplay)
|
|
917
|
+
|
|
918
|
+
let toolTitle =
|
|
919
|
+
part.state.status === 'completed' ? part.state.title || '' : 'error'
|
|
920
|
+
// Escape backticks in the title before wrapping in backticks
|
|
921
|
+
if (toolTitle) {
|
|
922
|
+
toolTitle = `\`${escapeInlineCode(toolTitle)}\``
|
|
923
|
+
}
|
|
924
|
+
const icon =
|
|
925
|
+
part.state.status === 'completed'
|
|
926
|
+
? '◼︎'
|
|
927
|
+
: part.state.status === 'error'
|
|
928
|
+
? '✖️'
|
|
929
|
+
: ''
|
|
930
|
+
const title = `${icon} ${part.tool} ${toolTitle}`
|
|
931
|
+
|
|
932
|
+
let text = title
|
|
933
|
+
|
|
934
|
+
if (outputToDisplay) {
|
|
935
|
+
// Don't wrap todowrite output in code blocks
|
|
936
|
+
if (part.tool === 'todowrite') {
|
|
937
|
+
text += '\n\n' + outputToDisplay
|
|
938
|
+
} else {
|
|
939
|
+
if (language.startsWith('.')) {
|
|
940
|
+
language = language.slice(1)
|
|
941
|
+
}
|
|
942
|
+
text += '\n\n```' + language + '\n' + outputToDisplay + '\n```'
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return text
|
|
946
|
+
}
|
|
947
|
+
return ''
|
|
948
|
+
case 'file':
|
|
949
|
+
return `📄 ${part.filename || 'File'}`
|
|
950
|
+
case 'step-start':
|
|
951
|
+
case 'step-finish':
|
|
952
|
+
case 'patch':
|
|
953
|
+
return ''
|
|
954
|
+
case 'agent':
|
|
955
|
+
return `◼︎ agent ${part.id}`
|
|
956
|
+
case 'snapshot':
|
|
957
|
+
return `◼︎ snapshot ${part.snapshot}`
|
|
958
|
+
default:
|
|
959
|
+
discordLogger.warn('Unknown part type:', part)
|
|
960
|
+
return ''
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export async function createDiscordClient() {
|
|
965
|
+
return new Client({
|
|
966
|
+
intents: [
|
|
967
|
+
GatewayIntentBits.Guilds,
|
|
968
|
+
GatewayIntentBits.GuildMessages,
|
|
969
|
+
GatewayIntentBits.MessageContent,
|
|
970
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
971
|
+
],
|
|
972
|
+
partials: [
|
|
973
|
+
Partials.Channel,
|
|
974
|
+
Partials.Message,
|
|
975
|
+
Partials.User,
|
|
976
|
+
Partials.ThreadMember,
|
|
977
|
+
],
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function handleOpencodeSession(
|
|
982
|
+
prompt: string,
|
|
983
|
+
thread: ThreadChannel,
|
|
984
|
+
projectDirectory?: string,
|
|
985
|
+
originalMessage?: Message,
|
|
986
|
+
) {
|
|
987
|
+
voiceLogger.log(
|
|
988
|
+
`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
// Track session start time
|
|
992
|
+
const sessionStartTime = Date.now()
|
|
993
|
+
|
|
994
|
+
// Add processing reaction to original message
|
|
995
|
+
if (originalMessage) {
|
|
996
|
+
try {
|
|
997
|
+
await originalMessage.react('⏳')
|
|
998
|
+
discordLogger.log(`Added processing reaction to message`)
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
discordLogger.log(`Could not add processing reaction:`, e)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Use default directory if not specified
|
|
1005
|
+
const directory = projectDirectory || process.cwd()
|
|
1006
|
+
sessionLogger.log(`Using directory: ${directory}`)
|
|
1007
|
+
|
|
1008
|
+
// Note: We'll cancel the existing request after we have the session ID
|
|
1009
|
+
|
|
1010
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
1011
|
+
|
|
1012
|
+
// Get session ID from database
|
|
1013
|
+
const row = getDatabase()
|
|
1014
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
1015
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
1016
|
+
let sessionId = row?.session_id
|
|
1017
|
+
let session
|
|
1018
|
+
|
|
1019
|
+
if (sessionId) {
|
|
1020
|
+
sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
|
|
1021
|
+
try {
|
|
1022
|
+
const sessionResponse = await getClient().session.get({
|
|
1023
|
+
path: { id: sessionId },
|
|
1024
|
+
})
|
|
1025
|
+
session = sessionResponse.data
|
|
1026
|
+
sessionLogger.log(`Successfully reused session ${sessionId}`)
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
voiceLogger.log(
|
|
1029
|
+
`[SESSION] Session ${sessionId} not found, will create new one`,
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (!session) {
|
|
1035
|
+
voiceLogger.log(
|
|
1036
|
+
`[SESSION] Creating new session with title: "${prompt.slice(0, 80)}"`,
|
|
1037
|
+
)
|
|
1038
|
+
const sessionResponse = await getClient().session.create({
|
|
1039
|
+
body: { title: prompt.slice(0, 80) },
|
|
1040
|
+
})
|
|
1041
|
+
session = sessionResponse.data
|
|
1042
|
+
sessionLogger.log(`Created new session ${session?.id}`)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (!session) {
|
|
1046
|
+
throw new Error('Failed to create or get session')
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Store session ID in database
|
|
1050
|
+
getDatabase()
|
|
1051
|
+
.prepare(
|
|
1052
|
+
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
|
|
1053
|
+
)
|
|
1054
|
+
.run(thread.id, session.id)
|
|
1055
|
+
dbLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
|
|
1056
|
+
|
|
1057
|
+
// Cancel any existing request for this session
|
|
1058
|
+
const existingController = abortControllers.get(session.id)
|
|
1059
|
+
if (existingController) {
|
|
1060
|
+
voiceLogger.log(
|
|
1061
|
+
`[ABORT] Cancelling existing request for session: ${session.id}`,
|
|
1062
|
+
)
|
|
1063
|
+
existingController.abort('New request started')
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (abortControllers.has(session.id)) {
|
|
1067
|
+
abortControllers.get(session.id)?.abort('new reply')
|
|
1068
|
+
}
|
|
1069
|
+
const abortController = new AbortController()
|
|
1070
|
+
// Store this controller for this session
|
|
1071
|
+
abortControllers.set(session.id, abortController)
|
|
1072
|
+
|
|
1073
|
+
const eventsResult = await getClient().event.subscribe({
|
|
1074
|
+
signal: abortController.signal,
|
|
1075
|
+
})
|
|
1076
|
+
const events = eventsResult.stream
|
|
1077
|
+
sessionLogger.log(`Subscribed to OpenCode events`)
|
|
1078
|
+
|
|
1079
|
+
// Load existing part-message mappings from database
|
|
1080
|
+
const partIdToMessage = new Map<string, Message>()
|
|
1081
|
+
const existingParts = getDatabase()
|
|
1082
|
+
.prepare(
|
|
1083
|
+
'SELECT part_id, message_id FROM part_messages WHERE thread_id = ?',
|
|
1084
|
+
)
|
|
1085
|
+
.all(thread.id) as { part_id: string; message_id: string }[]
|
|
1086
|
+
|
|
1087
|
+
// Pre-populate map with existing messages
|
|
1088
|
+
for (const row of existingParts) {
|
|
1089
|
+
try {
|
|
1090
|
+
const message = await thread.messages.fetch(row.message_id)
|
|
1091
|
+
if (message) {
|
|
1092
|
+
partIdToMessage.set(row.part_id, message)
|
|
1093
|
+
}
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
voiceLogger.log(
|
|
1096
|
+
`Could not fetch message ${row.message_id} for part ${row.part_id}`,
|
|
1097
|
+
)
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
let currentParts: Part[] = []
|
|
1102
|
+
let stopTyping: (() => void) | null = null
|
|
1103
|
+
|
|
1104
|
+
const sendPartMessage = async (part: Part) => {
|
|
1105
|
+
const content = formatPart(part) + '\n\n'
|
|
1106
|
+
if (!content.trim() || content.length === 0) {
|
|
1107
|
+
discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Skip if already sent
|
|
1112
|
+
if (partIdToMessage.has(part.id)) {
|
|
1113
|
+
voiceLogger.log(
|
|
1114
|
+
`[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`,
|
|
1115
|
+
)
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
try {
|
|
1120
|
+
voiceLogger.log(
|
|
1121
|
+
`[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
const firstMessage = await sendThreadMessage(thread, content)
|
|
1125
|
+
partIdToMessage.set(part.id, firstMessage)
|
|
1126
|
+
voiceLogger.log(
|
|
1127
|
+
`[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`,
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
// Store part-message mapping in database
|
|
1131
|
+
getDatabase()
|
|
1132
|
+
.prepare(
|
|
1133
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
1134
|
+
)
|
|
1135
|
+
.run(part.id, firstMessage.id, thread.id)
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error)
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const eventHandler = async () => {
|
|
1142
|
+
// Local typing function for this session
|
|
1143
|
+
// Outer-scoped interval for typing notifications. Only one at a time.
|
|
1144
|
+
let typingInterval: NodeJS.Timeout | null = null
|
|
1145
|
+
|
|
1146
|
+
function startTyping(thread: ThreadChannel): () => void {
|
|
1147
|
+
if (abortController.signal.aborted) {
|
|
1148
|
+
discordLogger.log(`Not starting typing, already aborted`)
|
|
1149
|
+
return () => {}
|
|
1150
|
+
}
|
|
1151
|
+
discordLogger.log(`Starting typing for thread ${thread.id}`)
|
|
1152
|
+
|
|
1153
|
+
// Clear any previous typing interval
|
|
1154
|
+
if (typingInterval) {
|
|
1155
|
+
clearInterval(typingInterval)
|
|
1156
|
+
typingInterval = null
|
|
1157
|
+
discordLogger.log(`Cleared previous typing interval`)
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Send initial typing
|
|
1161
|
+
thread.sendTyping().catch((e) => {
|
|
1162
|
+
discordLogger.log(`Failed to send initial typing: ${e}`)
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
// Set up interval to send typing every 8 seconds
|
|
1166
|
+
typingInterval = setInterval(() => {
|
|
1167
|
+
thread.sendTyping().catch((e) => {
|
|
1168
|
+
discordLogger.log(`Failed to send periodic typing: ${e}`)
|
|
1169
|
+
})
|
|
1170
|
+
}, 8000)
|
|
1171
|
+
|
|
1172
|
+
// Only add listener if not already aborted
|
|
1173
|
+
if (!abortController.signal.aborted) {
|
|
1174
|
+
abortController.signal.addEventListener(
|
|
1175
|
+
'abort',
|
|
1176
|
+
() => {
|
|
1177
|
+
if (typingInterval) {
|
|
1178
|
+
clearInterval(typingInterval)
|
|
1179
|
+
typingInterval = null
|
|
1180
|
+
}
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
once: true,
|
|
1184
|
+
},
|
|
1185
|
+
)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Return stop function
|
|
1189
|
+
return () => {
|
|
1190
|
+
if (typingInterval) {
|
|
1191
|
+
clearInterval(typingInterval)
|
|
1192
|
+
typingInterval = null
|
|
1193
|
+
discordLogger.log(`Stopped typing for thread ${thread.id}`)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
try {
|
|
1199
|
+
let assistantMessageId: string | undefined
|
|
1200
|
+
|
|
1201
|
+
for await (const event of events) {
|
|
1202
|
+
sessionLogger.log(`Received: ${event.type}`)
|
|
1203
|
+
if (event.type === 'message.updated') {
|
|
1204
|
+
const msg = event.properties.info
|
|
1205
|
+
|
|
1206
|
+
if (msg.sessionID !== session.id) {
|
|
1207
|
+
voiceLogger.log(
|
|
1208
|
+
`[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`,
|
|
1209
|
+
)
|
|
1210
|
+
continue
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Track assistant message ID
|
|
1214
|
+
if (msg.role === 'assistant') {
|
|
1215
|
+
assistantMessageId = msg.id
|
|
1216
|
+
voiceLogger.log(
|
|
1217
|
+
`[EVENT] Tracking assistant message ${assistantMessageId}`,
|
|
1218
|
+
)
|
|
1219
|
+
} else {
|
|
1220
|
+
sessionLogger.log(`Message role: ${msg.role}`)
|
|
1221
|
+
}
|
|
1222
|
+
} else if (event.type === 'message.part.updated') {
|
|
1223
|
+
const part = event.properties.part
|
|
1224
|
+
|
|
1225
|
+
if (part.sessionID !== session.id) {
|
|
1226
|
+
voiceLogger.log(
|
|
1227
|
+
`[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`,
|
|
1228
|
+
)
|
|
1229
|
+
continue
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Only process parts from assistant messages
|
|
1233
|
+
if (part.messageID !== assistantMessageId) {
|
|
1234
|
+
voiceLogger.log(
|
|
1235
|
+
`[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`,
|
|
1236
|
+
)
|
|
1237
|
+
continue
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const existingIndex = currentParts.findIndex(
|
|
1241
|
+
(p: Part) => p.id === part.id,
|
|
1242
|
+
)
|
|
1243
|
+
if (existingIndex >= 0) {
|
|
1244
|
+
currentParts[existingIndex] = part
|
|
1245
|
+
} else {
|
|
1246
|
+
currentParts.push(part)
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
voiceLogger.log(
|
|
1250
|
+
`[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`,
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
// Start typing on step-start
|
|
1254
|
+
if (part.type === 'step-start') {
|
|
1255
|
+
stopTyping = startTyping(thread)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Check if this is a step-finish part
|
|
1259
|
+
if (part.type === 'step-finish') {
|
|
1260
|
+
// Send all parts accumulated so far to Discord
|
|
1261
|
+
voiceLogger.log(
|
|
1262
|
+
`[STEP-FINISH] Sending ${currentParts.length} parts to Discord`,
|
|
1263
|
+
)
|
|
1264
|
+
for (const p of currentParts) {
|
|
1265
|
+
// Skip step-start and step-finish parts as they have no visual content
|
|
1266
|
+
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
1267
|
+
await sendPartMessage(p)
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
// start typing in a moment, so that if the session finished, because step-finish is at the end of the message, we do not show typing status
|
|
1271
|
+
setTimeout(() => {
|
|
1272
|
+
if (abortController.signal.aborted) return
|
|
1273
|
+
stopTyping = startTyping(thread)
|
|
1274
|
+
}, 300)
|
|
1275
|
+
}
|
|
1276
|
+
} else if (event.type === 'session.error') {
|
|
1277
|
+
sessionLogger.error(`ERROR:`, event.properties)
|
|
1278
|
+
if (event.properties.sessionID === session.id) {
|
|
1279
|
+
const errorData = event.properties.error
|
|
1280
|
+
const errorMessage = errorData?.data?.message || 'Unknown error'
|
|
1281
|
+
sessionLogger.error(`Sending error to thread: ${errorMessage}`)
|
|
1282
|
+
await sendThreadMessage(
|
|
1283
|
+
thread,
|
|
1284
|
+
`✗ opencode session error: ${errorMessage}`,
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
// Update reaction to error
|
|
1288
|
+
if (originalMessage) {
|
|
1289
|
+
try {
|
|
1290
|
+
await originalMessage.reactions.removeAll()
|
|
1291
|
+
await originalMessage.react('❌')
|
|
1292
|
+
voiceLogger.log(
|
|
1293
|
+
`[REACTION] Added error reaction due to session error`,
|
|
1294
|
+
)
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
discordLogger.log(`Could not update reaction:`, e)
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
} else {
|
|
1300
|
+
voiceLogger.log(
|
|
1301
|
+
`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`,
|
|
1302
|
+
)
|
|
1303
|
+
}
|
|
1304
|
+
break
|
|
1305
|
+
} else if (event.type === 'file.edited') {
|
|
1306
|
+
sessionLogger.log(`File edited event received`)
|
|
1307
|
+
} else {
|
|
1308
|
+
sessionLogger.log(`Unhandled event type: ${event.type}`)
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
if (e instanceof Error && e.name === 'AbortError') {
|
|
1313
|
+
// Ignore abort controller errors as requested
|
|
1314
|
+
sessionLogger.log(
|
|
1315
|
+
'AbortController aborted event handling (normal exit)',
|
|
1316
|
+
)
|
|
1317
|
+
return
|
|
1318
|
+
}
|
|
1319
|
+
sessionLogger.error(`Unexpected error in event handling code`, e)
|
|
1320
|
+
throw e
|
|
1321
|
+
} finally {
|
|
1322
|
+
// Send any remaining parts that weren't sent
|
|
1323
|
+
voiceLogger.log(
|
|
1324
|
+
`[CLEANUP] Checking ${currentParts.length} parts for unsent messages`,
|
|
1325
|
+
)
|
|
1326
|
+
let unsentCount = 0
|
|
1327
|
+
for (const part of currentParts) {
|
|
1328
|
+
if (!partIdToMessage.has(part.id)) {
|
|
1329
|
+
unsentCount++
|
|
1330
|
+
voiceLogger.log(
|
|
1331
|
+
`[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`,
|
|
1332
|
+
)
|
|
1333
|
+
try {
|
|
1334
|
+
await sendPartMessage(part)
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
sessionLogger.log(
|
|
1337
|
+
`Failed to send part ${part.id} during cleanup:`,
|
|
1338
|
+
error,
|
|
1339
|
+
)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (unsentCount === 0) {
|
|
1344
|
+
sessionLogger.log(`All parts were already sent`)
|
|
1345
|
+
} else {
|
|
1346
|
+
sessionLogger.log(`Sent ${unsentCount} previously unsent parts`)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Stop typing when session ends
|
|
1350
|
+
if (stopTyping) {
|
|
1351
|
+
stopTyping()
|
|
1352
|
+
stopTyping = null
|
|
1353
|
+
sessionLogger.log(`Stopped typing for session`)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Only send duration message if request was not aborted or was aborted with 'finished' reason
|
|
1357
|
+
if (
|
|
1358
|
+
!abortController.signal.aborted ||
|
|
1359
|
+
abortController.signal.reason === 'finished'
|
|
1360
|
+
) {
|
|
1361
|
+
const sessionDuration = prettyMilliseconds(
|
|
1362
|
+
Date.now() - sessionStartTime,
|
|
1363
|
+
)
|
|
1364
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}_`)
|
|
1365
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}`)
|
|
1366
|
+
} else {
|
|
1367
|
+
sessionLogger.log(
|
|
1368
|
+
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
1369
|
+
)
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
try {
|
|
1375
|
+
voiceLogger.log(
|
|
1376
|
+
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
// Start the event handler
|
|
1380
|
+
const eventHandlerPromise = eventHandler()
|
|
1381
|
+
|
|
1382
|
+
const response = await getClient().session.prompt({
|
|
1383
|
+
path: { id: session.id },
|
|
1384
|
+
body: {
|
|
1385
|
+
parts: [{ type: 'text', text: prompt }],
|
|
1386
|
+
},
|
|
1387
|
+
signal: abortController.signal,
|
|
1388
|
+
})
|
|
1389
|
+
abortController.abort('finished')
|
|
1390
|
+
|
|
1391
|
+
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1392
|
+
|
|
1393
|
+
abortControllers.delete(session.id)
|
|
1394
|
+
|
|
1395
|
+
// Update reaction to success
|
|
1396
|
+
if (originalMessage) {
|
|
1397
|
+
try {
|
|
1398
|
+
await originalMessage.reactions.removeAll()
|
|
1399
|
+
await originalMessage.react('✅')
|
|
1400
|
+
discordLogger.log(`Added success reaction to message`)
|
|
1401
|
+
} catch (e) {
|
|
1402
|
+
discordLogger.log(`Could not update reaction:`, e)
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
return { sessionID: session.id, result: response.data }
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
sessionLogger.error(`ERROR: Failed to send prompt:`, error)
|
|
1409
|
+
|
|
1410
|
+
if (!(error instanceof Error && error.name === 'AbortError')) {
|
|
1411
|
+
abortController.abort('error')
|
|
1412
|
+
|
|
1413
|
+
if (originalMessage) {
|
|
1414
|
+
try {
|
|
1415
|
+
await originalMessage.reactions.removeAll()
|
|
1416
|
+
await originalMessage.react('❌')
|
|
1417
|
+
discordLogger.log(`Added error reaction to message`)
|
|
1418
|
+
} catch (e) {
|
|
1419
|
+
discordLogger.log(`Could not update reaction:`, e)
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
await sendThreadMessage(
|
|
1423
|
+
thread,
|
|
1424
|
+
`✗ Unexpected bot Error: ${error instanceof Error ? error.stack || error.message : String(error)}`,
|
|
1425
|
+
)
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
export type ChannelWithTags = {
|
|
1431
|
+
id: string
|
|
1432
|
+
name: string
|
|
1433
|
+
description: string | null
|
|
1434
|
+
kimakiDirectory?: string
|
|
1435
|
+
kimakiApp?: string
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
export async function getChannelsWithDescriptions(
|
|
1439
|
+
guild: Guild,
|
|
1440
|
+
): Promise<ChannelWithTags[]> {
|
|
1441
|
+
const channels: ChannelWithTags[] = []
|
|
1442
|
+
|
|
1443
|
+
guild.channels.cache
|
|
1444
|
+
.filter((channel) => channel.isTextBased())
|
|
1445
|
+
.forEach((channel) => {
|
|
1446
|
+
const textChannel = channel as TextChannel
|
|
1447
|
+
const description = textChannel.topic || null
|
|
1448
|
+
|
|
1449
|
+
let kimakiDirectory: string | undefined
|
|
1450
|
+
let kimakiApp: string | undefined
|
|
1451
|
+
|
|
1452
|
+
if (description) {
|
|
1453
|
+
const extracted = extractTagsArrays({
|
|
1454
|
+
xml: description,
|
|
1455
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1456
|
+
})
|
|
1457
|
+
|
|
1458
|
+
kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
1459
|
+
kimakiApp = extracted['kimaki.app']?.[0]?.trim()
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
channels.push({
|
|
1463
|
+
id: textChannel.id,
|
|
1464
|
+
name: textChannel.name,
|
|
1465
|
+
description,
|
|
1466
|
+
kimakiDirectory,
|
|
1467
|
+
kimakiApp,
|
|
1468
|
+
})
|
|
1469
|
+
})
|
|
1470
|
+
|
|
1471
|
+
return channels
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export async function startDiscordBot({
|
|
1475
|
+
token,
|
|
1476
|
+
appId,
|
|
1477
|
+
discordClient,
|
|
1478
|
+
}: StartOptions & { discordClient?: Client }) {
|
|
1479
|
+
if (!discordClient) {
|
|
1480
|
+
discordClient = await createDiscordClient()
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Get the app ID for this bot instance
|
|
1484
|
+
let currentAppId: string | undefined = appId
|
|
1485
|
+
|
|
1486
|
+
discordClient.once(Events.ClientReady, async (c) => {
|
|
1487
|
+
discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
|
|
1488
|
+
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
|
|
1489
|
+
discordLogger.log(`Bot user ID: ${c.user.id}`)
|
|
1490
|
+
|
|
1491
|
+
// If appId wasn't provided, fetch it from the application
|
|
1492
|
+
if (!currentAppId) {
|
|
1493
|
+
await c.application?.fetch()
|
|
1494
|
+
currentAppId = c.application?.id
|
|
1495
|
+
|
|
1496
|
+
if (!currentAppId) {
|
|
1497
|
+
discordLogger.error('Could not get application ID')
|
|
1498
|
+
throw new Error('Failed to get bot application ID')
|
|
1499
|
+
}
|
|
1500
|
+
discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
|
|
1501
|
+
} else {
|
|
1502
|
+
discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// List all guilds and channels that belong to this bot
|
|
1506
|
+
for (const guild of c.guilds.cache.values()) {
|
|
1507
|
+
discordLogger.log(`${guild.name} (${guild.id})`)
|
|
1508
|
+
|
|
1509
|
+
const channels = await getChannelsWithDescriptions(guild)
|
|
1510
|
+
// Only show channels that belong to this bot
|
|
1511
|
+
const kimakiChannels = channels.filter(
|
|
1512
|
+
(ch) =>
|
|
1513
|
+
ch.kimakiDirectory &&
|
|
1514
|
+
(!ch.kimakiApp || ch.kimakiApp === currentAppId),
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
if (kimakiChannels.length > 0) {
|
|
1518
|
+
discordLogger.log(
|
|
1519
|
+
` Found ${kimakiChannels.length} channel(s) for this bot:`,
|
|
1520
|
+
)
|
|
1521
|
+
for (const channel of kimakiChannels) {
|
|
1522
|
+
discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`)
|
|
1523
|
+
}
|
|
1524
|
+
} else {
|
|
1525
|
+
discordLogger.log(` No channels for this bot`)
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
voiceLogger.log(
|
|
1530
|
+
`[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`,
|
|
1531
|
+
)
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
discordClient.on(Events.MessageCreate, async (message: Message) => {
|
|
1535
|
+
try {
|
|
1536
|
+
if (message.author?.bot) {
|
|
1537
|
+
voiceLogger.log(
|
|
1538
|
+
`[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`,
|
|
1539
|
+
)
|
|
1540
|
+
return
|
|
1541
|
+
}
|
|
1542
|
+
if (message.partial) {
|
|
1543
|
+
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
1544
|
+
try {
|
|
1545
|
+
await message.fetch()
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
discordLogger.log(
|
|
1548
|
+
`Failed to fetch partial message ${message.id}:`,
|
|
1549
|
+
error,
|
|
1550
|
+
)
|
|
1551
|
+
return
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Check if user is authoritative (server owner or has admin permissions)
|
|
1556
|
+
if (message.guild && message.member) {
|
|
1557
|
+
const isOwner = message.member.id === message.guild.ownerId
|
|
1558
|
+
const isAdmin = message.member.permissions.has(
|
|
1559
|
+
PermissionsBitField.Flags.Administrator,
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
if (!isOwner && !isAdmin) {
|
|
1563
|
+
voiceLogger.log(
|
|
1564
|
+
`[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`,
|
|
1565
|
+
)
|
|
1566
|
+
return
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
voiceLogger.log(
|
|
1570
|
+
`[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`,
|
|
1571
|
+
)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
const channel = message.channel
|
|
1575
|
+
const isThread = [
|
|
1576
|
+
ChannelType.PublicThread,
|
|
1577
|
+
ChannelType.PrivateThread,
|
|
1578
|
+
ChannelType.AnnouncementThread,
|
|
1579
|
+
].includes(channel.type)
|
|
1580
|
+
|
|
1581
|
+
// For existing threads, check if session exists
|
|
1582
|
+
if (isThread) {
|
|
1583
|
+
const thread = channel as ThreadChannel
|
|
1584
|
+
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
|
|
1585
|
+
|
|
1586
|
+
const row = getDatabase()
|
|
1587
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
1588
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
1589
|
+
|
|
1590
|
+
if (!row) {
|
|
1591
|
+
discordLogger.log(`No session found for thread ${thread.id}`)
|
|
1592
|
+
return
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
voiceLogger.log(
|
|
1596
|
+
`[SESSION] Found session ${row.session_id} for thread ${thread.id}`,
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
// Get project directory and app ID from parent channel
|
|
1600
|
+
const parent = thread.parent as TextChannel | null
|
|
1601
|
+
let projectDirectory: string | undefined
|
|
1602
|
+
let channelAppId: string | undefined
|
|
1603
|
+
|
|
1604
|
+
if (parent?.topic) {
|
|
1605
|
+
const extracted = extractTagsArrays({
|
|
1606
|
+
xml: parent.topic,
|
|
1607
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1608
|
+
})
|
|
1609
|
+
|
|
1610
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
1611
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Check if this channel belongs to current bot instance
|
|
1615
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
1616
|
+
voiceLogger.log(
|
|
1617
|
+
`[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
1618
|
+
)
|
|
1619
|
+
return
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
1623
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
1624
|
+
await sendThreadMessage(
|
|
1625
|
+
thread,
|
|
1626
|
+
`✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
1627
|
+
)
|
|
1628
|
+
return
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Handle voice message if present
|
|
1632
|
+
let messageContent = message.content || ''
|
|
1633
|
+
|
|
1634
|
+
const transcription = await processVoiceAttachment({
|
|
1635
|
+
message,
|
|
1636
|
+
thread,
|
|
1637
|
+
projectDirectory,
|
|
1638
|
+
})
|
|
1639
|
+
if (transcription) {
|
|
1640
|
+
messageContent = transcription
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
await handleOpencodeSession(
|
|
1644
|
+
messageContent,
|
|
1645
|
+
thread,
|
|
1646
|
+
projectDirectory,
|
|
1647
|
+
message,
|
|
1648
|
+
)
|
|
1649
|
+
return
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// For text channels, start new sessions with kimaki.directory tag
|
|
1653
|
+
if (channel.type === ChannelType.GuildText) {
|
|
1654
|
+
const textChannel = channel as TextChannel
|
|
1655
|
+
voiceLogger.log(
|
|
1656
|
+
`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
if (!textChannel.topic) {
|
|
1660
|
+
voiceLogger.log(
|
|
1661
|
+
`[IGNORED] Channel #${textChannel.name} has no description`,
|
|
1662
|
+
)
|
|
1663
|
+
return
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const extracted = extractTagsArrays({
|
|
1667
|
+
xml: textChannel.topic,
|
|
1668
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1669
|
+
})
|
|
1670
|
+
|
|
1671
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
1672
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
1673
|
+
|
|
1674
|
+
if (!projectDirectory) {
|
|
1675
|
+
voiceLogger.log(
|
|
1676
|
+
`[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`,
|
|
1677
|
+
)
|
|
1678
|
+
return
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Check if this channel belongs to current bot instance
|
|
1682
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
1683
|
+
voiceLogger.log(
|
|
1684
|
+
`[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
1685
|
+
)
|
|
1686
|
+
return
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
discordLogger.log(
|
|
1690
|
+
`DIRECTORY: Found kimaki.directory: ${projectDirectory}`,
|
|
1691
|
+
)
|
|
1692
|
+
if (channelAppId) {
|
|
1693
|
+
discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
1697
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
1698
|
+
await message.reply(
|
|
1699
|
+
`✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
1700
|
+
)
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Determine if this is a voice message
|
|
1705
|
+
const hasVoice = message.attachments.some((a) =>
|
|
1706
|
+
a.contentType?.startsWith('audio/'),
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
// Create thread
|
|
1710
|
+
const threadName = hasVoice
|
|
1711
|
+
? 'Voice Message'
|
|
1712
|
+
: message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread'
|
|
1713
|
+
|
|
1714
|
+
const thread = await message.startThread({
|
|
1715
|
+
name: threadName.slice(0, 80),
|
|
1716
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
1717
|
+
reason: 'Start Claude session',
|
|
1718
|
+
})
|
|
1719
|
+
|
|
1720
|
+
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
|
|
1721
|
+
|
|
1722
|
+
// Handle voice message if present
|
|
1723
|
+
let messageContent = message.content || ''
|
|
1724
|
+
|
|
1725
|
+
const transcription = await processVoiceAttachment({
|
|
1726
|
+
message,
|
|
1727
|
+
thread,
|
|
1728
|
+
projectDirectory,
|
|
1729
|
+
isNewThread: true,
|
|
1730
|
+
})
|
|
1731
|
+
if (transcription) {
|
|
1732
|
+
messageContent = transcription
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
await handleOpencodeSession(
|
|
1736
|
+
messageContent,
|
|
1737
|
+
thread,
|
|
1738
|
+
projectDirectory,
|
|
1739
|
+
message,
|
|
1740
|
+
)
|
|
1741
|
+
} else {
|
|
1742
|
+
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
1743
|
+
}
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
voiceLogger.error('Discord handler error:', error)
|
|
1746
|
+
try {
|
|
1747
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
1748
|
+
await message.reply(`Error: ${errMsg}`)
|
|
1749
|
+
} catch {
|
|
1750
|
+
voiceLogger.error('Discord handler error (fallback):', error)
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
})
|
|
1754
|
+
|
|
1755
|
+
// Handle slash command interactions
|
|
1756
|
+
discordClient.on(
|
|
1757
|
+
Events.InteractionCreate,
|
|
1758
|
+
async (interaction: Interaction) => {
|
|
1759
|
+
try {
|
|
1760
|
+
// Handle autocomplete
|
|
1761
|
+
if (interaction.isAutocomplete()) {
|
|
1762
|
+
if (interaction.commandName === 'resume') {
|
|
1763
|
+
const focusedValue = interaction.options.getFocused()
|
|
1764
|
+
|
|
1765
|
+
// Get the channel's project directory from its topic
|
|
1766
|
+
let projectDirectory: string | undefined
|
|
1767
|
+
if (
|
|
1768
|
+
interaction.channel &&
|
|
1769
|
+
interaction.channel.type === ChannelType.GuildText
|
|
1770
|
+
) {
|
|
1771
|
+
const textChannel = resolveTextChannel(
|
|
1772
|
+
interaction.channel as TextChannel | ThreadChannel | null,
|
|
1773
|
+
)
|
|
1774
|
+
if (textChannel) {
|
|
1775
|
+
const { projectDirectory: directory, channelAppId } =
|
|
1776
|
+
getKimakiMetadata(textChannel)
|
|
1777
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
1778
|
+
await interaction.respond([])
|
|
1779
|
+
return
|
|
1780
|
+
}
|
|
1781
|
+
projectDirectory = directory
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
if (!projectDirectory) {
|
|
1786
|
+
await interaction.respond([])
|
|
1787
|
+
return
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
try {
|
|
1791
|
+
// Get OpenCode client for this directory
|
|
1792
|
+
const getClient =
|
|
1793
|
+
await initializeOpencodeForDirectory(projectDirectory)
|
|
1794
|
+
|
|
1795
|
+
// List sessions
|
|
1796
|
+
const sessionsResponse = await getClient().session.list()
|
|
1797
|
+
if (!sessionsResponse.data) {
|
|
1798
|
+
await interaction.respond([])
|
|
1799
|
+
return
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// Filter and map sessions to choices
|
|
1803
|
+
const sessions = sessionsResponse.data
|
|
1804
|
+
.filter((session) =>
|
|
1805
|
+
session.title
|
|
1806
|
+
.toLowerCase()
|
|
1807
|
+
.includes(focusedValue.toLowerCase()),
|
|
1808
|
+
)
|
|
1809
|
+
.slice(0, 25) // Discord limit
|
|
1810
|
+
.map((session) => ({
|
|
1811
|
+
name: `${session.title} (${new Date(session.time.updated).toLocaleString()})`,
|
|
1812
|
+
value: session.id,
|
|
1813
|
+
}))
|
|
1814
|
+
|
|
1815
|
+
await interaction.respond(sessions)
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
voiceLogger.error(
|
|
1818
|
+
'[AUTOCOMPLETE] Error fetching sessions:',
|
|
1819
|
+
error,
|
|
1820
|
+
)
|
|
1821
|
+
await interaction.respond([])
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Handle slash commands
|
|
1827
|
+
if (interaction.isChatInputCommand()) {
|
|
1828
|
+
const command = interaction
|
|
1829
|
+
|
|
1830
|
+
if (command.commandName === 'resume') {
|
|
1831
|
+
await command.deferReply({ ephemeral: false })
|
|
1832
|
+
|
|
1833
|
+
const sessionId = command.options.getString('session', true)
|
|
1834
|
+
const channel = command.channel
|
|
1835
|
+
|
|
1836
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
1837
|
+
await command.editReply(
|
|
1838
|
+
'This command can only be used in text channels',
|
|
1839
|
+
)
|
|
1840
|
+
return
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const textChannel = channel as TextChannel
|
|
1844
|
+
|
|
1845
|
+
// Get project directory from channel topic
|
|
1846
|
+
let projectDirectory: string | undefined
|
|
1847
|
+
let channelAppId: string | undefined
|
|
1848
|
+
|
|
1849
|
+
if (textChannel.topic) {
|
|
1850
|
+
const extracted = extractTagsArrays({
|
|
1851
|
+
xml: textChannel.topic,
|
|
1852
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1853
|
+
})
|
|
1854
|
+
|
|
1855
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
1856
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Check if this channel belongs to current bot instance
|
|
1860
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
1861
|
+
await command.editReply(
|
|
1862
|
+
'This channel is not configured for this bot',
|
|
1863
|
+
)
|
|
1864
|
+
return
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (!projectDirectory) {
|
|
1868
|
+
await command.editReply(
|
|
1869
|
+
'This channel is not configured with a project directory',
|
|
1870
|
+
)
|
|
1871
|
+
return
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
1875
|
+
await command.editReply(
|
|
1876
|
+
`Directory does not exist: ${projectDirectory}`,
|
|
1877
|
+
)
|
|
1878
|
+
return
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
try {
|
|
1882
|
+
// Initialize OpenCode client for the directory
|
|
1883
|
+
const getClient =
|
|
1884
|
+
await initializeOpencodeForDirectory(projectDirectory)
|
|
1885
|
+
|
|
1886
|
+
// Get session title
|
|
1887
|
+
const sessionResponse = await getClient().session.get({
|
|
1888
|
+
path: { id: sessionId },
|
|
1889
|
+
})
|
|
1890
|
+
|
|
1891
|
+
if (!sessionResponse.data) {
|
|
1892
|
+
await command.editReply('Session not found')
|
|
1893
|
+
return
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const sessionTitle = sessionResponse.data.title
|
|
1897
|
+
|
|
1898
|
+
// Create thread for the resumed session
|
|
1899
|
+
const thread = await textChannel.threads.create({
|
|
1900
|
+
name: `Resume: ${sessionTitle}`.slice(0, 100),
|
|
1901
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
1902
|
+
reason: `Resuming session ${sessionId}`,
|
|
1903
|
+
})
|
|
1904
|
+
|
|
1905
|
+
// Store session ID in database
|
|
1906
|
+
getDatabase()
|
|
1907
|
+
.prepare(
|
|
1908
|
+
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
|
|
1909
|
+
)
|
|
1910
|
+
.run(thread.id, sessionId)
|
|
1911
|
+
|
|
1912
|
+
voiceLogger.log(
|
|
1913
|
+
`[RESUME] Created thread ${thread.id} for session ${sessionId}`,
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
// Fetch all messages for the session
|
|
1917
|
+
const messagesResponse = await getClient().session.messages({
|
|
1918
|
+
path: { id: sessionId },
|
|
1919
|
+
})
|
|
1920
|
+
|
|
1921
|
+
if (!messagesResponse.data) {
|
|
1922
|
+
throw new Error('Failed to fetch session messages')
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const messages = messagesResponse.data
|
|
1926
|
+
|
|
1927
|
+
await command.editReply(
|
|
1928
|
+
`Resumed session "${sessionTitle}" in ${thread.toString()}`,
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
// Send initial message to thread
|
|
1932
|
+
await sendThreadMessage(
|
|
1933
|
+
thread,
|
|
1934
|
+
`📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
|
|
1935
|
+
)
|
|
1936
|
+
|
|
1937
|
+
// Render all existing messages
|
|
1938
|
+
let messageCount = 0
|
|
1939
|
+
for (const message of messages) {
|
|
1940
|
+
if (message.info.role === 'user') {
|
|
1941
|
+
// Render user messages
|
|
1942
|
+
const userParts = message.parts.filter(
|
|
1943
|
+
(p) => p.type === 'text',
|
|
1944
|
+
)
|
|
1945
|
+
const userText = userParts
|
|
1946
|
+
.map((p) => (typeof p.text === 'string' ? p.text : ''))
|
|
1947
|
+
.filter((t) => t.trim())
|
|
1948
|
+
.join('\n\n')
|
|
1949
|
+
if (userText) {
|
|
1950
|
+
// Escape backticks in user messages to prevent formatting issues
|
|
1951
|
+
const escapedText = escapeDiscordFormatting(userText)
|
|
1952
|
+
await sendThreadMessage(thread, `**User:**\n${escapedText}`)
|
|
1953
|
+
}
|
|
1954
|
+
} else if (message.info.role === 'assistant') {
|
|
1955
|
+
// Render assistant parts
|
|
1956
|
+
for (const part of message.parts) {
|
|
1957
|
+
const content = formatPart(part)
|
|
1958
|
+
if (content.trim()) {
|
|
1959
|
+
const discordMessage = await sendThreadMessage(
|
|
1960
|
+
thread,
|
|
1961
|
+
content,
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
// Store part-message mapping in database
|
|
1965
|
+
getDatabase()
|
|
1966
|
+
.prepare(
|
|
1967
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
1968
|
+
)
|
|
1969
|
+
.run(part.id, discordMessage.id, thread.id)
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
messageCount++
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
await sendThreadMessage(
|
|
1977
|
+
thread,
|
|
1978
|
+
`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
|
|
1979
|
+
)
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
voiceLogger.error('[RESUME] Error:', error)
|
|
1982
|
+
await command.editReply(
|
|
1983
|
+
`Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
1984
|
+
)
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
voiceLogger.error('[INTERACTION] Error handling interaction:', error)
|
|
1990
|
+
}
|
|
1991
|
+
},
|
|
1992
|
+
)
|
|
1993
|
+
|
|
1994
|
+
// Helper function to clean up voice connection and associated resources
|
|
1995
|
+
async function cleanupVoiceConnection(guildId: string) {
|
|
1996
|
+
const voiceData = voiceConnections.get(guildId)
|
|
1997
|
+
if (!voiceData) return
|
|
1998
|
+
|
|
1999
|
+
voiceLogger.log(`Starting cleanup for guild ${guildId}`)
|
|
2000
|
+
|
|
2001
|
+
try {
|
|
2002
|
+
// Stop GenAI worker if exists (this is async!)
|
|
2003
|
+
if (voiceData.genAiWorker) {
|
|
2004
|
+
voiceLogger.log(`Stopping GenAI worker...`)
|
|
2005
|
+
await voiceData.genAiWorker.stop()
|
|
2006
|
+
voiceLogger.log(`GenAI worker stopped`)
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Close user audio stream if exists
|
|
2010
|
+
if (voiceData.userAudioStream) {
|
|
2011
|
+
voiceLogger.log(`Closing user audio stream...`)
|
|
2012
|
+
await new Promise<void>((resolve) => {
|
|
2013
|
+
voiceData.userAudioStream!.end(() => {
|
|
2014
|
+
voiceLogger.log('User audio stream closed')
|
|
2015
|
+
resolve()
|
|
2016
|
+
})
|
|
2017
|
+
// Timeout after 2 seconds
|
|
2018
|
+
setTimeout(resolve, 2000)
|
|
2019
|
+
})
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// Destroy voice connection
|
|
2023
|
+
if (
|
|
2024
|
+
voiceData.connection.state.status !== VoiceConnectionStatus.Destroyed
|
|
2025
|
+
) {
|
|
2026
|
+
voiceLogger.log(`Destroying voice connection...`)
|
|
2027
|
+
voiceData.connection.destroy()
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Remove from map
|
|
2031
|
+
voiceConnections.delete(guildId)
|
|
2032
|
+
voiceLogger.log(`Cleanup complete for guild ${guildId}`)
|
|
2033
|
+
} catch (error) {
|
|
2034
|
+
voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
|
|
2035
|
+
// Still remove from map even if there was an error
|
|
2036
|
+
voiceConnections.delete(guildId)
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// Handle voice state updates
|
|
2041
|
+
discordClient.on(Events.VoiceStateUpdate, async (oldState, newState) => {
|
|
2042
|
+
try {
|
|
2043
|
+
const member = newState.member || oldState.member
|
|
2044
|
+
if (!member) return
|
|
2045
|
+
|
|
2046
|
+
// Check if user is admin or server owner
|
|
2047
|
+
const guild = newState.guild || oldState.guild
|
|
2048
|
+
const isOwner = member.id === guild.ownerId
|
|
2049
|
+
const isAdmin = member.permissions.has(
|
|
2050
|
+
PermissionsBitField.Flags.Administrator,
|
|
2051
|
+
)
|
|
2052
|
+
|
|
2053
|
+
if (!isOwner && !isAdmin) {
|
|
2054
|
+
// Not an admin user, ignore
|
|
2055
|
+
return
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// Handle admin leaving voice channel
|
|
2059
|
+
if (oldState.channelId !== null && newState.channelId === null) {
|
|
2060
|
+
voiceLogger.log(
|
|
2061
|
+
`Admin user ${member.user.tag} left voice channel: ${oldState.channel?.name}`,
|
|
2062
|
+
)
|
|
2063
|
+
|
|
2064
|
+
// Check if bot should leave too
|
|
2065
|
+
const guildId = guild.id
|
|
2066
|
+
const voiceData = voiceConnections.get(guildId)
|
|
2067
|
+
|
|
2068
|
+
if (
|
|
2069
|
+
voiceData &&
|
|
2070
|
+
voiceData.connection.joinConfig.channelId === oldState.channelId
|
|
2071
|
+
) {
|
|
2072
|
+
// Check if any other admin is still in the channel
|
|
2073
|
+
const voiceChannel = oldState.channel as VoiceChannel
|
|
2074
|
+
if (!voiceChannel) return
|
|
2075
|
+
|
|
2076
|
+
const hasOtherAdmins = voiceChannel.members.some((m) => {
|
|
2077
|
+
if (m.id === member.id || m.user.bot) return false
|
|
2078
|
+
return (
|
|
2079
|
+
m.id === guild.ownerId ||
|
|
2080
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
2081
|
+
)
|
|
2082
|
+
})
|
|
2083
|
+
|
|
2084
|
+
if (!hasOtherAdmins) {
|
|
2085
|
+
voiceLogger.log(
|
|
2086
|
+
`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`,
|
|
2087
|
+
)
|
|
2088
|
+
|
|
2089
|
+
// Properly clean up all resources
|
|
2090
|
+
await cleanupVoiceConnection(guildId)
|
|
2091
|
+
} else {
|
|
2092
|
+
voiceLogger.log(
|
|
2093
|
+
`Other admins still in channel, bot staying in voice channel`,
|
|
2094
|
+
)
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// Handle admin moving between voice channels
|
|
2101
|
+
if (
|
|
2102
|
+
oldState.channelId !== null &&
|
|
2103
|
+
newState.channelId !== null &&
|
|
2104
|
+
oldState.channelId !== newState.channelId
|
|
2105
|
+
) {
|
|
2106
|
+
voiceLogger.log(
|
|
2107
|
+
`Admin user ${member.user.tag} moved from ${oldState.channel?.name} to ${newState.channel?.name}`,
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
// Check if we need to follow the admin
|
|
2111
|
+
const guildId = guild.id
|
|
2112
|
+
const voiceData = voiceConnections.get(guildId)
|
|
2113
|
+
|
|
2114
|
+
if (
|
|
2115
|
+
voiceData &&
|
|
2116
|
+
voiceData.connection.joinConfig.channelId === oldState.channelId
|
|
2117
|
+
) {
|
|
2118
|
+
// Check if any other admin is still in the old channel
|
|
2119
|
+
const oldVoiceChannel = oldState.channel as VoiceChannel
|
|
2120
|
+
if (oldVoiceChannel) {
|
|
2121
|
+
const hasOtherAdmins = oldVoiceChannel.members.some((m) => {
|
|
2122
|
+
if (m.id === member.id || m.user.bot) return false
|
|
2123
|
+
return (
|
|
2124
|
+
m.id === guild.ownerId ||
|
|
2125
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
2126
|
+
)
|
|
2127
|
+
})
|
|
2128
|
+
|
|
2129
|
+
if (!hasOtherAdmins) {
|
|
2130
|
+
voiceLogger.log(
|
|
2131
|
+
`Following admin to new channel: ${newState.channel?.name}`,
|
|
2132
|
+
)
|
|
2133
|
+
const voiceChannel = newState.channel as VoiceChannel
|
|
2134
|
+
if (voiceChannel) {
|
|
2135
|
+
voiceData.connection.rejoin({
|
|
2136
|
+
channelId: voiceChannel.id,
|
|
2137
|
+
selfDeaf: false,
|
|
2138
|
+
selfMute: false,
|
|
2139
|
+
})
|
|
2140
|
+
}
|
|
2141
|
+
} else {
|
|
2142
|
+
voiceLogger.log(
|
|
2143
|
+
`Other admins still in old channel, bot staying put`,
|
|
2144
|
+
)
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// Handle admin joining voice channel (initial join)
|
|
2151
|
+
if (oldState.channelId === null && newState.channelId !== null) {
|
|
2152
|
+
voiceLogger.log(
|
|
2153
|
+
`Admin user ${member.user.tag} (Owner: ${isOwner}, Admin: ${isAdmin}) joined voice channel: ${newState.channel?.name}`,
|
|
2154
|
+
)
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// Only proceed with joining if this is a new join or channel move
|
|
2158
|
+
if (newState.channelId === null) return
|
|
2159
|
+
|
|
2160
|
+
const voiceChannel = newState.channel as VoiceChannel
|
|
2161
|
+
if (!voiceChannel) return
|
|
2162
|
+
|
|
2163
|
+
// Check if bot already has a connection in this guild
|
|
2164
|
+
const existingVoiceData = voiceConnections.get(newState.guild.id)
|
|
2165
|
+
if (
|
|
2166
|
+
existingVoiceData &&
|
|
2167
|
+
existingVoiceData.connection.state.status !==
|
|
2168
|
+
VoiceConnectionStatus.Destroyed
|
|
2169
|
+
) {
|
|
2170
|
+
voiceLogger.log(
|
|
2171
|
+
`Bot already connected to a voice channel in guild ${newState.guild.name}`,
|
|
2172
|
+
)
|
|
2173
|
+
|
|
2174
|
+
// If bot is in a different channel, move to the admin's channel
|
|
2175
|
+
if (
|
|
2176
|
+
existingVoiceData.connection.joinConfig.channelId !== voiceChannel.id
|
|
2177
|
+
) {
|
|
2178
|
+
voiceLogger.log(
|
|
2179
|
+
`Moving bot from channel ${existingVoiceData.connection.joinConfig.channelId} to ${voiceChannel.id}`,
|
|
2180
|
+
)
|
|
2181
|
+
existingVoiceData.connection.rejoin({
|
|
2182
|
+
channelId: voiceChannel.id,
|
|
2183
|
+
selfDeaf: false,
|
|
2184
|
+
selfMute: false,
|
|
2185
|
+
})
|
|
2186
|
+
}
|
|
2187
|
+
return
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
try {
|
|
2191
|
+
// Join the voice channel
|
|
2192
|
+
voiceLogger.log(
|
|
2193
|
+
`Attempting to join voice channel: ${voiceChannel.name} (${voiceChannel.id})`,
|
|
2194
|
+
)
|
|
2195
|
+
|
|
2196
|
+
const connection = joinVoiceChannel({
|
|
2197
|
+
channelId: voiceChannel.id,
|
|
2198
|
+
guildId: newState.guild.id,
|
|
2199
|
+
adapterCreator: newState.guild.voiceAdapterCreator,
|
|
2200
|
+
selfDeaf: false,
|
|
2201
|
+
debug: true,
|
|
2202
|
+
daveEncryption: false,
|
|
2203
|
+
|
|
2204
|
+
selfMute: false, // Not muted so bot can speak
|
|
2205
|
+
})
|
|
2206
|
+
|
|
2207
|
+
// Store the connection
|
|
2208
|
+
voiceConnections.set(newState.guild.id, { connection })
|
|
2209
|
+
|
|
2210
|
+
// Wait for connection to be ready
|
|
2211
|
+
await entersState(connection, VoiceConnectionStatus.Ready, 30_000)
|
|
2212
|
+
voiceLogger.log(
|
|
2213
|
+
`Successfully joined voice channel: ${voiceChannel.name} in guild: ${newState.guild.name}`,
|
|
2214
|
+
)
|
|
2215
|
+
|
|
2216
|
+
// Set up voice handling (only once per connection)
|
|
2217
|
+
await setupVoiceHandling({
|
|
2218
|
+
connection,
|
|
2219
|
+
guildId: newState.guild.id,
|
|
2220
|
+
channelId: voiceChannel.id,
|
|
2221
|
+
})
|
|
2222
|
+
|
|
2223
|
+
// Handle connection state changes
|
|
2224
|
+
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
|
2225
|
+
voiceLogger.log(
|
|
2226
|
+
`Disconnected from voice channel in guild: ${newState.guild.name}`,
|
|
2227
|
+
)
|
|
2228
|
+
try {
|
|
2229
|
+
// Try to reconnect
|
|
2230
|
+
await Promise.race([
|
|
2231
|
+
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
|
2232
|
+
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
|
2233
|
+
])
|
|
2234
|
+
voiceLogger.log(`Reconnecting to voice channel`)
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
// Seems to be a real disconnect, destroy the connection
|
|
2237
|
+
voiceLogger.log(`Failed to reconnect, destroying connection`)
|
|
2238
|
+
connection.destroy()
|
|
2239
|
+
voiceConnections.delete(newState.guild.id)
|
|
2240
|
+
}
|
|
2241
|
+
})
|
|
2242
|
+
|
|
2243
|
+
connection.on(VoiceConnectionStatus.Destroyed, async () => {
|
|
2244
|
+
voiceLogger.log(
|
|
2245
|
+
`Connection destroyed for guild: ${newState.guild.name}`,
|
|
2246
|
+
)
|
|
2247
|
+
// Use the cleanup function to ensure everything is properly closed
|
|
2248
|
+
await cleanupVoiceConnection(newState.guild.id)
|
|
2249
|
+
})
|
|
2250
|
+
|
|
2251
|
+
// Handle errors
|
|
2252
|
+
connection.on('error', (error) => {
|
|
2253
|
+
voiceLogger.error(
|
|
2254
|
+
`Connection error in guild ${newState.guild.name}:`,
|
|
2255
|
+
error,
|
|
2256
|
+
)
|
|
2257
|
+
})
|
|
2258
|
+
} catch (error) {
|
|
2259
|
+
voiceLogger.error(`Failed to join voice channel:`, error)
|
|
2260
|
+
await cleanupVoiceConnection(newState.guild.id)
|
|
2261
|
+
}
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
voiceLogger.error('Error in voice state update handler:', error)
|
|
2264
|
+
}
|
|
2265
|
+
})
|
|
2266
|
+
|
|
2267
|
+
await discordClient.login(token)
|
|
2268
|
+
|
|
2269
|
+
const handleShutdown = async (signal: string) => {
|
|
2270
|
+
discordLogger.log(`Received ${signal}, cleaning up...`)
|
|
2271
|
+
|
|
2272
|
+
// Prevent multiple shutdown calls
|
|
2273
|
+
if ((global as any).shuttingDown) {
|
|
2274
|
+
discordLogger.log('Already shutting down, ignoring duplicate signal')
|
|
2275
|
+
return
|
|
2276
|
+
}
|
|
2277
|
+
;(global as any).shuttingDown = true
|
|
2278
|
+
|
|
2279
|
+
try {
|
|
2280
|
+
// Clean up all voice connections (this includes GenAI workers and audio streams)
|
|
2281
|
+
const cleanupPromises: Promise<void>[] = []
|
|
2282
|
+
for (const [guildId] of voiceConnections) {
|
|
2283
|
+
voiceLogger.log(
|
|
2284
|
+
`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
|
|
2285
|
+
)
|
|
2286
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId))
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// Wait for all cleanups to complete
|
|
2290
|
+
if (cleanupPromises.length > 0) {
|
|
2291
|
+
voiceLogger.log(
|
|
2292
|
+
`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
|
|
2293
|
+
)
|
|
2294
|
+
await Promise.allSettled(cleanupPromises)
|
|
2295
|
+
discordLogger.log(`All voice connections cleaned up`)
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// Kill all OpenCode servers
|
|
2299
|
+
for (const [dir, server] of opencodeServers) {
|
|
2300
|
+
if (!server.process.killed) {
|
|
2301
|
+
voiceLogger.log(
|
|
2302
|
+
`[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
|
|
2303
|
+
)
|
|
2304
|
+
server.process.kill('SIGTERM')
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
opencodeServers.clear()
|
|
2308
|
+
|
|
2309
|
+
discordLogger.log('Closing database...')
|
|
2310
|
+
getDatabase().close()
|
|
2311
|
+
|
|
2312
|
+
discordLogger.log('Destroying Discord client...')
|
|
2313
|
+
discordClient.destroy()
|
|
2314
|
+
|
|
2315
|
+
discordLogger.log('Cleanup complete, exiting.')
|
|
2316
|
+
process.exit(0)
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
|
|
2319
|
+
process.exit(1)
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// Override default signal handlers to prevent immediate exit
|
|
2324
|
+
process.on('SIGTERM', async () => {
|
|
2325
|
+
try {
|
|
2326
|
+
await handleShutdown('SIGTERM')
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
voiceLogger.error('[SIGTERM] Error during shutdown:', error)
|
|
2329
|
+
process.exit(1)
|
|
2330
|
+
}
|
|
2331
|
+
})
|
|
2332
|
+
|
|
2333
|
+
process.on('SIGINT', async () => {
|
|
2334
|
+
try {
|
|
2335
|
+
await handleShutdown('SIGINT')
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
voiceLogger.error('[SIGINT] Error during shutdown:', error)
|
|
2338
|
+
process.exit(1)
|
|
2339
|
+
}
|
|
2340
|
+
})
|
|
2341
|
+
|
|
2342
|
+
// Prevent unhandled promise rejections from crashing the process during shutdown
|
|
2343
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
2344
|
+
if ((global as any).shuttingDown) {
|
|
2345
|
+
discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
|
|
2346
|
+
return
|
|
2347
|
+
}
|
|
2348
|
+
discordLogger.error('Unhandled Rejection at:', promise, 'reason:', reason)
|
|
2349
|
+
})
|
|
2350
|
+
}
|