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