kimaki 0.4.22 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/discordBot.ts DELETED
@@ -1,3671 +0,0 @@
1
- import {
2
- createOpencodeClient,
3
- type OpencodeClient,
4
- type Part,
5
- type Config,
6
- type FilePartInput,
7
- type Permission,
8
- } from '@opencode-ai/sdk'
9
-
10
- import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
11
-
12
- import Database from 'better-sqlite3'
13
- import {
14
- ChannelType,
15
- Client,
16
- Events,
17
- GatewayIntentBits,
18
- Partials,
19
- PermissionsBitField,
20
- ThreadAutoArchiveDuration,
21
- type CategoryChannel,
22
- type Guild,
23
- type Interaction,
24
- type Message,
25
- type TextChannel,
26
- type ThreadChannel,
27
- type VoiceChannel,
28
- } from 'discord.js'
29
- import {
30
- joinVoiceChannel,
31
- VoiceConnectionStatus,
32
- entersState,
33
- EndBehaviorType,
34
- type VoiceConnection,
35
- } from '@discordjs/voice'
36
- import { Lexer } from 'marked'
37
- import { spawn, exec, type ChildProcess } from 'node:child_process'
38
- import fs, { createWriteStream } from 'node:fs'
39
- import { mkdir } from 'node:fs/promises'
40
- import net from 'node:net'
41
- import os from 'node:os'
42
- import path from 'node:path'
43
- import { promisify } from 'node:util'
44
- import { PassThrough, Transform, type TransformCallback } from 'node:stream'
45
- import * as prism from 'prism-media'
46
- import dedent from 'string-dedent'
47
- import { transcribeAudio } from './voice.js'
48
- import { extractTagsArrays, extractNonXmlContent } from './xml.js'
49
- import { formatMarkdownTables } from './format-tables.js'
50
- import prettyMilliseconds from 'pretty-ms'
51
- import type { Session } from '@google/genai'
52
- import { createLogger } from './logger.js'
53
- import { isAbortError } from './utils.js'
54
- import { setGlobalDispatcher, Agent } from 'undici'
55
- // disables the automatic 5 minutes abort after no body
56
- setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
57
-
58
- type ParsedCommand = {
59
- isCommand: true
60
- command: string
61
- arguments: string
62
- } | {
63
- isCommand: false
64
- }
65
- function parseSlashCommand(text: string): ParsedCommand {
66
- const trimmed = text.trim()
67
- if (!trimmed.startsWith('/')) {
68
- return { isCommand: false }
69
- }
70
- const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/)
71
- if (!match) {
72
- return { isCommand: false }
73
- }
74
- const command = match[1]!
75
- const args = match[2]?.trim() || ''
76
- return { isCommand: true, command, arguments: args }
77
- }
78
-
79
- export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
80
- return `
81
- The user is reading your messages from inside Discord, via kimaki.xyz
82
-
83
- The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
84
-
85
- Your current OpenCode session ID is: ${sessionId}
86
-
87
- ## permissions
88
-
89
- Only users with these Discord permissions can send messages to the bot:
90
- - Server Owner
91
- - Administrator permission
92
- - Manage Server permission
93
- - "Kimaki" role (case-insensitive)
94
-
95
- ## changing the model
96
-
97
- To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
98
-
99
- \`\`\`json
100
- {
101
- "model": "anthropic/claude-sonnet-4-20250514"
102
- }
103
- \`\`\`
104
-
105
- Examples:
106
- - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
107
- - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
108
- - \`"openai/gpt-4o"\` - GPT-4o
109
- - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
110
-
111
- Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
112
-
113
- ## uploading files to discord
114
-
115
- To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
116
-
117
- npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
118
-
119
- ## showing diffs
120
-
121
- After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
122
-
123
- bunx critique web
124
-
125
- you can also show latest commit changes using
126
-
127
- bunx critique web HEAD~1
128
-
129
- do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
130
-
131
- ## markdown
132
-
133
- discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
134
-
135
- the max heading level is 3, so do not use ####
136
-
137
- headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
138
-
139
- ## tables
140
-
141
- discord does NOT support markdown gfm tables.
142
-
143
- so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
144
-
145
- \`\`\`
146
- Item Qty Price
147
- ---------- --- -----
148
- Apples 10 $5
149
- Oranges 3 $2
150
- \`\`\`
151
-
152
- Using code blocks will make the content use monospaced font so that space will be aligned correctly
153
-
154
- IMPORTANT: add enough space characters to align the table! otherwise the content will not look good and will be difficult to understand for the user
155
-
156
- code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
157
-
158
- ## diagrams
159
-
160
- you can create diagrams wrapping them in code blocks too.
161
- `
162
- }
163
-
164
- const discordLogger = createLogger('DISCORD')
165
- const voiceLogger = createLogger('VOICE')
166
- const opencodeLogger = createLogger('OPENCODE')
167
- const sessionLogger = createLogger('SESSION')
168
- const dbLogger = createLogger('DB')
169
-
170
- type StartOptions = {
171
- token: string
172
- appId?: string
173
- }
174
-
175
- // Map of project directory to OpenCode server process and client
176
- const opencodeServers = new Map<
177
- string,
178
- {
179
- process: ChildProcess
180
- client: OpencodeClient
181
- port: number
182
- }
183
- >()
184
-
185
- // Map of session ID to current AbortController
186
- const abortControllers = new Map<string, AbortController>()
187
-
188
- // Map of guild ID to voice connection and GenAI worker
189
- const voiceConnections = new Map<
190
- string,
191
- {
192
- connection: VoiceConnection
193
- genAiWorker?: GenAIWorker
194
- userAudioStream?: fs.WriteStream
195
- }
196
- >()
197
-
198
- // Map of directory to retry count for server restarts
199
- const serverRetryCount = new Map<string, number>()
200
-
201
- // Map of thread ID to pending permission (only one pending permission per thread)
202
- const pendingPermissions = new Map<
203
- string,
204
- { permission: Permission; messageId: string; directory: string }
205
- >()
206
-
207
- let db: Database.Database | null = null
208
-
209
- function convertToMono16k(buffer: Buffer): Buffer {
210
- // Parameters
211
- const inputSampleRate = 48000
212
- const outputSampleRate = 16000
213
- const ratio = inputSampleRate / outputSampleRate
214
- const inputChannels = 2 // Stereo
215
- const bytesPerSample = 2 // 16-bit
216
-
217
- // Calculate output buffer size
218
- const inputSamples = buffer.length / (bytesPerSample * inputChannels)
219
- const outputSamples = Math.floor(inputSamples / ratio)
220
- const outputBuffer = Buffer.alloc(outputSamples * bytesPerSample)
221
-
222
- // Process each output sample
223
- for (let i = 0; i < outputSamples; i++) {
224
- // Find the corresponding input sample
225
- const inputIndex = Math.floor(i * ratio) * inputChannels * bytesPerSample
226
-
227
- // Average the left and right channels for mono conversion
228
- if (inputIndex + 3 < buffer.length) {
229
- const leftSample = buffer.readInt16LE(inputIndex)
230
- const rightSample = buffer.readInt16LE(inputIndex + 2)
231
- const monoSample = Math.round((leftSample + rightSample) / 2)
232
-
233
- // Write to output buffer
234
- outputBuffer.writeInt16LE(monoSample, i * bytesPerSample)
235
- }
236
- }
237
-
238
- return outputBuffer
239
- }
240
-
241
- // Create user audio log stream for debugging
242
- async function createUserAudioLogStream(
243
- guildId: string,
244
- channelId: string,
245
- ): Promise<fs.WriteStream | undefined> {
246
- if (!process.env.DEBUG) return undefined
247
-
248
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
249
- const audioDir = path.join(
250
- process.cwd(),
251
- 'discord-audio-logs',
252
- guildId,
253
- channelId,
254
- )
255
-
256
- try {
257
- await mkdir(audioDir, { recursive: true })
258
-
259
- // Create stream for user audio (16kHz mono s16le PCM)
260
- const inputFileName = `user_${timestamp}.16.pcm`
261
- const inputFilePath = path.join(audioDir, inputFileName)
262
- const inputAudioStream = createWriteStream(inputFilePath)
263
- voiceLogger.log(`Created user audio log: ${inputFilePath}`)
264
-
265
- return inputAudioStream
266
- } catch (error) {
267
- voiceLogger.error('Failed to create audio log directory:', error)
268
- return undefined
269
- }
270
- }
271
-
272
- // Set up voice handling for a connection (called once per connection)
273
- async function setupVoiceHandling({
274
- connection,
275
- guildId,
276
- channelId,
277
- appId,
278
- discordClient,
279
- }: {
280
- connection: VoiceConnection
281
- guildId: string
282
- channelId: string
283
- appId: string
284
- discordClient: Client
285
- }) {
286
- voiceLogger.log(
287
- `Setting up voice handling for guild ${guildId}, channel ${channelId}`,
288
- )
289
-
290
- // Check if this voice channel has an associated directory
291
- const channelDirRow = getDatabase()
292
- .prepare(
293
- 'SELECT directory FROM channel_directories WHERE channel_id = ? AND channel_type = ?',
294
- )
295
- .get(channelId, 'voice') as { directory: string } | undefined
296
-
297
- if (!channelDirRow) {
298
- voiceLogger.log(
299
- `Voice channel ${channelId} has no associated directory, skipping setup`,
300
- )
301
- return
302
- }
303
-
304
- const directory = channelDirRow.directory
305
- voiceLogger.log(`Found directory for voice channel: ${directory}`)
306
-
307
- // Get voice data
308
- const voiceData = voiceConnections.get(guildId)
309
- if (!voiceData) {
310
- voiceLogger.error(`No voice data found for guild ${guildId}`)
311
- return
312
- }
313
-
314
- // Create user audio stream for debugging
315
- voiceData.userAudioStream = await createUserAudioLogStream(guildId, channelId)
316
-
317
- // Get API keys from database
318
- const apiKeys = getDatabase()
319
- .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
320
- .get(appId) as { gemini_api_key: string | null } | undefined
321
-
322
- // Create GenAI worker
323
- const genAiWorker = await createGenAIWorker({
324
- directory,
325
- guildId,
326
- channelId,
327
- appId,
328
- geminiApiKey: apiKeys?.gemini_api_key,
329
- systemMessage: dedent`
330
- 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.
331
-
332
- You should talk like Jarvis, British accent, satirical, joking and calm. Be short and concise. Speak fast.
333
-
334
- After tool calls give a super short summary of the assistant message, you should say what the assistant message writes.
335
-
336
- Before starting a new session ask for confirmation if it is not clear if the user finished describing it. ask "message ready, send?"
337
-
338
- NEVER repeat the whole tool call parameters or message.
339
-
340
- 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.
341
-
342
- For everything the user asks it is implicit that the user is asking for you to proxy the requests to opencode sessions.
343
-
344
- You can
345
- - start new chats on a given project
346
- - read the chats to report progress to the user
347
- - submit messages to the chat
348
- - list files for a given projects, so you can translate imprecise user prompts to precise messages that mention filename paths using @
349
-
350
- Common patterns
351
- - to get the last session use the listChats tool
352
- - 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!
353
- - when you submit a session assume the session will take a minute or 2 to complete the task
354
-
355
- Rules
356
- - never spell files by mentioning dots, letters, etc. instead give a brief description of the filename
357
- - NEVER spell hashes or IDs
358
- - never read session ids or other ids
359
-
360
- Your voice is calm and monotone, NEVER excited and goofy. But you speak without jargon or bs and do veiled short jokes.
361
- You speak like you knew something other don't. You are cool and cold.
362
- `,
363
- onAssistantOpusPacket(packet) {
364
- // Opus packets are sent at 20ms intervals from worker, play directly
365
- if (connection.state.status !== VoiceConnectionStatus.Ready) {
366
- voiceLogger.log('Skipping packet: connection not ready')
367
- return
368
- }
369
-
370
- try {
371
- connection.setSpeaking(true)
372
- connection.playOpusPacket(Buffer.from(packet))
373
- } catch (error) {
374
- voiceLogger.error('Error sending packet:', error)
375
- }
376
- },
377
- onAssistantStartSpeaking() {
378
- voiceLogger.log('Assistant started speaking')
379
- connection.setSpeaking(true)
380
- },
381
- onAssistantStopSpeaking() {
382
- voiceLogger.log('Assistant stopped speaking (natural finish)')
383
- connection.setSpeaking(false)
384
- },
385
- onAssistantInterruptSpeaking() {
386
- voiceLogger.log('Assistant interrupted while speaking')
387
- genAiWorker.interrupt()
388
- connection.setSpeaking(false)
389
- },
390
- onToolCallCompleted(params) {
391
- const text = params.error
392
- ? `<systemMessage>\nThe coding agent encountered an error while processing session ${params.sessionId}: ${params.error?.message || String(params.error)}\n</systemMessage>`
393
- : `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`
394
-
395
- genAiWorker.sendTextInput(text)
396
- },
397
- async onError(error) {
398
- voiceLogger.error('GenAI worker error:', error)
399
- const textChannelRow = getDatabase()
400
- .prepare(
401
- `SELECT cd2.channel_id FROM channel_directories cd1
402
- JOIN channel_directories cd2 ON cd1.directory = cd2.directory
403
- WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`,
404
- )
405
- .get(channelId) as { channel_id: string } | undefined
406
-
407
- if (textChannelRow) {
408
- try {
409
- const textChannel = await discordClient.channels.fetch(
410
- textChannelRow.channel_id,
411
- )
412
- if (textChannel?.isTextBased() && 'send' in textChannel) {
413
- await textChannel.send(`⚠️ Voice session error: ${error}`)
414
- }
415
- } catch (e) {
416
- voiceLogger.error('Failed to send error to text channel:', e)
417
- }
418
- }
419
- },
420
- })
421
-
422
- // Stop any existing GenAI worker before storing new one
423
- if (voiceData.genAiWorker) {
424
- voiceLogger.log('Stopping existing GenAI worker before creating new one')
425
- await voiceData.genAiWorker.stop()
426
- }
427
-
428
- // Send initial greeting
429
- genAiWorker.sendTextInput(
430
- `<systemMessage>\nsay "Hello boss, how we doing today?"\n</systemMessage>`,
431
- )
432
-
433
- voiceData.genAiWorker = genAiWorker
434
-
435
- // Set up voice receiver for user input
436
- const receiver = connection.receiver
437
-
438
- // Remove all existing listeners to prevent accumulation
439
- receiver.speaking.removeAllListeners('start')
440
-
441
- // Counter to track overlapping speaking sessions
442
- let speakingSessionCount = 0
443
-
444
- receiver.speaking.on('start', (userId) => {
445
- voiceLogger.log(`User ${userId} started speaking`)
446
-
447
- // Increment session count for this new speaking session
448
- speakingSessionCount++
449
- const currentSessionCount = speakingSessionCount
450
- voiceLogger.log(`Speaking session ${currentSessionCount} started`)
451
-
452
- const audioStream = receiver.subscribe(userId, {
453
- end: { behavior: EndBehaviorType.AfterSilence, duration: 500 },
454
- })
455
-
456
- const decoder = new prism.opus.Decoder({
457
- rate: 48000,
458
- channels: 2,
459
- frameSize: 960,
460
- })
461
-
462
- // Add error handler to prevent crashes from corrupted data
463
- decoder.on('error', (error) => {
464
- voiceLogger.error(`Opus decoder error for user ${userId}:`, error)
465
- })
466
-
467
- // Transform to downsample 48k stereo -> 16k mono
468
- const downsampleTransform = new Transform({
469
- transform(chunk: Buffer, _encoding, callback) {
470
- try {
471
- const downsampled = convertToMono16k(chunk)
472
- callback(null, downsampled)
473
- } catch (error) {
474
- callback(error as Error)
475
- }
476
- },
477
- })
478
-
479
- const framer = frameMono16khz()
480
-
481
- const pipeline = audioStream
482
- .pipe(decoder)
483
- .pipe(downsampleTransform)
484
- .pipe(framer)
485
-
486
- pipeline
487
- .on('data', (frame: Buffer) => {
488
- // Check if a newer speaking session has started
489
- if (currentSessionCount !== speakingSessionCount) {
490
- // voiceLogger.log(
491
- // `Skipping audio frame from session ${currentSessionCount} because newer session ${speakingSessionCount} has started`,
492
- // )
493
- return
494
- }
495
-
496
- if (!voiceData.genAiWorker) {
497
- voiceLogger.warn(
498
- `[VOICE] Received audio frame but no GenAI worker active for guild ${guildId}`,
499
- )
500
- return
501
- }
502
- // voiceLogger.debug('User audio chunk length', frame.length)
503
-
504
- // Write to PCM file if stream exists
505
- voiceData.userAudioStream?.write(frame)
506
-
507
- // stream incrementally — low latency
508
- voiceData.genAiWorker.sendRealtimeInput({
509
- audio: {
510
- mimeType: 'audio/pcm;rate=16000',
511
- data: frame.toString('base64'),
512
- },
513
- })
514
- })
515
- .on('end', () => {
516
- // Only send audioStreamEnd if this is still the current session
517
- if (currentSessionCount === speakingSessionCount) {
518
- voiceLogger.log(
519
- `User ${userId} stopped speaking (session ${currentSessionCount})`,
520
- )
521
- voiceData.genAiWorker?.sendRealtimeInput({
522
- audioStreamEnd: true,
523
- })
524
- } else {
525
- voiceLogger.log(
526
- `User ${userId} stopped speaking (session ${currentSessionCount}), but skipping audioStreamEnd because newer session ${speakingSessionCount} exists`,
527
- )
528
- }
529
- })
530
- .on('error', (error) => {
531
- voiceLogger.error(`Pipeline error for user ${userId}:`, error)
532
- })
533
-
534
- // Also add error handlers to individual stream components
535
- audioStream.on('error', (error) => {
536
- voiceLogger.error(`Audio stream error for user ${userId}:`, error)
537
- })
538
-
539
- downsampleTransform.on('error', (error) => {
540
- voiceLogger.error(`Downsample transform error for user ${userId}:`, error)
541
- })
542
-
543
- framer.on('error', (error) => {
544
- voiceLogger.error(`Framer error for user ${userId}:`, error)
545
- })
546
- })
547
- }
548
-
549
- function frameMono16khz(): Transform {
550
- // Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
551
- const FRAME_BYTES =
552
- (100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
553
- 1000
554
- let stash: Buffer = Buffer.alloc(0)
555
- let offset = 0
556
-
557
- return new Transform({
558
- readableObjectMode: false,
559
- writableObjectMode: false,
560
-
561
- transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback) {
562
- // Normalize stash so offset is always 0 before appending
563
- if (offset > 0) {
564
- // Drop already-consumed prefix without copying the rest twice
565
- stash = stash.subarray(offset)
566
- offset = 0
567
- }
568
-
569
- // Append new data (single concat per incoming chunk)
570
- stash = stash.length ? Buffer.concat([stash, chunk]) : chunk
571
-
572
- // Emit as many full 20 ms frames as we can
573
- while (stash.length - offset >= FRAME_BYTES) {
574
- this.push(stash.subarray(offset, offset + FRAME_BYTES))
575
- offset += FRAME_BYTES
576
- }
577
-
578
- // If everything was consumed exactly, reset to empty buffer
579
- if (offset === stash.length) {
580
- stash = Buffer.alloc(0)
581
- offset = 0
582
- }
583
-
584
- cb()
585
- },
586
-
587
- flush(cb: TransformCallback) {
588
- // We intentionally drop any trailing partial (< 20 ms) to keep framing strict.
589
- // If you prefer to emit it, uncomment the next line:
590
- // if (stash.length - offset > 0) this.push(stash.subarray(offset));
591
- stash = Buffer.alloc(0)
592
- offset = 0
593
- cb()
594
- },
595
- })
596
- }
597
-
598
- export function getDatabase(): Database.Database {
599
- if (!db) {
600
- const kimakiDir = path.join(os.homedir(), '.kimaki')
601
-
602
- try {
603
- fs.mkdirSync(kimakiDir, { recursive: true })
604
- } catch (error) {
605
- dbLogger.error('Failed to create ~/.kimaki directory:', error)
606
- }
607
-
608
- const dbPath = path.join(kimakiDir, 'discord-sessions.db')
609
-
610
- dbLogger.log(`Opening database at: ${dbPath}`)
611
- db = new Database(dbPath)
612
-
613
- db.exec(`
614
- CREATE TABLE IF NOT EXISTS thread_sessions (
615
- thread_id TEXT PRIMARY KEY,
616
- session_id TEXT NOT NULL,
617
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
618
- )
619
- `)
620
-
621
- db.exec(`
622
- CREATE TABLE IF NOT EXISTS part_messages (
623
- part_id TEXT PRIMARY KEY,
624
- message_id TEXT NOT NULL,
625
- thread_id TEXT NOT NULL,
626
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
627
- )
628
- `)
629
-
630
- db.exec(`
631
- CREATE TABLE IF NOT EXISTS bot_tokens (
632
- app_id TEXT PRIMARY KEY,
633
- token TEXT NOT NULL,
634
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
635
- )
636
- `)
637
-
638
- db.exec(`
639
- CREATE TABLE IF NOT EXISTS channel_directories (
640
- channel_id TEXT PRIMARY KEY,
641
- directory TEXT NOT NULL,
642
- channel_type TEXT NOT NULL,
643
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
644
- )
645
- `)
646
-
647
- db.exec(`
648
- CREATE TABLE IF NOT EXISTS bot_api_keys (
649
- app_id TEXT PRIMARY KEY,
650
- gemini_api_key TEXT,
651
- xai_api_key TEXT,
652
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
653
- )
654
- `)
655
- }
656
-
657
- return db
658
- }
659
-
660
- export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
661
- const existingCategory = guild.channels.cache.find(
662
- (channel): channel is CategoryChannel => {
663
- if (channel.type !== ChannelType.GuildCategory) {
664
- return false
665
- }
666
-
667
- return channel.name.toLowerCase() === 'kimaki'
668
- },
669
- )
670
-
671
- if (existingCategory) {
672
- return existingCategory
673
- }
674
-
675
- return guild.channels.create({
676
- name: 'Kimaki',
677
- type: ChannelType.GuildCategory,
678
- })
679
- }
680
-
681
- export async function createProjectChannels({
682
- guild,
683
- projectDirectory,
684
- appId,
685
- }: {
686
- guild: Guild
687
- projectDirectory: string
688
- appId: string
689
- }): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
690
- const baseName = path.basename(projectDirectory)
691
- const channelName = `${baseName}`
692
- .toLowerCase()
693
- .replace(/[^a-z0-9-]/g, '-')
694
- .slice(0, 100)
695
-
696
- const kimakiCategory = await ensureKimakiCategory(guild)
697
-
698
- const textChannel = await guild.channels.create({
699
- name: channelName,
700
- type: ChannelType.GuildText,
701
- parent: kimakiCategory,
702
- topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
703
- })
704
-
705
- const voiceChannel = await guild.channels.create({
706
- name: channelName,
707
- type: ChannelType.GuildVoice,
708
- parent: kimakiCategory,
709
- })
710
-
711
- getDatabase()
712
- .prepare(
713
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
714
- )
715
- .run(textChannel.id, projectDirectory, 'text')
716
-
717
- getDatabase()
718
- .prepare(
719
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
720
- )
721
- .run(voiceChannel.id, projectDirectory, 'voice')
722
-
723
- return {
724
- textChannelId: textChannel.id,
725
- voiceChannelId: voiceChannel.id,
726
- channelName,
727
- }
728
- }
729
-
730
- async function getOpenPort(): Promise<number> {
731
- return new Promise((resolve, reject) => {
732
- const server = net.createServer()
733
- server.listen(0, () => {
734
- const address = server.address()
735
- if (address && typeof address === 'object') {
736
- const port = address.port
737
- server.close(() => {
738
- resolve(port)
739
- })
740
- } else {
741
- reject(new Error('Failed to get port'))
742
- }
743
- })
744
- server.on('error', reject)
745
- })
746
- }
747
-
748
- /**
749
- * Send a message to a Discord thread, automatically splitting long messages
750
- * @param thread - The thread channel to send to
751
- * @param content - The content to send (can be longer than 2000 chars)
752
- * @returns The first message sent
753
- */
754
- async function sendThreadMessage(
755
- thread: ThreadChannel,
756
- content: string,
757
- ): Promise<Message> {
758
- const MAX_LENGTH = 2000
759
-
760
- content = formatMarkdownTables(content)
761
- content = escapeBackticksInCodeBlocks(content)
762
-
763
- const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
764
-
765
- if (chunks.length > 1) {
766
- discordLogger.log(
767
- `MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
768
- )
769
- }
770
-
771
- let firstMessage: Message | undefined
772
- for (let i = 0; i < chunks.length; i++) {
773
- const chunk = chunks[i]
774
- if (!chunk) {
775
- continue
776
- }
777
- const message = await thread.send(chunk)
778
- if (i === 0) {
779
- firstMessage = message
780
- }
781
- }
782
-
783
- return firstMessage!
784
- }
785
-
786
- async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
787
- for (let i = 0; i < maxAttempts; i++) {
788
- try {
789
- const endpoints = [
790
- `http://localhost:${port}/api/health`,
791
- `http://localhost:${port}/`,
792
- `http://localhost:${port}/api`,
793
- ]
794
-
795
- for (const endpoint of endpoints) {
796
- try {
797
- const response = await fetch(endpoint)
798
- if (response.status < 500) {
799
- opencodeLogger.log(`Server ready on port `)
800
- return true
801
- }
802
- } catch (e) {}
803
- }
804
- } catch (e) {}
805
- await new Promise((resolve) => setTimeout(resolve, 1000))
806
- }
807
- throw new Error(
808
- `Server did not start on port ${port} after ${maxAttempts} seconds`,
809
- )
810
- }
811
-
812
- async function processVoiceAttachment({
813
- message,
814
- thread,
815
- projectDirectory,
816
- isNewThread = false,
817
- appId,
818
- }: {
819
- message: Message
820
- thread: ThreadChannel
821
- projectDirectory?: string
822
- isNewThread?: boolean
823
- appId?: string
824
- }): Promise<string | null> {
825
- const audioAttachment = Array.from(message.attachments.values()).find(
826
- (attachment) => attachment.contentType?.startsWith('audio/'),
827
- )
828
-
829
- if (!audioAttachment) return null
830
-
831
- voiceLogger.log(
832
- `Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
833
- )
834
-
835
- await sendThreadMessage(thread, '🎤 Transcribing voice message...')
836
-
837
- const audioResponse = await fetch(audioAttachment.url)
838
- const audioBuffer = Buffer.from(await audioResponse.arrayBuffer())
839
-
840
- voiceLogger.log(`Downloaded ${audioBuffer.length} bytes, transcribing...`)
841
-
842
- // Get project file tree for context if directory is provided
843
- let transcriptionPrompt = 'Discord voice message transcription'
844
-
845
- if (projectDirectory) {
846
- try {
847
- voiceLogger.log(`Getting project file tree from ${projectDirectory}`)
848
- // Use git ls-files to get tracked files, then pipe to tree
849
- const execAsync = promisify(exec)
850
- const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
851
- cwd: projectDirectory,
852
- })
853
- const result = stdout
854
-
855
- if (result) {
856
- transcriptionPrompt = `Discord voice message transcription. Project file structure:\n${result}\n\nPlease transcribe file names and paths accurately based on this context.`
857
- voiceLogger.log(`Added project context to transcription prompt`)
858
- }
859
- } catch (e) {
860
- voiceLogger.log(`Could not get project tree:`, e)
861
- }
862
- }
863
-
864
- // Get Gemini API key from database if appId is provided
865
- let geminiApiKey: string | undefined
866
- if (appId) {
867
- const apiKeys = getDatabase()
868
- .prepare('SELECT gemini_api_key FROM bot_api_keys WHERE app_id = ?')
869
- .get(appId) as { gemini_api_key: string | null } | undefined
870
-
871
- if (apiKeys?.gemini_api_key) {
872
- geminiApiKey = apiKeys.gemini_api_key
873
- }
874
- }
875
-
876
- const transcription = await transcribeAudio({
877
- audio: audioBuffer,
878
- prompt: transcriptionPrompt,
879
- geminiApiKey,
880
- })
881
-
882
- voiceLogger.log(
883
- `Transcription successful: "${transcription.slice(0, 50)}${transcription.length > 50 ? '...' : ''}"`,
884
- )
885
-
886
- // Update thread name with transcribed content only for new threads
887
- if (isNewThread) {
888
- const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80)
889
- if (threadName) {
890
- try {
891
- await Promise.race([
892
- thread.setName(threadName),
893
- new Promise((resolve) => setTimeout(resolve, 2000)),
894
- ])
895
- discordLogger.log(`Updated thread name to: "${threadName}"`)
896
- } catch (e) {
897
- discordLogger.log(`Could not update thread name:`, e)
898
- }
899
- }
900
- }
901
-
902
- await sendThreadMessage(
903
- thread,
904
- `📝 **Transcribed message:** ${escapeDiscordFormatting(transcription)}`,
905
- )
906
- return transcription
907
- }
908
-
909
- const TEXT_MIME_TYPES = [
910
- 'text/',
911
- 'application/json',
912
- 'application/xml',
913
- 'application/javascript',
914
- 'application/typescript',
915
- 'application/x-yaml',
916
- 'application/toml',
917
- ]
918
-
919
- function isTextMimeType(contentType: string | null): boolean {
920
- if (!contentType) {
921
- return false
922
- }
923
- return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
924
- }
925
-
926
- async function getTextAttachments(message: Message): Promise<string> {
927
- const textAttachments = Array.from(message.attachments.values()).filter(
928
- (attachment) => isTextMimeType(attachment.contentType),
929
- )
930
-
931
- if (textAttachments.length === 0) {
932
- return ''
933
- }
934
-
935
- const textContents = await Promise.all(
936
- textAttachments.map(async (attachment) => {
937
- try {
938
- const response = await fetch(attachment.url)
939
- if (!response.ok) {
940
- return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
941
- }
942
- const text = await response.text()
943
- return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
944
- } catch (error) {
945
- const errMsg = error instanceof Error ? error.message : String(error)
946
- return `<attachment filename="${attachment.name}" error="${errMsg}" />`
947
- }
948
- }),
949
- )
950
-
951
- return textContents.join('\n\n')
952
- }
953
-
954
- function getFileAttachments(message: Message): FilePartInput[] {
955
- const fileAttachments = Array.from(message.attachments.values()).filter(
956
- (attachment) => {
957
- const contentType = attachment.contentType || ''
958
- return (
959
- contentType.startsWith('image/') || contentType === 'application/pdf'
960
- )
961
- },
962
- )
963
-
964
- return fileAttachments.map((attachment) => ({
965
- type: 'file' as const,
966
- mime: attachment.contentType || 'application/octet-stream',
967
- filename: attachment.name,
968
- url: attachment.url,
969
- }))
970
- }
971
-
972
- export function escapeBackticksInCodeBlocks(markdown: string): string {
973
- const lexer = new Lexer()
974
- const tokens = lexer.lex(markdown)
975
-
976
- let result = ''
977
-
978
- for (const token of tokens) {
979
- if (token.type === 'code') {
980
- const escapedCode = token.text.replace(/`/g, '\\`')
981
- result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
982
- } else {
983
- result += token.raw
984
- }
985
- }
986
-
987
- return result
988
- }
989
-
990
- type LineInfo = {
991
- text: string
992
- inCodeBlock: boolean
993
- lang: string
994
- isOpeningFence: boolean
995
- isClosingFence: boolean
996
- }
997
-
998
- export function splitMarkdownForDiscord({
999
- content,
1000
- maxLength,
1001
- }: {
1002
- content: string
1003
- maxLength: number
1004
- }): string[] {
1005
- if (content.length <= maxLength) {
1006
- return [content]
1007
- }
1008
-
1009
- const lexer = new Lexer()
1010
- const tokens = lexer.lex(content)
1011
-
1012
- const lines: LineInfo[] = []
1013
- for (const token of tokens) {
1014
- if (token.type === 'code') {
1015
- const lang = token.lang || ''
1016
- lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
1017
- const codeLines = token.text.split('\n')
1018
- for (const codeLine of codeLines) {
1019
- lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
1020
- }
1021
- lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
1022
- } else {
1023
- const rawLines = token.raw.split('\n')
1024
- for (let i = 0; i < rawLines.length; i++) {
1025
- const isLast = i === rawLines.length - 1
1026
- const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
1027
- if (text) {
1028
- lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
1029
- }
1030
- }
1031
- }
1032
- }
1033
-
1034
- const chunks: string[] = []
1035
- let currentChunk = ''
1036
- let currentLang: string | null = null
1037
-
1038
- for (const line of lines) {
1039
- const wouldExceed = currentChunk.length + line.text.length > maxLength
1040
-
1041
- if (wouldExceed && currentChunk) {
1042
- if (currentLang !== null) {
1043
- currentChunk += '```\n'
1044
- }
1045
- chunks.push(currentChunk)
1046
-
1047
- if (line.isClosingFence && currentLang !== null) {
1048
- currentChunk = ''
1049
- currentLang = null
1050
- continue
1051
- }
1052
-
1053
- if (line.inCodeBlock || line.isOpeningFence) {
1054
- const lang = line.lang
1055
- currentChunk = '```' + lang + '\n'
1056
- if (!line.isOpeningFence) {
1057
- currentChunk += line.text
1058
- }
1059
- currentLang = lang
1060
- } else {
1061
- currentChunk = line.text
1062
- currentLang = null
1063
- }
1064
- } else {
1065
- currentChunk += line.text
1066
- if (line.inCodeBlock || line.isOpeningFence) {
1067
- currentLang = line.lang
1068
- } else if (line.isClosingFence) {
1069
- currentLang = null
1070
- }
1071
- }
1072
- }
1073
-
1074
- if (currentChunk) {
1075
- chunks.push(currentChunk)
1076
- }
1077
-
1078
- return chunks
1079
- }
1080
-
1081
- /**
1082
- * Escape Discord formatting characters to prevent breaking code blocks and inline code
1083
- */
1084
- function escapeDiscordFormatting(text: string): string {
1085
- return text
1086
- .replace(/```/g, '\\`\\`\\`') // Triple backticks
1087
- .replace(/````/g, '\\`\\`\\`\\`') // Quadruple backticks
1088
- }
1089
-
1090
- function escapeInlineCode(text: string): string {
1091
- return text
1092
- .replace(/``/g, '\\`\\`') // Double backticks
1093
- .replace(/(?<!\\)`(?!`)/g, '\\`') // Single backticks (not already escaped or part of double/triple)
1094
- .replace(/\|\|/g, '\\|\\|') // Double pipes (spoiler syntax)
1095
- }
1096
-
1097
- async function resolveTextChannel(
1098
- channel: TextChannel | ThreadChannel | null | undefined,
1099
- ): Promise<TextChannel | null> {
1100
- if (!channel) {
1101
- return null
1102
- }
1103
-
1104
- if (channel.type === ChannelType.GuildText) {
1105
- return channel as TextChannel
1106
- }
1107
-
1108
- if (
1109
- channel.type === ChannelType.PublicThread ||
1110
- channel.type === ChannelType.PrivateThread ||
1111
- channel.type === ChannelType.AnnouncementThread
1112
- ) {
1113
- const parentId = channel.parentId
1114
- if (parentId) {
1115
- const parent = await channel.guild.channels.fetch(parentId)
1116
- if (parent?.type === ChannelType.GuildText) {
1117
- return parent as TextChannel
1118
- }
1119
- }
1120
- }
1121
-
1122
- return null
1123
- }
1124
-
1125
- function getKimakiMetadata(textChannel: TextChannel | null): {
1126
- projectDirectory?: string
1127
- channelAppId?: string
1128
- } {
1129
- if (!textChannel?.topic) {
1130
- return {}
1131
- }
1132
-
1133
- const extracted = extractTagsArrays({
1134
- xml: textChannel.topic,
1135
- tags: ['kimaki.directory', 'kimaki.app'],
1136
- })
1137
-
1138
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
1139
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
1140
-
1141
- return { projectDirectory, channelAppId }
1142
- }
1143
-
1144
- export async function initializeOpencodeForDirectory(directory: string) {
1145
- // console.log(`[OPENCODE] Initializing for directory: ${directory}`)
1146
-
1147
- // Check if we already have a server for this directory
1148
- const existing = opencodeServers.get(directory)
1149
- if (existing && !existing.process.killed) {
1150
- opencodeLogger.log(
1151
- `Reusing existing server on port ${existing.port} for directory: ${directory}`,
1152
- )
1153
- return () => {
1154
- const entry = opencodeServers.get(directory)
1155
- if (!entry?.client) {
1156
- throw new Error(
1157
- `OpenCode server for directory "${directory}" is in an error state (no client available)`,
1158
- )
1159
- }
1160
- return entry.client
1161
- }
1162
- }
1163
-
1164
- const port = await getOpenPort()
1165
- // console.log(
1166
- // `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
1167
- // )
1168
-
1169
- const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
1170
-
1171
- const serverProcess = spawn(
1172
- opencodeCommand,
1173
- ['serve', '--port', port.toString()],
1174
- {
1175
- stdio: 'pipe',
1176
- detached: false,
1177
- cwd: directory,
1178
- env: {
1179
- ...process.env,
1180
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
1181
- $schema: 'https://opencode.ai/config.json',
1182
- lsp: {
1183
- typescript: { disabled: true },
1184
- eslint: { disabled: true },
1185
- gopls: { disabled: true },
1186
- 'ruby-lsp': { disabled: true },
1187
- pyright: { disabled: true },
1188
- 'elixir-ls': { disabled: true },
1189
- zls: { disabled: true },
1190
- csharp: { disabled: true },
1191
- vue: { disabled: true },
1192
- rust: { disabled: true },
1193
- clangd: { disabled: true },
1194
- svelte: { disabled: true },
1195
- },
1196
- formatter: {
1197
- prettier: { disabled: true },
1198
- biome: { disabled: true },
1199
- gofmt: { disabled: true },
1200
- mix: { disabled: true },
1201
- zig: { disabled: true },
1202
- 'clang-format': { disabled: true },
1203
- ktlint: { disabled: true },
1204
- ruff: { disabled: true },
1205
- rubocop: { disabled: true },
1206
- standardrb: { disabled: true },
1207
- htmlbeautifier: { disabled: true },
1208
- },
1209
- permission: {
1210
- edit: 'allow',
1211
- bash: 'allow',
1212
- webfetch: 'allow',
1213
- },
1214
- } satisfies Config),
1215
- OPENCODE_PORT: port.toString(),
1216
- },
1217
- },
1218
- )
1219
-
1220
- serverProcess.stdout?.on('data', (data) => {
1221
- opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`)
1222
- })
1223
-
1224
- serverProcess.stderr?.on('data', (data) => {
1225
- opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`)
1226
- })
1227
-
1228
- serverProcess.on('error', (error) => {
1229
- opencodeLogger.error(`Failed to start server on port :`, port, error)
1230
- })
1231
-
1232
- serverProcess.on('exit', (code) => {
1233
- opencodeLogger.log(
1234
- `Opencode server on ${directory} exited with code:`,
1235
- code,
1236
- )
1237
- opencodeServers.delete(directory)
1238
- if (code !== 0) {
1239
- const retryCount = serverRetryCount.get(directory) || 0
1240
- if (retryCount < 5) {
1241
- serverRetryCount.set(directory, retryCount + 1)
1242
- opencodeLogger.log(
1243
- `Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`,
1244
- )
1245
- initializeOpencodeForDirectory(directory).catch((e) => {
1246
- opencodeLogger.error(`Failed to restart opencode server:`, e)
1247
- })
1248
- } else {
1249
- opencodeLogger.error(
1250
- `Server for ${directory} crashed too many times (5), not restarting`,
1251
- )
1252
- }
1253
- } else {
1254
- // Reset retry count on clean exit
1255
- serverRetryCount.delete(directory)
1256
- }
1257
- })
1258
-
1259
- await waitForServer(port)
1260
-
1261
- const client = createOpencodeClient({
1262
- baseUrl: `http://localhost:${port}`,
1263
- fetch: (request: Request) =>
1264
- fetch(request, {
1265
- // @ts-ignore
1266
- timeout: false,
1267
- }),
1268
- })
1269
-
1270
- opencodeServers.set(directory, {
1271
- process: serverProcess,
1272
- client,
1273
- port,
1274
- })
1275
-
1276
- return () => {
1277
- const entry = opencodeServers.get(directory)
1278
- if (!entry?.client) {
1279
- throw new Error(
1280
- `OpenCode server for directory "${directory}" is in an error state (no client available)`,
1281
- )
1282
- }
1283
- return entry.client
1284
- }
1285
- }
1286
-
1287
- function getToolSummaryText(part: Part): string {
1288
- if (part.type !== 'tool') return ''
1289
-
1290
- if (part.tool === 'edit') {
1291
- const filePath = (part.state.input?.filePath as string) || ''
1292
- const newString = (part.state.input?.newString as string) || ''
1293
- const oldString = (part.state.input?.oldString as string) || ''
1294
- const added = newString.split('\n').length
1295
- const removed = oldString.split('\n').length
1296
- const fileName = filePath.split('/').pop() || ''
1297
- return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
1298
- }
1299
-
1300
- if (part.tool === 'write') {
1301
- const filePath = (part.state.input?.filePath as string) || ''
1302
- const content = (part.state.input?.content as string) || ''
1303
- const lines = content.split('\n').length
1304
- const fileName = filePath.split('/').pop() || ''
1305
- return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
1306
- }
1307
-
1308
- if (part.tool === 'webfetch') {
1309
- const url = (part.state.input?.url as string) || ''
1310
- const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
1311
- return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
1312
- }
1313
-
1314
- if (part.tool === 'read') {
1315
- const filePath = (part.state.input?.filePath as string) || ''
1316
- const fileName = filePath.split('/').pop() || ''
1317
- return fileName ? `*${fileName}*` : ''
1318
- }
1319
-
1320
- if (part.tool === 'list') {
1321
- const path = (part.state.input?.path as string) || ''
1322
- const dirName = path.split('/').pop() || path
1323
- return dirName ? `*${dirName}*` : ''
1324
- }
1325
-
1326
- if (part.tool === 'glob') {
1327
- const pattern = (part.state.input?.pattern as string) || ''
1328
- return pattern ? `*${pattern}*` : ''
1329
- }
1330
-
1331
- if (part.tool === 'grep') {
1332
- const pattern = (part.state.input?.pattern as string) || ''
1333
- return pattern ? `*${pattern}*` : ''
1334
- }
1335
-
1336
- if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
1337
- return ''
1338
- }
1339
-
1340
- if (part.tool === 'task') {
1341
- const description = (part.state.input?.description as string) || ''
1342
- return description ? `_${description}_` : ''
1343
- }
1344
-
1345
- if (part.tool === 'skill') {
1346
- const name = (part.state.input?.name as string) || ''
1347
- return name ? `_${name}_` : ''
1348
- }
1349
-
1350
- if (!part.state.input) return ''
1351
-
1352
- const inputFields = Object.entries(part.state.input)
1353
- .map(([key, value]) => {
1354
- if (value === null || value === undefined) return null
1355
- const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
1356
- const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue
1357
- return `${key}: ${truncatedValue}`
1358
- })
1359
- .filter(Boolean)
1360
-
1361
- if (inputFields.length === 0) return ''
1362
-
1363
- return `(${inputFields.join(', ')})`
1364
- }
1365
-
1366
- function formatTodoList(part: Part): string {
1367
- if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
1368
- const todos =
1369
- (part.state.input?.todos as {
1370
- content: string
1371
- status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1372
- }[]) || []
1373
- const activeIndex = todos.findIndex((todo) => {
1374
- return todo.status === 'in_progress'
1375
- })
1376
- const activeTodo = todos[activeIndex]
1377
- if (activeIndex === -1 || !activeTodo) return ''
1378
- return `${activeIndex + 1}. **${activeTodo.content}**`
1379
- }
1380
-
1381
- function formatPart(part: Part): string {
1382
- if (part.type === 'text') {
1383
- return part.text || ''
1384
- }
1385
-
1386
- if (part.type === 'reasoning') {
1387
- if (!part.text?.trim()) return ''
1388
- return `◼︎ thinking`
1389
- }
1390
-
1391
- if (part.type === 'file') {
1392
- return `📄 ${part.filename || 'File'}`
1393
- }
1394
-
1395
- if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
1396
- return ''
1397
- }
1398
-
1399
- if (part.type === 'agent') {
1400
- return `◼︎ agent ${part.id}`
1401
- }
1402
-
1403
- if (part.type === 'snapshot') {
1404
- return `◼︎ snapshot ${part.snapshot}`
1405
- }
1406
-
1407
- if (part.type === 'tool') {
1408
- if (part.tool === 'todowrite') {
1409
- return formatTodoList(part)
1410
- }
1411
-
1412
- if (part.state.status === 'pending') {
1413
- return ''
1414
- }
1415
-
1416
- const summaryText = getToolSummaryText(part)
1417
- const stateTitle = 'title' in part.state ? part.state.title : undefined
1418
-
1419
- let toolTitle = ''
1420
- if (part.state.status === 'error') {
1421
- toolTitle = part.state.error || 'error'
1422
- } else if (part.tool === 'bash') {
1423
- const command = (part.state.input?.command as string) || ''
1424
- const description = (part.state.input?.description as string) || ''
1425
- const isSingleLine = !command.includes('\n')
1426
- const hasUnderscores = command.includes('_')
1427
- if (isSingleLine && !hasUnderscores && command.length <= 50) {
1428
- toolTitle = `_${command}_`
1429
- } else if (description) {
1430
- toolTitle = `_${description}_`
1431
- } else if (stateTitle) {
1432
- toolTitle = `_${stateTitle}_`
1433
- }
1434
- } else if (stateTitle) {
1435
- toolTitle = `_${stateTitle}_`
1436
- }
1437
-
1438
- const icon = part.state.status === 'error' ? '⨯' : '◼︎'
1439
- return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
1440
- }
1441
-
1442
- discordLogger.warn('Unknown part type:', part)
1443
- return ''
1444
- }
1445
-
1446
- export async function createDiscordClient() {
1447
- return new Client({
1448
- intents: [
1449
- GatewayIntentBits.Guilds,
1450
- GatewayIntentBits.GuildMessages,
1451
- GatewayIntentBits.MessageContent,
1452
- GatewayIntentBits.GuildVoiceStates,
1453
- ],
1454
- partials: [
1455
- Partials.Channel,
1456
- Partials.Message,
1457
- Partials.User,
1458
- Partials.ThreadMember,
1459
- ],
1460
- })
1461
- }
1462
-
1463
- async function handleOpencodeSession({
1464
- prompt,
1465
- thread,
1466
- projectDirectory,
1467
- originalMessage,
1468
- images = [],
1469
- parsedCommand,
1470
- }: {
1471
- prompt: string
1472
- thread: ThreadChannel
1473
- projectDirectory?: string
1474
- originalMessage?: Message
1475
- images?: FilePartInput[]
1476
- parsedCommand?: ParsedCommand
1477
- }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
1478
- voiceLogger.log(
1479
- `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
1480
- )
1481
-
1482
- // Track session start time
1483
- const sessionStartTime = Date.now()
1484
-
1485
- // Use default directory if not specified
1486
- const directory = projectDirectory || process.cwd()
1487
- sessionLogger.log(`Using directory: ${directory}`)
1488
-
1489
- // Note: We'll cancel the existing request after we have the session ID
1490
-
1491
- const getClient = await initializeOpencodeForDirectory(directory)
1492
-
1493
- // Get the port for this directory
1494
- const serverEntry = opencodeServers.get(directory)
1495
- const port = serverEntry?.port
1496
-
1497
- // Get session ID from database
1498
- const row = getDatabase()
1499
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
1500
- .get(thread.id) as { session_id: string } | undefined
1501
- let sessionId = row?.session_id
1502
- let session
1503
-
1504
- if (sessionId) {
1505
- sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
1506
- try {
1507
- const sessionResponse = await getClient().session.get({
1508
- path: { id: sessionId },
1509
- })
1510
- session = sessionResponse.data
1511
- sessionLogger.log(`Successfully reused session ${sessionId}`)
1512
- } catch (error) {
1513
- voiceLogger.log(
1514
- `[SESSION] Session ${sessionId} not found, will create new one`,
1515
- )
1516
- }
1517
- }
1518
-
1519
- if (!session) {
1520
- const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
1521
- voiceLogger.log(
1522
- `[SESSION] Creating new session with title: "${sessionTitle}"`,
1523
- )
1524
- const sessionResponse = await getClient().session.create({
1525
- body: { title: sessionTitle },
1526
- })
1527
- session = sessionResponse.data
1528
- sessionLogger.log(`Created new session ${session?.id}`)
1529
- }
1530
-
1531
- if (!session) {
1532
- throw new Error('Failed to create or get session')
1533
- }
1534
-
1535
- // Store session ID in database
1536
- getDatabase()
1537
- .prepare(
1538
- 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
1539
- )
1540
- .run(thread.id, session.id)
1541
- dbLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
1542
-
1543
- // Cancel any existing request for this session
1544
- const existingController = abortControllers.get(session.id)
1545
- if (existingController) {
1546
- voiceLogger.log(
1547
- `[ABORT] Cancelling existing request for session: ${session.id}`,
1548
- )
1549
- existingController.abort(new Error('New request started'))
1550
- }
1551
-
1552
- const abortController = new AbortController()
1553
- abortControllers.set(session.id, abortController)
1554
-
1555
- if (existingController) {
1556
- await new Promise((resolve) => { setTimeout(resolve, 200) })
1557
- if (abortController.signal.aborted) {
1558
- sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
1559
- return
1560
- }
1561
- }
1562
-
1563
- if (abortController.signal.aborted) {
1564
- sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
1565
- return
1566
- }
1567
-
1568
- const eventsResult = await getClient().event.subscribe({
1569
- signal: abortController.signal,
1570
- })
1571
-
1572
- if (abortController.signal.aborted) {
1573
- sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
1574
- return
1575
- }
1576
-
1577
- const events = eventsResult.stream
1578
- sessionLogger.log(`Subscribed to OpenCode events`)
1579
-
1580
- const sentPartIds = new Set<string>(
1581
- (getDatabase()
1582
- .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
1583
- .all(thread.id) as { part_id: string }[])
1584
- .map((row) => row.part_id)
1585
- )
1586
-
1587
- let currentParts: Part[] = []
1588
- let stopTyping: (() => void) | null = null
1589
- let usedModel: string | undefined
1590
- let usedProviderID: string | undefined
1591
- let tokensUsedInSession = 0
1592
- let lastDisplayedContextPercentage = 0
1593
- let modelContextLimit: number | undefined
1594
-
1595
- let typingInterval: NodeJS.Timeout | null = null
1596
-
1597
- function startTyping(): () => void {
1598
- if (abortController.signal.aborted) {
1599
- discordLogger.log(`Not starting typing, already aborted`)
1600
- return () => {}
1601
- }
1602
- if (typingInterval) {
1603
- clearInterval(typingInterval)
1604
- typingInterval = null
1605
- }
1606
-
1607
- thread.sendTyping().catch((e) => {
1608
- discordLogger.log(`Failed to send initial typing: ${e}`)
1609
- })
1610
-
1611
- typingInterval = setInterval(() => {
1612
- thread.sendTyping().catch((e) => {
1613
- discordLogger.log(`Failed to send periodic typing: ${e}`)
1614
- })
1615
- }, 8000)
1616
-
1617
- if (!abortController.signal.aborted) {
1618
- abortController.signal.addEventListener(
1619
- 'abort',
1620
- () => {
1621
- if (typingInterval) {
1622
- clearInterval(typingInterval)
1623
- typingInterval = null
1624
- }
1625
- },
1626
- { once: true },
1627
- )
1628
- }
1629
-
1630
- return () => {
1631
- if (typingInterval) {
1632
- clearInterval(typingInterval)
1633
- typingInterval = null
1634
- }
1635
- }
1636
- }
1637
-
1638
- const sendPartMessage = async (part: Part) => {
1639
- const content = formatPart(part) + '\n\n'
1640
- if (!content.trim() || content.length === 0) {
1641
- discordLogger.log(`SKIP: Part ${part.id} has no content`)
1642
- return
1643
- }
1644
-
1645
- // Skip if already sent
1646
- if (sentPartIds.has(part.id)) {
1647
- return
1648
- }
1649
-
1650
- try {
1651
- const firstMessage = await sendThreadMessage(thread, content)
1652
- sentPartIds.add(part.id)
1653
-
1654
- // Store part-message mapping in database
1655
- getDatabase()
1656
- .prepare(
1657
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
1658
- )
1659
- .run(part.id, firstMessage.id, thread.id)
1660
- } catch (error) {
1661
- discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error)
1662
- }
1663
- }
1664
-
1665
- const eventHandler = async () => {
1666
- try {
1667
- let assistantMessageId: string | undefined
1668
-
1669
- for await (const event of events) {
1670
- if (event.type === 'message.updated') {
1671
- const msg = event.properties.info
1672
-
1673
-
1674
-
1675
- if (msg.sessionID !== session.id) {
1676
- continue
1677
- }
1678
-
1679
- // Track assistant message ID
1680
- if (msg.role === 'assistant') {
1681
- const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
1682
- if (newTokensTotal > 0) {
1683
- tokensUsedInSession = newTokensTotal
1684
- }
1685
-
1686
- assistantMessageId = msg.id
1687
- usedModel = msg.modelID
1688
- usedProviderID = msg.providerID
1689
-
1690
- if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
1691
- if (!modelContextLimit) {
1692
- try {
1693
- const providersResponse = await getClient().provider.list({ query: { directory } })
1694
- const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1695
- const model = provider?.models?.[usedModel]
1696
- if (model?.limit?.context) {
1697
- modelContextLimit = model.limit.context
1698
- }
1699
- } catch (e) {
1700
- sessionLogger.error('Failed to fetch provider info for context limit:', e)
1701
- }
1702
- }
1703
-
1704
- if (modelContextLimit) {
1705
- const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
1706
- const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
1707
- if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
1708
- lastDisplayedContextPercentage = thresholdCrossed
1709
- await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`)
1710
- }
1711
- }
1712
- }
1713
- }
1714
- } else if (event.type === 'message.part.updated') {
1715
- const part = event.properties.part
1716
-
1717
-
1718
- if (part.sessionID !== session.id) {
1719
- continue
1720
- }
1721
-
1722
- // Only process parts from assistant messages
1723
- if (part.messageID !== assistantMessageId) {
1724
- continue
1725
- }
1726
-
1727
- const existingIndex = currentParts.findIndex(
1728
- (p: Part) => p.id === part.id,
1729
- )
1730
- if (existingIndex >= 0) {
1731
- currentParts[existingIndex] = part
1732
- } else {
1733
- currentParts.push(part)
1734
- }
1735
-
1736
-
1737
-
1738
- // Start typing on step-start
1739
- if (part.type === 'step-start') {
1740
- stopTyping = startTyping()
1741
- }
1742
-
1743
- // Send tool parts immediately when they start running
1744
- if (part.type === 'tool' && part.state.status === 'running') {
1745
- await sendPartMessage(part)
1746
- }
1747
-
1748
- // Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
1749
- if (part.type === 'reasoning') {
1750
- await sendPartMessage(part)
1751
- }
1752
-
1753
- // Check if this is a step-finish part
1754
- if (part.type === 'step-finish') {
1755
-
1756
- // Send all parts accumulated so far to Discord
1757
- for (const p of currentParts) {
1758
- // Skip step-start and step-finish parts as they have no visual content
1759
- if (p.type !== 'step-start' && p.type !== 'step-finish') {
1760
- await sendPartMessage(p)
1761
- }
1762
- }
1763
- // 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
1764
- setTimeout(() => {
1765
- if (abortController.signal.aborted) return
1766
- stopTyping = startTyping()
1767
- }, 300)
1768
- }
1769
- } else if (event.type === 'session.error') {
1770
- sessionLogger.error(`ERROR:`, event.properties)
1771
- if (event.properties.sessionID === session.id) {
1772
- const errorData = event.properties.error
1773
- const errorMessage = errorData?.data?.message || 'Unknown error'
1774
- sessionLogger.error(`Sending error to thread: ${errorMessage}`)
1775
- await sendThreadMessage(
1776
- thread,
1777
- `✗ opencode session error: ${errorMessage}`,
1778
- )
1779
-
1780
- // Update reaction to error
1781
- if (originalMessage) {
1782
- try {
1783
- await originalMessage.reactions.removeAll()
1784
- await originalMessage.react('❌')
1785
- voiceLogger.log(
1786
- `[REACTION] Added error reaction due to session error`,
1787
- )
1788
- } catch (e) {
1789
- discordLogger.log(`Could not update reaction:`, e)
1790
- }
1791
- }
1792
- } else {
1793
- voiceLogger.log(
1794
- `[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`,
1795
- )
1796
- }
1797
- break
1798
- } else if (event.type === 'permission.updated') {
1799
- const permission = event.properties
1800
- if (permission.sessionID !== session.id) {
1801
- voiceLogger.log(
1802
- `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
1803
- )
1804
- continue
1805
- }
1806
-
1807
- sessionLogger.log(
1808
- `Permission requested: type=${permission.type}, title=${permission.title}`,
1809
- )
1810
-
1811
- const patternStr = Array.isArray(permission.pattern)
1812
- ? permission.pattern.join(', ')
1813
- : permission.pattern || ''
1814
-
1815
- const permissionMessage = await sendThreadMessage(
1816
- thread,
1817
- `⚠️ **Permission Required**\n\n` +
1818
- `**Type:** \`${permission.type}\`\n` +
1819
- `**Action:** ${permission.title}\n` +
1820
- (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
1821
- `\nUse \`/accept\` or \`/reject\` to respond.`,
1822
- )
1823
-
1824
- pendingPermissions.set(thread.id, {
1825
- permission,
1826
- messageId: permissionMessage.id,
1827
- directory,
1828
- })
1829
- } else if (event.type === 'permission.replied') {
1830
- const { permissionID, response, sessionID } = event.properties
1831
- if (sessionID !== session.id) {
1832
- continue
1833
- }
1834
-
1835
- sessionLogger.log(
1836
- `Permission ${permissionID} replied with: ${response}`,
1837
- )
1838
-
1839
- const pending = pendingPermissions.get(thread.id)
1840
- if (pending && pending.permission.id === permissionID) {
1841
- pendingPermissions.delete(thread.id)
1842
- }
1843
- }
1844
- }
1845
- } catch (e) {
1846
- if (isAbortError(e, abortController.signal)) {
1847
- sessionLogger.log(
1848
- 'AbortController aborted event handling (normal exit)',
1849
- )
1850
- return
1851
- }
1852
- sessionLogger.error(`Unexpected error in event handling code`, e)
1853
- throw e
1854
- } finally {
1855
- // Send any remaining parts that weren't sent
1856
- for (const part of currentParts) {
1857
- if (!sentPartIds.has(part.id)) {
1858
- try {
1859
- await sendPartMessage(part)
1860
- } catch (error) {
1861
- sessionLogger.error(`Failed to send part ${part.id}:`, error)
1862
- }
1863
- }
1864
- }
1865
-
1866
- // Stop typing when session ends
1867
- if (stopTyping) {
1868
- stopTyping()
1869
- stopTyping = null
1870
- }
1871
-
1872
- // Only send duration message if request was not aborted or was aborted with 'finished' reason
1873
- if (
1874
- !abortController.signal.aborted ||
1875
- abortController.signal.reason === 'finished'
1876
- ) {
1877
- const sessionDuration = prettyMilliseconds(
1878
- Date.now() - sessionStartTime,
1879
- )
1880
- const attachCommand = port ? ` ⋅ ${session.id}` : ''
1881
- const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
1882
- let contextInfo = ''
1883
-
1884
-
1885
- try {
1886
- const providersResponse = await getClient().provider.list({ query: { directory } })
1887
- const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1888
- const model = provider?.models?.[usedModel || '']
1889
- if (model?.limit?.context) {
1890
- const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
1891
- contextInfo = ` ⋅ ${percentage}%`
1892
- }
1893
- } catch (e) {
1894
- sessionLogger.error('Failed to fetch provider info for context percentage:', e)
1895
- }
1896
-
1897
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
1898
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
1899
- } else {
1900
- sessionLogger.log(
1901
- `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
1902
- )
1903
- }
1904
- }
1905
- }
1906
-
1907
- try {
1908
- const eventHandlerPromise = eventHandler()
1909
-
1910
- if (abortController.signal.aborted) {
1911
- sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
1912
- return
1913
- }
1914
-
1915
- stopTyping = startTyping()
1916
-
1917
- let response: { data?: unknown; error?: unknown; response: Response }
1918
- if (parsedCommand?.isCommand) {
1919
- sessionLogger.log(
1920
- `[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
1921
- )
1922
- response = await getClient().session.command({
1923
- path: { id: session.id },
1924
- body: {
1925
- command: parsedCommand.command,
1926
- arguments: parsedCommand.arguments,
1927
- },
1928
- signal: abortController.signal,
1929
- })
1930
- } else {
1931
- voiceLogger.log(
1932
- `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
1933
- )
1934
- if (images.length > 0) {
1935
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
1936
- }
1937
-
1938
- const parts = [{ type: 'text' as const, text: prompt }, ...images]
1939
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
1940
-
1941
- response = await getClient().session.prompt({
1942
- path: { id: session.id },
1943
- body: {
1944
- parts,
1945
- system: getOpencodeSystemMessage({ sessionId: session.id }),
1946
- },
1947
- signal: abortController.signal,
1948
- })
1949
- }
1950
-
1951
- if (response.error) {
1952
- const errorMessage = (() => {
1953
- const err = response.error
1954
- if (err && typeof err === 'object') {
1955
- if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
1956
- return String(err.data.message)
1957
- }
1958
- if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
1959
- return JSON.stringify(err.errors)
1960
- }
1961
- }
1962
- return JSON.stringify(err)
1963
- })()
1964
- throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
1965
- }
1966
-
1967
- abortController.abort('finished')
1968
-
1969
- sessionLogger.log(`Successfully sent prompt, got response`)
1970
-
1971
- if (originalMessage) {
1972
- try {
1973
- await originalMessage.reactions.removeAll()
1974
- await originalMessage.react('✅')
1975
- } catch (e) {
1976
- discordLogger.log(`Could not update reactions:`, e)
1977
- }
1978
- }
1979
-
1980
- return { sessionID: session.id, result: response.data, port }
1981
- } catch (error) {
1982
- sessionLogger.error(`ERROR: Failed to send prompt:`, error)
1983
-
1984
- if (!isAbortError(error, abortController.signal)) {
1985
- abortController.abort('error')
1986
-
1987
- if (originalMessage) {
1988
- try {
1989
- await originalMessage.reactions.removeAll()
1990
- await originalMessage.react('❌')
1991
- discordLogger.log(`Added error reaction to message`)
1992
- } catch (e) {
1993
- discordLogger.log(`Could not update reaction:`, e)
1994
- }
1995
- }
1996
- const errorName =
1997
- error &&
1998
- typeof error === 'object' &&
1999
- 'constructor' in error &&
2000
- error.constructor &&
2001
- typeof error.constructor.name === 'string'
2002
- ? error.constructor.name
2003
- : typeof error
2004
- const errorMsg =
2005
- error instanceof Error ? error.stack || error.message : String(error)
2006
- await sendThreadMessage(
2007
- thread,
2008
- `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
2009
- )
2010
- }
2011
- }
2012
- }
2013
-
2014
- export type ChannelWithTags = {
2015
- id: string
2016
- name: string
2017
- description: string | null
2018
- kimakiDirectory?: string
2019
- kimakiApp?: string
2020
- }
2021
-
2022
- export async function getChannelsWithDescriptions(
2023
- guild: Guild,
2024
- ): Promise<ChannelWithTags[]> {
2025
- const channels: ChannelWithTags[] = []
2026
-
2027
- guild.channels.cache
2028
- .filter((channel) => channel.isTextBased())
2029
- .forEach((channel) => {
2030
- const textChannel = channel as TextChannel
2031
- const description = textChannel.topic || null
2032
-
2033
- let kimakiDirectory: string | undefined
2034
- let kimakiApp: string | undefined
2035
-
2036
- if (description) {
2037
- const extracted = extractTagsArrays({
2038
- xml: description,
2039
- tags: ['kimaki.directory', 'kimaki.app'],
2040
- })
2041
-
2042
- kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim()
2043
- kimakiApp = extracted['kimaki.app']?.[0]?.trim()
2044
- }
2045
-
2046
- channels.push({
2047
- id: textChannel.id,
2048
- name: textChannel.name,
2049
- description,
2050
- kimakiDirectory,
2051
- kimakiApp,
2052
- })
2053
- })
2054
-
2055
- return channels
2056
- }
2057
-
2058
- export async function startDiscordBot({
2059
- token,
2060
- appId,
2061
- discordClient,
2062
- }: StartOptions & { discordClient?: Client }) {
2063
- if (!discordClient) {
2064
- discordClient = await createDiscordClient()
2065
- }
2066
-
2067
- // Get the app ID for this bot instance
2068
- let currentAppId: string | undefined = appId
2069
-
2070
- discordClient.once(Events.ClientReady, async (c) => {
2071
- discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
2072
- discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
2073
- discordLogger.log(`Bot user ID: ${c.user.id}`)
2074
-
2075
- // If appId wasn't provided, fetch it from the application
2076
- if (!currentAppId) {
2077
- await c.application?.fetch()
2078
- currentAppId = c.application?.id
2079
-
2080
- if (!currentAppId) {
2081
- discordLogger.error('Could not get application ID')
2082
- throw new Error('Failed to get bot application ID')
2083
- }
2084
- discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
2085
- } else {
2086
- discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
2087
- }
2088
-
2089
- // List all guilds and channels that belong to this bot
2090
- for (const guild of c.guilds.cache.values()) {
2091
- discordLogger.log(`${guild.name} (${guild.id})`)
2092
-
2093
- const channels = await getChannelsWithDescriptions(guild)
2094
- // Only show channels that belong to this bot
2095
- const kimakiChannels = channels.filter(
2096
- (ch) =>
2097
- ch.kimakiDirectory &&
2098
- (!ch.kimakiApp || ch.kimakiApp === currentAppId),
2099
- )
2100
-
2101
- if (kimakiChannels.length > 0) {
2102
- discordLogger.log(
2103
- ` Found ${kimakiChannels.length} channel(s) for this bot:`,
2104
- )
2105
- for (const channel of kimakiChannels) {
2106
- discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`)
2107
- }
2108
- } else {
2109
- discordLogger.log(` No channels for this bot`)
2110
- }
2111
- }
2112
-
2113
- voiceLogger.log(
2114
- `[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`,
2115
- )
2116
- })
2117
-
2118
- discordClient.on(Events.MessageCreate, async (message: Message) => {
2119
- try {
2120
- if (message.author?.bot) {
2121
- return
2122
- }
2123
- if (message.partial) {
2124
- discordLogger.log(`Fetching partial message ${message.id}`)
2125
- try {
2126
- await message.fetch()
2127
- } catch (error) {
2128
- discordLogger.log(
2129
- `Failed to fetch partial message ${message.id}:`,
2130
- error,
2131
- )
2132
- return
2133
- }
2134
- }
2135
-
2136
- // Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
2137
- if (message.guild && message.member) {
2138
- const isOwner = message.member.id === message.guild.ownerId
2139
- const isAdmin = message.member.permissions.has(
2140
- PermissionsBitField.Flags.Administrator,
2141
- )
2142
- const canManageServer = message.member.permissions.has(
2143
- PermissionsBitField.Flags.ManageGuild,
2144
- )
2145
- const hasKimakiRole = message.member.roles.cache.some(
2146
- (role) => role.name.toLowerCase() === 'kimaki',
2147
- )
2148
-
2149
- if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
2150
- return
2151
- }
2152
- }
2153
-
2154
- const channel = message.channel
2155
- const isThread = [
2156
- ChannelType.PublicThread,
2157
- ChannelType.PrivateThread,
2158
- ChannelType.AnnouncementThread,
2159
- ].includes(channel.type)
2160
-
2161
- // For existing threads, check if session exists
2162
- if (isThread) {
2163
- const thread = channel as ThreadChannel
2164
- discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
2165
-
2166
- const row = getDatabase()
2167
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
2168
- .get(thread.id) as { session_id: string } | undefined
2169
-
2170
- if (!row) {
2171
- discordLogger.log(`No session found for thread ${thread.id}`)
2172
- return
2173
- }
2174
-
2175
- voiceLogger.log(
2176
- `[SESSION] Found session ${row.session_id} for thread ${thread.id}`,
2177
- )
2178
-
2179
- // Get project directory and app ID from parent channel
2180
- const parent = thread.parent as TextChannel | null
2181
- let projectDirectory: string | undefined
2182
- let channelAppId: string | undefined
2183
-
2184
- if (parent?.topic) {
2185
- const extracted = extractTagsArrays({
2186
- xml: parent.topic,
2187
- tags: ['kimaki.directory', 'kimaki.app'],
2188
- })
2189
-
2190
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
2191
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
2192
- }
2193
-
2194
- // Check if this channel belongs to current bot instance
2195
- if (channelAppId && channelAppId !== currentAppId) {
2196
- voiceLogger.log(
2197
- `[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
2198
- )
2199
- return
2200
- }
2201
-
2202
- if (projectDirectory && !fs.existsSync(projectDirectory)) {
2203
- discordLogger.error(`Directory does not exist: ${projectDirectory}`)
2204
- await sendThreadMessage(
2205
- thread,
2206
- `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
2207
- )
2208
- return
2209
- }
2210
-
2211
- // Handle voice message if present
2212
- let messageContent = message.content || ''
2213
-
2214
- const transcription = await processVoiceAttachment({
2215
- message,
2216
- thread,
2217
- projectDirectory,
2218
- appId: currentAppId,
2219
- })
2220
- if (transcription) {
2221
- messageContent = transcription
2222
- }
2223
-
2224
- const fileAttachments = getFileAttachments(message)
2225
- const textAttachmentsContent = await getTextAttachments(message)
2226
- const promptWithAttachments = textAttachmentsContent
2227
- ? `${messageContent}\n\n${textAttachmentsContent}`
2228
- : messageContent
2229
- const parsedCommand = parseSlashCommand(messageContent)
2230
- await handleOpencodeSession({
2231
- prompt: promptWithAttachments,
2232
- thread,
2233
- projectDirectory,
2234
- originalMessage: message,
2235
- images: fileAttachments,
2236
- parsedCommand,
2237
- })
2238
- return
2239
- }
2240
-
2241
- // For text channels, start new sessions with kimaki.directory tag
2242
- if (channel.type === ChannelType.GuildText) {
2243
- const textChannel = channel as TextChannel
2244
- voiceLogger.log(
2245
- `[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
2246
- )
2247
-
2248
- if (!textChannel.topic) {
2249
- voiceLogger.log(
2250
- `[IGNORED] Channel #${textChannel.name} has no description`,
2251
- )
2252
- return
2253
- }
2254
-
2255
- const extracted = extractTagsArrays({
2256
- xml: textChannel.topic,
2257
- tags: ['kimaki.directory', 'kimaki.app'],
2258
- })
2259
-
2260
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
2261
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
2262
-
2263
- if (!projectDirectory) {
2264
- voiceLogger.log(
2265
- `[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`,
2266
- )
2267
- return
2268
- }
2269
-
2270
- // Check if this channel belongs to current bot instance
2271
- if (channelAppId && channelAppId !== currentAppId) {
2272
- voiceLogger.log(
2273
- `[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
2274
- )
2275
- return
2276
- }
2277
-
2278
- discordLogger.log(
2279
- `DIRECTORY: Found kimaki.directory: ${projectDirectory}`,
2280
- )
2281
- if (channelAppId) {
2282
- discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
2283
- }
2284
-
2285
- if (!fs.existsSync(projectDirectory)) {
2286
- discordLogger.error(`Directory does not exist: ${projectDirectory}`)
2287
- await message.reply(
2288
- `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
2289
- )
2290
- return
2291
- }
2292
-
2293
- // Determine if this is a voice message
2294
- const hasVoice = message.attachments.some((a) =>
2295
- a.contentType?.startsWith('audio/'),
2296
- )
2297
-
2298
- // Create thread
2299
- const threadName = hasVoice
2300
- ? 'Voice Message'
2301
- : message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread'
2302
-
2303
- const thread = await message.startThread({
2304
- name: threadName.slice(0, 80),
2305
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
2306
- reason: 'Start Claude session',
2307
- })
2308
-
2309
- discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
2310
-
2311
- // Handle voice message if present
2312
- let messageContent = message.content || ''
2313
-
2314
- const transcription = await processVoiceAttachment({
2315
- message,
2316
- thread,
2317
- projectDirectory,
2318
- isNewThread: true,
2319
- appId: currentAppId,
2320
- })
2321
- if (transcription) {
2322
- messageContent = transcription
2323
- }
2324
-
2325
- const fileAttachments = getFileAttachments(message)
2326
- const textAttachmentsContent = await getTextAttachments(message)
2327
- const promptWithAttachments = textAttachmentsContent
2328
- ? `${messageContent}\n\n${textAttachmentsContent}`
2329
- : messageContent
2330
- const parsedCommand = parseSlashCommand(messageContent)
2331
- await handleOpencodeSession({
2332
- prompt: promptWithAttachments,
2333
- thread,
2334
- projectDirectory,
2335
- originalMessage: message,
2336
- images: fileAttachments,
2337
- parsedCommand,
2338
- })
2339
- } else {
2340
- discordLogger.log(`Channel type ${channel.type} is not supported`)
2341
- }
2342
- } catch (error) {
2343
- voiceLogger.error('Discord handler error:', error)
2344
- try {
2345
- const errMsg = error instanceof Error ? error.message : String(error)
2346
- await message.reply(`Error: ${errMsg}`)
2347
- } catch {
2348
- voiceLogger.error('Discord handler error (fallback):', error)
2349
- }
2350
- }
2351
- })
2352
-
2353
- // Handle slash command interactions
2354
- discordClient.on(
2355
- Events.InteractionCreate,
2356
- async (interaction: Interaction) => {
2357
- try {
2358
- // Handle autocomplete
2359
- if (interaction.isAutocomplete()) {
2360
- if (interaction.commandName === 'resume') {
2361
- const focusedValue = interaction.options.getFocused()
2362
-
2363
- // Get the channel's project directory from its topic
2364
- let projectDirectory: string | undefined
2365
- if (interaction.channel) {
2366
- const textChannel = await resolveTextChannel(
2367
- interaction.channel as TextChannel | ThreadChannel | null,
2368
- )
2369
- if (textChannel) {
2370
- const { projectDirectory: directory, channelAppId } =
2371
- getKimakiMetadata(textChannel)
2372
- if (channelAppId && channelAppId !== currentAppId) {
2373
- await interaction.respond([])
2374
- return
2375
- }
2376
- projectDirectory = directory
2377
- }
2378
- }
2379
-
2380
- if (!projectDirectory) {
2381
- await interaction.respond([])
2382
- return
2383
- }
2384
-
2385
- try {
2386
- // Get OpenCode client for this directory
2387
- const getClient =
2388
- await initializeOpencodeForDirectory(projectDirectory)
2389
-
2390
- // List sessions
2391
- const sessionsResponse = await getClient().session.list()
2392
- if (!sessionsResponse.data) {
2393
- await interaction.respond([])
2394
- return
2395
- }
2396
-
2397
- // Filter and map sessions to choices
2398
- const sessions = sessionsResponse.data
2399
- .filter((session) =>
2400
- session.title
2401
- .toLowerCase()
2402
- .includes(focusedValue.toLowerCase()),
2403
- )
2404
- .slice(0, 25) // Discord limit
2405
- .map((session) => {
2406
- const dateStr = new Date(
2407
- session.time.updated,
2408
- ).toLocaleString()
2409
- const suffix = ` (${dateStr})`
2410
- // Discord limit is 100 chars. Reserve space for suffix.
2411
- const maxTitleLength = 100 - suffix.length
2412
-
2413
- let title = session.title
2414
- if (title.length > maxTitleLength) {
2415
- title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…'
2416
- }
2417
-
2418
- return {
2419
- name: `${title}${suffix}`,
2420
- value: session.id,
2421
- }
2422
- })
2423
-
2424
- await interaction.respond(sessions)
2425
- } catch (error) {
2426
- voiceLogger.error(
2427
- '[AUTOCOMPLETE] Error fetching sessions:',
2428
- error,
2429
- )
2430
- await interaction.respond([])
2431
- }
2432
- } else if (interaction.commandName === 'session') {
2433
- const focusedOption = interaction.options.getFocused(true)
2434
-
2435
- if (focusedOption.name === 'files') {
2436
- const focusedValue = focusedOption.value
2437
-
2438
- // Split by comma to handle multiple files
2439
- const parts = focusedValue.split(',')
2440
- const previousFiles = parts
2441
- .slice(0, -1)
2442
- .map((f) => f.trim())
2443
- .filter((f) => f)
2444
- const currentQuery = (parts[parts.length - 1] || '').trim()
2445
-
2446
- // Get the channel's project directory from its topic
2447
- let projectDirectory: string | undefined
2448
- if (interaction.channel) {
2449
- const textChannel = await resolveTextChannel(
2450
- interaction.channel as TextChannel | ThreadChannel | null,
2451
- )
2452
- if (textChannel) {
2453
- const { projectDirectory: directory, channelAppId } =
2454
- getKimakiMetadata(textChannel)
2455
- if (channelAppId && channelAppId !== currentAppId) {
2456
- await interaction.respond([])
2457
- return
2458
- }
2459
- projectDirectory = directory
2460
- }
2461
- }
2462
-
2463
- if (!projectDirectory) {
2464
- await interaction.respond([])
2465
- return
2466
- }
2467
-
2468
- try {
2469
- // Get OpenCode client for this directory
2470
- const getClient =
2471
- await initializeOpencodeForDirectory(projectDirectory)
2472
-
2473
- // Use find.files to search for files based on current query
2474
- const response = await getClient().find.files({
2475
- query: {
2476
- query: currentQuery || '',
2477
- },
2478
- })
2479
-
2480
- // Get file paths from the response
2481
- const files = response.data || []
2482
-
2483
- // Build the prefix with previous files
2484
- const prefix =
2485
- previousFiles.length > 0
2486
- ? previousFiles.join(', ') + ', '
2487
- : ''
2488
-
2489
- // Map to Discord autocomplete format
2490
- const choices = files
2491
- .map((file: string) => {
2492
- const fullValue = prefix + file
2493
- // Get all basenames for display
2494
- const allFiles = [...previousFiles, file]
2495
- const allBasenames = allFiles.map(
2496
- (f) => f.split('/').pop() || f,
2497
- )
2498
- let displayName = allBasenames.join(', ')
2499
- // Truncate if too long
2500
- if (displayName.length > 100) {
2501
- displayName = '…' + displayName.slice(-97)
2502
- }
2503
- return {
2504
- name: displayName,
2505
- value: fullValue,
2506
- }
2507
- })
2508
- // Discord API limits choice value to 100 characters
2509
- .filter((choice) => choice.value.length <= 100)
2510
- .slice(0, 25) // Discord limit
2511
-
2512
-
2513
- await interaction.respond(choices)
2514
- } catch (error) {
2515
- voiceLogger.error('[AUTOCOMPLETE] Error fetching files:', error)
2516
- await interaction.respond([])
2517
- }
2518
- }
2519
- } else if (interaction.commandName === 'add-project') {
2520
- const focusedValue = interaction.options.getFocused()
2521
-
2522
- try {
2523
- const currentDir = process.cwd()
2524
- const getClient = await initializeOpencodeForDirectory(currentDir)
2525
-
2526
- const projectsResponse = await getClient().project.list({})
2527
- if (!projectsResponse.data) {
2528
- await interaction.respond([])
2529
- return
2530
- }
2531
-
2532
- const db = getDatabase()
2533
- const existingDirs = db
2534
- .prepare(
2535
- 'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
2536
- )
2537
- .all('text') as { directory: string }[]
2538
- const existingDirSet = new Set(
2539
- existingDirs.map((row) => row.directory),
2540
- )
2541
-
2542
- const availableProjects = projectsResponse.data.filter(
2543
- (project) => !existingDirSet.has(project.worktree),
2544
- )
2545
-
2546
- const projects = availableProjects
2547
- .filter((project) => {
2548
- const baseName = path.basename(project.worktree)
2549
- const searchText = `${baseName} ${project.worktree}`.toLowerCase()
2550
- return searchText.includes(focusedValue.toLowerCase())
2551
- })
2552
- .sort((a, b) => {
2553
- const aTime = a.time.initialized || a.time.created
2554
- const bTime = b.time.initialized || b.time.created
2555
- return bTime - aTime
2556
- })
2557
- .slice(0, 25)
2558
- .map((project) => {
2559
- const name = `${path.basename(project.worktree)} (${project.worktree})`
2560
- return {
2561
- name: name.length > 100 ? name.slice(0, 99) + '…' : name,
2562
- value: project.id,
2563
- }
2564
- })
2565
-
2566
- await interaction.respond(projects)
2567
- } catch (error) {
2568
- voiceLogger.error(
2569
- '[AUTOCOMPLETE] Error fetching projects:',
2570
- error,
2571
- )
2572
- await interaction.respond([])
2573
- }
2574
- }
2575
- }
2576
-
2577
- // Handle slash commands
2578
- if (interaction.isChatInputCommand()) {
2579
- const command = interaction
2580
-
2581
- if (command.commandName === 'session') {
2582
- await command.deferReply({ ephemeral: false })
2583
-
2584
- const prompt = command.options.getString('prompt', true)
2585
- const filesString = command.options.getString('files') || ''
2586
- const channel = command.channel
2587
-
2588
- if (!channel || channel.type !== ChannelType.GuildText) {
2589
- await command.editReply(
2590
- 'This command can only be used in text channels',
2591
- )
2592
- return
2593
- }
2594
-
2595
- const textChannel = channel as TextChannel
2596
-
2597
- // Get project directory from channel topic
2598
- let projectDirectory: string | undefined
2599
- let channelAppId: string | undefined
2600
-
2601
- if (textChannel.topic) {
2602
- const extracted = extractTagsArrays({
2603
- xml: textChannel.topic,
2604
- tags: ['kimaki.directory', 'kimaki.app'],
2605
- })
2606
-
2607
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
2608
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
2609
- }
2610
-
2611
- // Check if this channel belongs to current bot instance
2612
- if (channelAppId && channelAppId !== currentAppId) {
2613
- await command.editReply(
2614
- 'This channel is not configured for this bot',
2615
- )
2616
- return
2617
- }
2618
-
2619
- if (!projectDirectory) {
2620
- await command.editReply(
2621
- 'This channel is not configured with a project directory',
2622
- )
2623
- return
2624
- }
2625
-
2626
- if (!fs.existsSync(projectDirectory)) {
2627
- await command.editReply(
2628
- `Directory does not exist: ${projectDirectory}`,
2629
- )
2630
- return
2631
- }
2632
-
2633
- try {
2634
- // Initialize OpenCode client for the directory
2635
- const getClient =
2636
- await initializeOpencodeForDirectory(projectDirectory)
2637
-
2638
- // Process file mentions - split by comma only
2639
- const files = filesString
2640
- .split(',')
2641
- .map((f) => f.trim())
2642
- .filter((f) => f)
2643
-
2644
- // Build the full prompt with file mentions
2645
- let fullPrompt = prompt
2646
- if (files.length > 0) {
2647
- fullPrompt = `${prompt}\n\n@${files.join(' @')}`
2648
- }
2649
-
2650
- // Send a message first, then create thread from it
2651
- const starterMessage = await textChannel.send({
2652
- content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
2653
- })
2654
-
2655
- // Create thread from the message
2656
- const thread = await starterMessage.startThread({
2657
- name: prompt.slice(0, 100),
2658
- autoArchiveDuration: 1440, // 24 hours
2659
- reason: 'OpenCode session',
2660
- })
2661
-
2662
- await command.editReply(
2663
- `Created new session in ${thread.toString()}`,
2664
- )
2665
-
2666
- // Start the OpenCode session
2667
- const parsedCommand = parseSlashCommand(fullPrompt)
2668
- await handleOpencodeSession({
2669
- prompt: fullPrompt,
2670
- thread,
2671
- projectDirectory,
2672
- parsedCommand,
2673
- })
2674
- } catch (error) {
2675
- voiceLogger.error('[SESSION] Error:', error)
2676
- await command.editReply(
2677
- `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2678
- )
2679
- }
2680
- } else if (command.commandName === 'resume') {
2681
- await command.deferReply({ ephemeral: false })
2682
-
2683
- const sessionId = command.options.getString('session', true)
2684
- const channel = command.channel
2685
-
2686
- if (!channel || channel.type !== ChannelType.GuildText) {
2687
- await command.editReply(
2688
- 'This command can only be used in text channels',
2689
- )
2690
- return
2691
- }
2692
-
2693
- const textChannel = channel as TextChannel
2694
-
2695
- // Get project directory from channel topic
2696
- let projectDirectory: string | undefined
2697
- let channelAppId: string | undefined
2698
-
2699
- if (textChannel.topic) {
2700
- const extracted = extractTagsArrays({
2701
- xml: textChannel.topic,
2702
- tags: ['kimaki.directory', 'kimaki.app'],
2703
- })
2704
-
2705
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
2706
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
2707
- }
2708
-
2709
- // Check if this channel belongs to current bot instance
2710
- if (channelAppId && channelAppId !== currentAppId) {
2711
- await command.editReply(
2712
- 'This channel is not configured for this bot',
2713
- )
2714
- return
2715
- }
2716
-
2717
- if (!projectDirectory) {
2718
- await command.editReply(
2719
- 'This channel is not configured with a project directory',
2720
- )
2721
- return
2722
- }
2723
-
2724
- if (!fs.existsSync(projectDirectory)) {
2725
- await command.editReply(
2726
- `Directory does not exist: ${projectDirectory}`,
2727
- )
2728
- return
2729
- }
2730
-
2731
- try {
2732
- // Initialize OpenCode client for the directory
2733
- const getClient =
2734
- await initializeOpencodeForDirectory(projectDirectory)
2735
-
2736
- // Get session title
2737
- const sessionResponse = await getClient().session.get({
2738
- path: { id: sessionId },
2739
- })
2740
-
2741
- if (!sessionResponse.data) {
2742
- await command.editReply('Session not found')
2743
- return
2744
- }
2745
-
2746
- const sessionTitle = sessionResponse.data.title
2747
-
2748
- // Create thread for the resumed session
2749
- const thread = await textChannel.threads.create({
2750
- name: `Resume: ${sessionTitle}`.slice(0, 100),
2751
- autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
2752
- reason: `Resuming session ${sessionId}`,
2753
- })
2754
-
2755
- // Store session ID in database
2756
- getDatabase()
2757
- .prepare(
2758
- 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
2759
- )
2760
- .run(thread.id, sessionId)
2761
-
2762
- voiceLogger.log(
2763
- `[RESUME] Created thread ${thread.id} for session ${sessionId}`,
2764
- )
2765
-
2766
- // Fetch all messages for the session
2767
- const messagesResponse = await getClient().session.messages({
2768
- path: { id: sessionId },
2769
- })
2770
-
2771
- if (!messagesResponse.data) {
2772
- throw new Error('Failed to fetch session messages')
2773
- }
2774
-
2775
- const messages = messagesResponse.data
2776
-
2777
- await command.editReply(
2778
- `Resumed session "${sessionTitle}" in ${thread.toString()}`,
2779
- )
2780
-
2781
- // Send initial message to thread
2782
- await sendThreadMessage(
2783
- thread,
2784
- `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
2785
- )
2786
-
2787
- // Collect all assistant parts first, then only render the last 30
2788
- const allAssistantParts: { id: string; content: string }[] = []
2789
- for (const message of messages) {
2790
- if (message.info.role === 'assistant') {
2791
- for (const part of message.parts) {
2792
- const content = formatPart(part)
2793
- if (content.trim()) {
2794
- allAssistantParts.push({ id: part.id, content })
2795
- }
2796
- }
2797
- }
2798
- }
2799
-
2800
- const partsToRender = allAssistantParts.slice(-30)
2801
- const skippedCount = allAssistantParts.length - partsToRender.length
2802
-
2803
- if (skippedCount > 0) {
2804
- await sendThreadMessage(
2805
- thread,
2806
- `*Skipped ${skippedCount} older assistant parts...*`,
2807
- )
2808
- }
2809
-
2810
- if (partsToRender.length > 0) {
2811
- const combinedContent = partsToRender
2812
- .map((p) => p.content)
2813
- .join('\n')
2814
-
2815
- const discordMessage = await sendThreadMessage(
2816
- thread,
2817
- combinedContent,
2818
- )
2819
-
2820
- const stmt = getDatabase().prepare(
2821
- 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
2822
- )
2823
-
2824
- const transaction = getDatabase().transaction(
2825
- (parts: { id: string }[]) => {
2826
- for (const part of parts) {
2827
- stmt.run(part.id, discordMessage.id, thread.id)
2828
- }
2829
- },
2830
- )
2831
-
2832
- transaction(partsToRender)
2833
- }
2834
-
2835
- const messageCount = messages.length
2836
-
2837
- await sendThreadMessage(
2838
- thread,
2839
- `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
2840
- )
2841
- } catch (error) {
2842
- voiceLogger.error('[RESUME] Error:', error)
2843
- await command.editReply(
2844
- `Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2845
- )
2846
- }
2847
- } else if (command.commandName === 'add-project') {
2848
- await command.deferReply({ ephemeral: false })
2849
-
2850
- const projectId = command.options.getString('project', true)
2851
- const guild = command.guild
2852
-
2853
- if (!guild) {
2854
- await command.editReply('This command can only be used in a guild')
2855
- return
2856
- }
2857
-
2858
- try {
2859
- const currentDir = process.cwd()
2860
- const getClient = await initializeOpencodeForDirectory(currentDir)
2861
-
2862
- const projectsResponse = await getClient().project.list({})
2863
- if (!projectsResponse.data) {
2864
- await command.editReply('Failed to fetch projects')
2865
- return
2866
- }
2867
-
2868
- const project = projectsResponse.data.find(
2869
- (p) => p.id === projectId,
2870
- )
2871
-
2872
- if (!project) {
2873
- await command.editReply('Project not found')
2874
- return
2875
- }
2876
-
2877
- const directory = project.worktree
2878
-
2879
- if (!fs.existsSync(directory)) {
2880
- await command.editReply(`Directory does not exist: ${directory}`)
2881
- return
2882
- }
2883
-
2884
- const db = getDatabase()
2885
- const existingChannel = db
2886
- .prepare(
2887
- 'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
2888
- )
2889
- .get(directory, 'text') as { channel_id: string } | undefined
2890
-
2891
- if (existingChannel) {
2892
- await command.editReply(
2893
- `A channel already exists for this directory: <#${existingChannel.channel_id}>`,
2894
- )
2895
- return
2896
- }
2897
-
2898
- const { textChannelId, voiceChannelId, channelName } =
2899
- await createProjectChannels({
2900
- guild,
2901
- projectDirectory: directory,
2902
- appId: currentAppId!,
2903
- })
2904
-
2905
- await command.editReply(
2906
- `✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
2907
- )
2908
-
2909
- discordLogger.log(
2910
- `Created channels for project ${channelName} at ${directory}`,
2911
- )
2912
- } catch (error) {
2913
- voiceLogger.error('[ADD-PROJECT] Error:', error)
2914
- await command.editReply(
2915
- `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2916
- )
2917
- }
2918
- } else if (command.commandName === 'create-new-project') {
2919
- await command.deferReply({ ephemeral: false })
2920
-
2921
- const projectName = command.options.getString('name', true)
2922
- const guild = command.guild
2923
- const channel = command.channel
2924
-
2925
- if (!guild) {
2926
- await command.editReply('This command can only be used in a guild')
2927
- return
2928
- }
2929
-
2930
- if (!channel || channel.type !== ChannelType.GuildText) {
2931
- await command.editReply('This command can only be used in a text channel')
2932
- return
2933
- }
2934
-
2935
- const sanitizedName = projectName
2936
- .toLowerCase()
2937
- .replace(/[^a-z0-9-]/g, '-')
2938
- .replace(/-+/g, '-')
2939
- .replace(/^-|-$/g, '')
2940
- .slice(0, 100)
2941
-
2942
- if (!sanitizedName) {
2943
- await command.editReply('Invalid project name')
2944
- return
2945
- }
2946
-
2947
- const kimakiDir = path.join(os.homedir(), 'kimaki')
2948
- const projectDirectory = path.join(kimakiDir, sanitizedName)
2949
-
2950
- try {
2951
- if (!fs.existsSync(kimakiDir)) {
2952
- fs.mkdirSync(kimakiDir, { recursive: true })
2953
- discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
2954
- }
2955
-
2956
- if (fs.existsSync(projectDirectory)) {
2957
- await command.editReply(`Project directory already exists: ${projectDirectory}`)
2958
- return
2959
- }
2960
-
2961
- fs.mkdirSync(projectDirectory, { recursive: true })
2962
- discordLogger.log(`Created project directory: ${projectDirectory}`)
2963
-
2964
- const { execSync } = await import('node:child_process')
2965
- execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
2966
- discordLogger.log(`Initialized git in: ${projectDirectory}`)
2967
-
2968
- const { textChannelId, voiceChannelId, channelName } =
2969
- await createProjectChannels({
2970
- guild,
2971
- projectDirectory,
2972
- appId: currentAppId!,
2973
- })
2974
-
2975
- const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
2976
-
2977
- await command.editReply(
2978
- `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
2979
- )
2980
-
2981
- const starterMessage = await textChannel.send({
2982
- content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
2983
- })
2984
-
2985
- const thread = await starterMessage.startThread({
2986
- name: `Init: ${sanitizedName}`,
2987
- autoArchiveDuration: 1440,
2988
- reason: 'New project session',
2989
- })
2990
-
2991
- await handleOpencodeSession({
2992
- prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
2993
- thread,
2994
- projectDirectory,
2995
- })
2996
-
2997
- discordLogger.log(
2998
- `Created new project ${channelName} at ${projectDirectory}`,
2999
- )
3000
- } catch (error) {
3001
- voiceLogger.error('[ADD-NEW-PROJECT] Error:', error)
3002
- await command.editReply(
3003
- `Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
3004
- )
3005
- }
3006
- } else if (
3007
- command.commandName === 'accept' ||
3008
- command.commandName === 'accept-always'
3009
- ) {
3010
- const scope = command.commandName === 'accept-always' ? 'always' : 'once'
3011
- const channel = command.channel
3012
-
3013
- if (!channel) {
3014
- await command.reply({
3015
- content: 'This command can only be used in a channel',
3016
- ephemeral: true,
3017
- })
3018
- return
3019
- }
3020
-
3021
- const isThread = [
3022
- ChannelType.PublicThread,
3023
- ChannelType.PrivateThread,
3024
- ChannelType.AnnouncementThread,
3025
- ].includes(channel.type)
3026
-
3027
- if (!isThread) {
3028
- await command.reply({
3029
- content: 'This command can only be used in a thread with an active session',
3030
- ephemeral: true,
3031
- })
3032
- return
3033
- }
3034
-
3035
- const pending = pendingPermissions.get(channel.id)
3036
- if (!pending) {
3037
- await command.reply({
3038
- content: 'No pending permission request in this thread',
3039
- ephemeral: true,
3040
- })
3041
- return
3042
- }
3043
-
3044
- try {
3045
- const getClient = await initializeOpencodeForDirectory(pending.directory)
3046
- await getClient().postSessionIdPermissionsPermissionId({
3047
- path: {
3048
- id: pending.permission.sessionID,
3049
- permissionID: pending.permission.id,
3050
- },
3051
- body: {
3052
- response: scope,
3053
- },
3054
- })
3055
-
3056
- pendingPermissions.delete(channel.id)
3057
- const msg =
3058
- scope === 'always'
3059
- ? `✅ Permission **accepted** (auto-approve similar requests)`
3060
- : `✅ Permission **accepted**`
3061
- await command.reply(msg)
3062
- sessionLogger.log(
3063
- `Permission ${pending.permission.id} accepted with scope: ${scope}`,
3064
- )
3065
- } catch (error) {
3066
- voiceLogger.error('[ACCEPT] Error:', error)
3067
- await command.reply({
3068
- content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
3069
- ephemeral: true,
3070
- })
3071
- }
3072
- } else if (command.commandName === 'reject') {
3073
- const channel = command.channel
3074
-
3075
- if (!channel) {
3076
- await command.reply({
3077
- content: 'This command can only be used in a channel',
3078
- ephemeral: true,
3079
- })
3080
- return
3081
- }
3082
-
3083
- const isThread = [
3084
- ChannelType.PublicThread,
3085
- ChannelType.PrivateThread,
3086
- ChannelType.AnnouncementThread,
3087
- ].includes(channel.type)
3088
-
3089
- if (!isThread) {
3090
- await command.reply({
3091
- content: 'This command can only be used in a thread with an active session',
3092
- ephemeral: true,
3093
- })
3094
- return
3095
- }
3096
-
3097
- const pending = pendingPermissions.get(channel.id)
3098
- if (!pending) {
3099
- await command.reply({
3100
- content: 'No pending permission request in this thread',
3101
- ephemeral: true,
3102
- })
3103
- return
3104
- }
3105
-
3106
- try {
3107
- const getClient = await initializeOpencodeForDirectory(pending.directory)
3108
- await getClient().postSessionIdPermissionsPermissionId({
3109
- path: {
3110
- id: pending.permission.sessionID,
3111
- permissionID: pending.permission.id,
3112
- },
3113
- body: {
3114
- response: 'reject',
3115
- },
3116
- })
3117
-
3118
- pendingPermissions.delete(channel.id)
3119
- await command.reply(`❌ Permission **rejected**`)
3120
- sessionLogger.log(`Permission ${pending.permission.id} rejected`)
3121
- } catch (error) {
3122
- voiceLogger.error('[REJECT] Error:', error)
3123
- await command.reply({
3124
- content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
3125
- ephemeral: true,
3126
- })
3127
- }
3128
- } else if (command.commandName === 'abort') {
3129
- const channel = command.channel
3130
-
3131
- if (!channel) {
3132
- await command.reply({
3133
- content: 'This command can only be used in a channel',
3134
- ephemeral: true,
3135
- })
3136
- return
3137
- }
3138
-
3139
- const isThread = [
3140
- ChannelType.PublicThread,
3141
- ChannelType.PrivateThread,
3142
- ChannelType.AnnouncementThread,
3143
- ].includes(channel.type)
3144
-
3145
- if (!isThread) {
3146
- await command.reply({
3147
- content: 'This command can only be used in a thread with an active session',
3148
- ephemeral: true,
3149
- })
3150
- return
3151
- }
3152
-
3153
- const textChannel = await resolveTextChannel(channel as ThreadChannel)
3154
- const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3155
-
3156
- if (!directory) {
3157
- await command.reply({
3158
- content: 'Could not determine project directory for this channel',
3159
- ephemeral: true,
3160
- })
3161
- return
3162
- }
3163
-
3164
- const row = getDatabase()
3165
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
3166
- .get(channel.id) as { session_id: string } | undefined
3167
-
3168
- if (!row?.session_id) {
3169
- await command.reply({
3170
- content: 'No active session in this thread',
3171
- ephemeral: true,
3172
- })
3173
- return
3174
- }
3175
-
3176
- const sessionId = row.session_id
3177
-
3178
- try {
3179
- const existingController = abortControllers.get(sessionId)
3180
- if (existingController) {
3181
- existingController.abort(new Error('User requested abort'))
3182
- abortControllers.delete(sessionId)
3183
- }
3184
-
3185
- const getClient = await initializeOpencodeForDirectory(directory)
3186
- await getClient().session.abort({
3187
- path: { id: sessionId },
3188
- })
3189
-
3190
- await command.reply(`🛑 Request **aborted**`)
3191
- sessionLogger.log(`Session ${sessionId} aborted by user`)
3192
- } catch (error) {
3193
- voiceLogger.error('[ABORT] Error:', error)
3194
- await command.reply({
3195
- content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
3196
- ephemeral: true,
3197
- })
3198
- }
3199
- } else if (command.commandName === 'share') {
3200
- const channel = command.channel
3201
-
3202
- if (!channel) {
3203
- await command.reply({
3204
- content: 'This command can only be used in a channel',
3205
- ephemeral: true,
3206
- })
3207
- return
3208
- }
3209
-
3210
- const isThread = [
3211
- ChannelType.PublicThread,
3212
- ChannelType.PrivateThread,
3213
- ChannelType.AnnouncementThread,
3214
- ].includes(channel.type)
3215
-
3216
- if (!isThread) {
3217
- await command.reply({
3218
- content: 'This command can only be used in a thread with an active session',
3219
- ephemeral: true,
3220
- })
3221
- return
3222
- }
3223
-
3224
- const textChannel = await resolveTextChannel(channel as ThreadChannel)
3225
- const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3226
-
3227
- if (!directory) {
3228
- await command.reply({
3229
- content: 'Could not determine project directory for this channel',
3230
- ephemeral: true,
3231
- })
3232
- return
3233
- }
3234
-
3235
- const row = getDatabase()
3236
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
3237
- .get(channel.id) as { session_id: string } | undefined
3238
-
3239
- if (!row?.session_id) {
3240
- await command.reply({
3241
- content: 'No active session in this thread',
3242
- ephemeral: true,
3243
- })
3244
- return
3245
- }
3246
-
3247
- const sessionId = row.session_id
3248
-
3249
- try {
3250
- const getClient = await initializeOpencodeForDirectory(directory)
3251
- const response = await getClient().session.share({
3252
- path: { id: sessionId },
3253
- })
3254
-
3255
- if (!response.data?.share?.url) {
3256
- await command.reply({
3257
- content: 'Failed to generate share URL',
3258
- ephemeral: true,
3259
- })
3260
- return
3261
- }
3262
-
3263
- await command.reply(`🔗 **Session shared:** ${response.data.share.url}`)
3264
- sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
3265
- } catch (error) {
3266
- voiceLogger.error('[SHARE] Error:', error)
3267
- await command.reply({
3268
- content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
3269
- ephemeral: true,
3270
- })
3271
- }
3272
- }
3273
- }
3274
- } catch (error) {
3275
- voiceLogger.error('[INTERACTION] Error handling interaction:', error)
3276
- }
3277
- },
3278
- )
3279
-
3280
- // Helper function to clean up voice connection and associated resources
3281
- async function cleanupVoiceConnection(guildId: string) {
3282
- const voiceData = voiceConnections.get(guildId)
3283
- if (!voiceData) return
3284
-
3285
- voiceLogger.log(`Starting cleanup for guild ${guildId}`)
3286
-
3287
- try {
3288
- // Stop GenAI worker if exists (this is async!)
3289
- if (voiceData.genAiWorker) {
3290
- voiceLogger.log(`Stopping GenAI worker...`)
3291
- await voiceData.genAiWorker.stop()
3292
- voiceLogger.log(`GenAI worker stopped`)
3293
- }
3294
-
3295
- // Close user audio stream if exists
3296
- if (voiceData.userAudioStream) {
3297
- voiceLogger.log(`Closing user audio stream...`)
3298
- await new Promise<void>((resolve) => {
3299
- voiceData.userAudioStream!.end(() => {
3300
- voiceLogger.log('User audio stream closed')
3301
- resolve()
3302
- })
3303
- // Timeout after 2 seconds
3304
- setTimeout(resolve, 2000)
3305
- })
3306
- }
3307
-
3308
- // Destroy voice connection
3309
- if (
3310
- voiceData.connection.state.status !== VoiceConnectionStatus.Destroyed
3311
- ) {
3312
- voiceLogger.log(`Destroying voice connection...`)
3313
- voiceData.connection.destroy()
3314
- }
3315
-
3316
- // Remove from map
3317
- voiceConnections.delete(guildId)
3318
- voiceLogger.log(`Cleanup complete for guild ${guildId}`)
3319
- } catch (error) {
3320
- voiceLogger.error(`Error during cleanup for guild ${guildId}:`, error)
3321
- // Still remove from map even if there was an error
3322
- voiceConnections.delete(guildId)
3323
- }
3324
- }
3325
-
3326
- // Handle voice state updates
3327
- discordClient.on(Events.VoiceStateUpdate, async (oldState, newState) => {
3328
- try {
3329
- const member = newState.member || oldState.member
3330
- if (!member) return
3331
-
3332
- // Check if user is admin, server owner, can manage server, or has Kimaki role
3333
- const guild = newState.guild || oldState.guild
3334
- const isOwner = member.id === guild.ownerId
3335
- const isAdmin = member.permissions.has(
3336
- PermissionsBitField.Flags.Administrator,
3337
- )
3338
- const canManageServer = member.permissions.has(
3339
- PermissionsBitField.Flags.ManageGuild,
3340
- )
3341
- const hasKimakiRole = member.roles.cache.some(
3342
- (role) => role.name.toLowerCase() === 'kimaki',
3343
- )
3344
-
3345
- if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
3346
- return
3347
- }
3348
-
3349
- // Handle admin leaving voice channel
3350
- if (oldState.channelId !== null && newState.channelId === null) {
3351
- voiceLogger.log(
3352
- `Admin user ${member.user.tag} left voice channel: ${oldState.channel?.name}`,
3353
- )
3354
-
3355
- // Check if bot should leave too
3356
- const guildId = guild.id
3357
- const voiceData = voiceConnections.get(guildId)
3358
-
3359
- if (
3360
- voiceData &&
3361
- voiceData.connection.joinConfig.channelId === oldState.channelId
3362
- ) {
3363
- // Check if any other admin is still in the channel
3364
- const voiceChannel = oldState.channel as VoiceChannel
3365
- if (!voiceChannel) return
3366
-
3367
- const hasOtherAdmins = voiceChannel.members.some((m) => {
3368
- if (m.id === member.id || m.user.bot) return false
3369
- return (
3370
- m.id === guild.ownerId ||
3371
- m.permissions.has(PermissionsBitField.Flags.Administrator) ||
3372
- m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
3373
- m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
3374
- )
3375
- })
3376
-
3377
- if (!hasOtherAdmins) {
3378
- voiceLogger.log(
3379
- `No other admins in channel, bot leaving voice channel in guild: ${guild.name}`,
3380
- )
3381
-
3382
- // Properly clean up all resources
3383
- await cleanupVoiceConnection(guildId)
3384
- } else {
3385
- voiceLogger.log(
3386
- `Other admins still in channel, bot staying in voice channel`,
3387
- )
3388
- }
3389
- }
3390
- return
3391
- }
3392
-
3393
- // Handle admin moving between voice channels
3394
- if (
3395
- oldState.channelId !== null &&
3396
- newState.channelId !== null &&
3397
- oldState.channelId !== newState.channelId
3398
- ) {
3399
- voiceLogger.log(
3400
- `Admin user ${member.user.tag} moved from ${oldState.channel?.name} to ${newState.channel?.name}`,
3401
- )
3402
-
3403
- // Check if we need to follow the admin
3404
- const guildId = guild.id
3405
- const voiceData = voiceConnections.get(guildId)
3406
-
3407
- if (
3408
- voiceData &&
3409
- voiceData.connection.joinConfig.channelId === oldState.channelId
3410
- ) {
3411
- // Check if any other admin is still in the old channel
3412
- const oldVoiceChannel = oldState.channel as VoiceChannel
3413
- if (oldVoiceChannel) {
3414
- const hasOtherAdmins = oldVoiceChannel.members.some((m) => {
3415
- if (m.id === member.id || m.user.bot) return false
3416
- return (
3417
- m.id === guild.ownerId ||
3418
- m.permissions.has(PermissionsBitField.Flags.Administrator) ||
3419
- m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
3420
- m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
3421
- )
3422
- })
3423
-
3424
- if (!hasOtherAdmins) {
3425
- voiceLogger.log(
3426
- `Following admin to new channel: ${newState.channel?.name}`,
3427
- )
3428
- const voiceChannel = newState.channel as VoiceChannel
3429
- if (voiceChannel) {
3430
- voiceData.connection.rejoin({
3431
- channelId: voiceChannel.id,
3432
- selfDeaf: false,
3433
- selfMute: false,
3434
- })
3435
- }
3436
- } else {
3437
- voiceLogger.log(
3438
- `Other admins still in old channel, bot staying put`,
3439
- )
3440
- }
3441
- }
3442
- }
3443
- }
3444
-
3445
- // Handle admin joining voice channel (initial join)
3446
- if (oldState.channelId === null && newState.channelId !== null) {
3447
- voiceLogger.log(
3448
- `Admin user ${member.user.tag} (Owner: ${isOwner}, Admin: ${isAdmin}) joined voice channel: ${newState.channel?.name}`,
3449
- )
3450
- }
3451
-
3452
- // Only proceed with joining if this is a new join or channel move
3453
- if (newState.channelId === null) return
3454
-
3455
- const voiceChannel = newState.channel as VoiceChannel
3456
- if (!voiceChannel) return
3457
-
3458
- // Check if bot already has a connection in this guild
3459
- const existingVoiceData = voiceConnections.get(newState.guild.id)
3460
- if (
3461
- existingVoiceData &&
3462
- existingVoiceData.connection.state.status !==
3463
- VoiceConnectionStatus.Destroyed
3464
- ) {
3465
- voiceLogger.log(
3466
- `Bot already connected to a voice channel in guild ${newState.guild.name}`,
3467
- )
3468
-
3469
- // If bot is in a different channel, move to the admin's channel
3470
- if (
3471
- existingVoiceData.connection.joinConfig.channelId !== voiceChannel.id
3472
- ) {
3473
- voiceLogger.log(
3474
- `Moving bot from channel ${existingVoiceData.connection.joinConfig.channelId} to ${voiceChannel.id}`,
3475
- )
3476
- existingVoiceData.connection.rejoin({
3477
- channelId: voiceChannel.id,
3478
- selfDeaf: false,
3479
- selfMute: false,
3480
- })
3481
- }
3482
- return
3483
- }
3484
-
3485
- try {
3486
- // Join the voice channel
3487
- voiceLogger.log(
3488
- `Attempting to join voice channel: ${voiceChannel.name} (${voiceChannel.id})`,
3489
- )
3490
-
3491
- const connection = joinVoiceChannel({
3492
- channelId: voiceChannel.id,
3493
- guildId: newState.guild.id,
3494
- adapterCreator: newState.guild.voiceAdapterCreator,
3495
- selfDeaf: false,
3496
- debug: true,
3497
- daveEncryption: false,
3498
-
3499
- selfMute: false, // Not muted so bot can speak
3500
- })
3501
-
3502
- // Store the connection
3503
- voiceConnections.set(newState.guild.id, { connection })
3504
-
3505
- // Wait for connection to be ready
3506
- await entersState(connection, VoiceConnectionStatus.Ready, 30_000)
3507
- voiceLogger.log(
3508
- `Successfully joined voice channel: ${voiceChannel.name} in guild: ${newState.guild.name}`,
3509
- )
3510
-
3511
- // Set up voice handling (only once per connection)
3512
- await setupVoiceHandling({
3513
- connection,
3514
- guildId: newState.guild.id,
3515
- channelId: voiceChannel.id,
3516
- appId: currentAppId!,
3517
- discordClient,
3518
- })
3519
-
3520
- // Handle connection state changes
3521
- connection.on(VoiceConnectionStatus.Disconnected, async () => {
3522
- voiceLogger.log(
3523
- `Disconnected from voice channel in guild: ${newState.guild.name}`,
3524
- )
3525
- try {
3526
- // Try to reconnect
3527
- await Promise.race([
3528
- entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
3529
- entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
3530
- ])
3531
- voiceLogger.log(`Reconnecting to voice channel`)
3532
- } catch (error) {
3533
- // Seems to be a real disconnect, destroy the connection
3534
- voiceLogger.log(`Failed to reconnect, destroying connection`)
3535
- connection.destroy()
3536
- voiceConnections.delete(newState.guild.id)
3537
- }
3538
- })
3539
-
3540
- connection.on(VoiceConnectionStatus.Destroyed, async () => {
3541
- voiceLogger.log(
3542
- `Connection destroyed for guild: ${newState.guild.name}`,
3543
- )
3544
- // Use the cleanup function to ensure everything is properly closed
3545
- await cleanupVoiceConnection(newState.guild.id)
3546
- })
3547
-
3548
- // Handle errors
3549
- connection.on('error', (error) => {
3550
- voiceLogger.error(
3551
- `Connection error in guild ${newState.guild.name}:`,
3552
- error,
3553
- )
3554
- })
3555
- } catch (error) {
3556
- voiceLogger.error(`Failed to join voice channel:`, error)
3557
- await cleanupVoiceConnection(newState.guild.id)
3558
- }
3559
- } catch (error) {
3560
- voiceLogger.error('Error in voice state update handler:', error)
3561
- }
3562
- })
3563
-
3564
- await discordClient.login(token)
3565
-
3566
- const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
3567
- discordLogger.log(`Received ${signal}, cleaning up...`)
3568
-
3569
- // Prevent multiple shutdown calls
3570
- if ((global as any).shuttingDown) {
3571
- discordLogger.log('Already shutting down, ignoring duplicate signal')
3572
- return
3573
- }
3574
- ;(global as any).shuttingDown = true
3575
-
3576
- try {
3577
- // Clean up all voice connections (this includes GenAI workers and audio streams)
3578
- const cleanupPromises: Promise<void>[] = []
3579
- for (const [guildId] of voiceConnections) {
3580
- voiceLogger.log(
3581
- `[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
3582
- )
3583
- cleanupPromises.push(cleanupVoiceConnection(guildId))
3584
- }
3585
-
3586
- // Wait for all cleanups to complete
3587
- if (cleanupPromises.length > 0) {
3588
- voiceLogger.log(
3589
- `[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
3590
- )
3591
- await Promise.allSettled(cleanupPromises)
3592
- discordLogger.log(`All voice connections cleaned up`)
3593
- }
3594
-
3595
- // Kill all OpenCode servers
3596
- for (const [dir, server] of opencodeServers) {
3597
- if (!server.process.killed) {
3598
- voiceLogger.log(
3599
- `[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
3600
- )
3601
- server.process.kill('SIGTERM')
3602
- }
3603
- }
3604
- opencodeServers.clear()
3605
-
3606
- discordLogger.log('Closing database...')
3607
- if (db) {
3608
- db.close()
3609
- db = null
3610
- }
3611
-
3612
- discordLogger.log('Destroying Discord client...')
3613
- discordClient.destroy()
3614
-
3615
- discordLogger.log('Cleanup complete.')
3616
- if (!skipExit) {
3617
- process.exit(0)
3618
- }
3619
- } catch (error) {
3620
- voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
3621
- if (!skipExit) {
3622
- process.exit(1)
3623
- }
3624
- }
3625
- }
3626
-
3627
- // Override default signal handlers to prevent immediate exit
3628
- process.on('SIGTERM', async () => {
3629
- try {
3630
- await handleShutdown('SIGTERM')
3631
- } catch (error) {
3632
- voiceLogger.error('[SIGTERM] Error during shutdown:', error)
3633
- process.exit(1)
3634
- }
3635
- })
3636
-
3637
- process.on('SIGINT', async () => {
3638
- try {
3639
- await handleShutdown('SIGINT')
3640
- } catch (error) {
3641
- voiceLogger.error('[SIGINT] Error during shutdown:', error)
3642
- process.exit(1)
3643
- }
3644
- })
3645
-
3646
- process.on('SIGUSR2', async () => {
3647
- discordLogger.log('Received SIGUSR2, restarting after cleanup...')
3648
- try {
3649
- await handleShutdown('SIGUSR2', { skipExit: true })
3650
- } catch (error) {
3651
- voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
3652
- }
3653
- const { spawn } = await import('node:child_process')
3654
- spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
3655
- stdio: 'inherit',
3656
- detached: true,
3657
- cwd: process.cwd(),
3658
- env: process.env,
3659
- }).unref()
3660
- process.exit(0)
3661
- })
3662
-
3663
- // Prevent unhandled promise rejections from crashing the process during shutdown
3664
- process.on('unhandledRejection', (reason, promise) => {
3665
- if ((global as any).shuttingDown) {
3666
- discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
3667
- return
3668
- }
3669
- discordLogger.error('Unhandled Rejection at:', promise, 'reason:', reason)
3670
- })
3671
- }