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