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.
@@ -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
+ }