kimaki 0.0.3 → 0.1.2

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