kimaki 0.4.22 → 0.4.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-management.js +92 -0
- package/dist/cli.js +9 -1
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/message-formatting.js +188 -0
- package/dist/model-command.js +293 -0
- package/dist/opencode.js +135 -0
- package/dist/session-handler.js +467 -0
- package/dist/system-message.js +92 -0
- package/dist/tools.js +1 -1
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -1
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +9 -1
- package/src/database.ts +155 -0
- package/src/discord-bot.ts +506 -0
- package/src/discord-utils.ts +208 -0
- package/src/escape-backticks.test.ts +1 -1
- package/src/fork.ts +224 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/message-formatting.ts +227 -0
- package/src/model-command.ts +380 -0
- package/src/opencode.ts +180 -0
- package/src/session-handler.ts +601 -0
- package/src/system-message.ts +92 -0
- package/src/tools.ts +1 -1
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3671
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { getDatabase, closeDatabase } from './database.js'
|
|
2
|
+
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
|
|
3
|
+
import {
|
|
4
|
+
escapeBackticksInCodeBlocks,
|
|
5
|
+
splitMarkdownForDiscord,
|
|
6
|
+
SILENT_MESSAGE_FLAGS,
|
|
7
|
+
} from './discord-utils.js'
|
|
8
|
+
import { getOpencodeSystemMessage } from './system-message.js'
|
|
9
|
+
import { getFileAttachments, getTextAttachments } from './message-formatting.js'
|
|
10
|
+
import {
|
|
11
|
+
ensureKimakiCategory,
|
|
12
|
+
ensureKimakiAudioCategory,
|
|
13
|
+
createProjectChannels,
|
|
14
|
+
getChannelsWithDescriptions,
|
|
15
|
+
type ChannelWithTags,
|
|
16
|
+
} from './channel-management.js'
|
|
17
|
+
import {
|
|
18
|
+
voiceConnections,
|
|
19
|
+
cleanupVoiceConnection,
|
|
20
|
+
processVoiceAttachment,
|
|
21
|
+
registerVoiceStateHandler,
|
|
22
|
+
} from './voice-handler.js'
|
|
23
|
+
import {
|
|
24
|
+
handleOpencodeSession,
|
|
25
|
+
parseSlashCommand,
|
|
26
|
+
} from './session-handler.js'
|
|
27
|
+
import { registerInteractionHandler } from './interaction-handler.js'
|
|
28
|
+
|
|
29
|
+
export { getDatabase, closeDatabase } from './database.js'
|
|
30
|
+
export { initializeOpencodeForDirectory } from './opencode.js'
|
|
31
|
+
export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
|
|
32
|
+
export { getOpencodeSystemMessage } from './system-message.js'
|
|
33
|
+
export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions } from './channel-management.js'
|
|
34
|
+
export type { ChannelWithTags } from './channel-management.js'
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
ChannelType,
|
|
38
|
+
Client,
|
|
39
|
+
Events,
|
|
40
|
+
GatewayIntentBits,
|
|
41
|
+
Partials,
|
|
42
|
+
PermissionsBitField,
|
|
43
|
+
ThreadAutoArchiveDuration,
|
|
44
|
+
type Message,
|
|
45
|
+
type TextChannel,
|
|
46
|
+
type ThreadChannel,
|
|
47
|
+
} from 'discord.js'
|
|
48
|
+
import fs from 'node:fs'
|
|
49
|
+
import { extractTagsArrays } from './xml.js'
|
|
50
|
+
import { createLogger } from './logger.js'
|
|
51
|
+
import { setGlobalDispatcher, Agent } from 'undici'
|
|
52
|
+
|
|
53
|
+
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
|
|
54
|
+
|
|
55
|
+
const discordLogger = createLogger('DISCORD')
|
|
56
|
+
const voiceLogger = createLogger('VOICE')
|
|
57
|
+
|
|
58
|
+
type StartOptions = {
|
|
59
|
+
token: string
|
|
60
|
+
appId?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function createDiscordClient() {
|
|
64
|
+
return new Client({
|
|
65
|
+
intents: [
|
|
66
|
+
GatewayIntentBits.Guilds,
|
|
67
|
+
GatewayIntentBits.GuildMessages,
|
|
68
|
+
GatewayIntentBits.MessageContent,
|
|
69
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
70
|
+
],
|
|
71
|
+
partials: [
|
|
72
|
+
Partials.Channel,
|
|
73
|
+
Partials.Message,
|
|
74
|
+
Partials.User,
|
|
75
|
+
Partials.ThreadMember,
|
|
76
|
+
],
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function startDiscordBot({
|
|
81
|
+
token,
|
|
82
|
+
appId,
|
|
83
|
+
discordClient,
|
|
84
|
+
}: StartOptions & { discordClient?: Client }) {
|
|
85
|
+
if (!discordClient) {
|
|
86
|
+
discordClient = await createDiscordClient()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let currentAppId: string | undefined = appId
|
|
90
|
+
|
|
91
|
+
const setupHandlers = async (c: Client<true>) => {
|
|
92
|
+
discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
|
|
93
|
+
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
|
|
94
|
+
discordLogger.log(`Bot user ID: ${c.user.id}`)
|
|
95
|
+
|
|
96
|
+
if (!currentAppId) {
|
|
97
|
+
await c.application?.fetch()
|
|
98
|
+
currentAppId = c.application?.id
|
|
99
|
+
|
|
100
|
+
if (!currentAppId) {
|
|
101
|
+
discordLogger.error('Could not get application ID')
|
|
102
|
+
throw new Error('Failed to get bot application ID')
|
|
103
|
+
}
|
|
104
|
+
discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
|
|
105
|
+
} else {
|
|
106
|
+
discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const guild of c.guilds.cache.values()) {
|
|
110
|
+
discordLogger.log(`${guild.name} (${guild.id})`)
|
|
111
|
+
|
|
112
|
+
const channels = await getChannelsWithDescriptions(guild)
|
|
113
|
+
const kimakiChannels = channels.filter(
|
|
114
|
+
(ch) =>
|
|
115
|
+
ch.kimakiDirectory &&
|
|
116
|
+
(!ch.kimakiApp || ch.kimakiApp === currentAppId),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if (kimakiChannels.length > 0) {
|
|
120
|
+
discordLogger.log(
|
|
121
|
+
` Found ${kimakiChannels.length} channel(s) for this bot:`,
|
|
122
|
+
)
|
|
123
|
+
for (const channel of kimakiChannels) {
|
|
124
|
+
discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`)
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
discordLogger.log(` No channels for this bot`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
voiceLogger.log(
|
|
132
|
+
`[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
registerInteractionHandler({ discordClient: c, appId: currentAppId })
|
|
136
|
+
registerVoiceStateHandler({ discordClient: c, appId: currentAppId })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If client is already ready (was logged in before being passed to us),
|
|
140
|
+
// run setup immediately. Otherwise wait for the ClientReady event.
|
|
141
|
+
if (discordClient.isReady()) {
|
|
142
|
+
await setupHandlers(discordClient)
|
|
143
|
+
} else {
|
|
144
|
+
discordClient.once(Events.ClientReady, setupHandlers)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
discordClient.on(Events.MessageCreate, async (message: Message) => {
|
|
148
|
+
try {
|
|
149
|
+
if (message.author?.bot) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (message.partial) {
|
|
153
|
+
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
154
|
+
try {
|
|
155
|
+
await message.fetch()
|
|
156
|
+
} catch (error) {
|
|
157
|
+
discordLogger.log(
|
|
158
|
+
`Failed to fetch partial message ${message.id}:`,
|
|
159
|
+
error,
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (message.guild && message.member) {
|
|
166
|
+
const isOwner = message.member.id === message.guild.ownerId
|
|
167
|
+
const isAdmin = message.member.permissions.has(
|
|
168
|
+
PermissionsBitField.Flags.Administrator,
|
|
169
|
+
)
|
|
170
|
+
const canManageServer = message.member.permissions.has(
|
|
171
|
+
PermissionsBitField.Flags.ManageGuild,
|
|
172
|
+
)
|
|
173
|
+
const hasKimakiRole = message.member.roles.cache.some(
|
|
174
|
+
(role) => role.name.toLowerCase() === 'kimaki',
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
178
|
+
await message.react('🔒')
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const channel = message.channel
|
|
184
|
+
const isThread = [
|
|
185
|
+
ChannelType.PublicThread,
|
|
186
|
+
ChannelType.PrivateThread,
|
|
187
|
+
ChannelType.AnnouncementThread,
|
|
188
|
+
].includes(channel.type)
|
|
189
|
+
|
|
190
|
+
if (isThread) {
|
|
191
|
+
const thread = channel as ThreadChannel
|
|
192
|
+
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
|
|
193
|
+
|
|
194
|
+
const row = getDatabase()
|
|
195
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
196
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
197
|
+
|
|
198
|
+
if (!row) {
|
|
199
|
+
discordLogger.log(`No session found for thread ${thread.id}`)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
voiceLogger.log(
|
|
204
|
+
`[SESSION] Found session ${row.session_id} for thread ${thread.id}`,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const parent = thread.parent as TextChannel | null
|
|
208
|
+
let projectDirectory: string | undefined
|
|
209
|
+
let channelAppId: string | undefined
|
|
210
|
+
|
|
211
|
+
if (parent?.topic) {
|
|
212
|
+
const extracted = extractTagsArrays({
|
|
213
|
+
xml: parent.topic,
|
|
214
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
218
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
222
|
+
voiceLogger.log(
|
|
223
|
+
`[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
229
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
230
|
+
await message.reply({
|
|
231
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
232
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
233
|
+
})
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let messageContent = message.content || ''
|
|
238
|
+
|
|
239
|
+
let sessionMessagesText: string | undefined
|
|
240
|
+
if (projectDirectory && row.session_id) {
|
|
241
|
+
try {
|
|
242
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
243
|
+
const messagesResponse = await getClient().session.messages({
|
|
244
|
+
path: { id: row.session_id },
|
|
245
|
+
})
|
|
246
|
+
const messages = messagesResponse.data || []
|
|
247
|
+
const recentMessages = messages.slice(-10)
|
|
248
|
+
sessionMessagesText = recentMessages
|
|
249
|
+
.map((m) => {
|
|
250
|
+
const role = m.info.role === 'user' ? 'User' : 'Assistant'
|
|
251
|
+
const text = (() => {
|
|
252
|
+
if (m.info.role === 'user') {
|
|
253
|
+
const textParts = (m.parts || []).filter((p) => p.type === 'text')
|
|
254
|
+
return textParts
|
|
255
|
+
.map((p) => ('text' in p ? p.text : ''))
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
.join('\n')
|
|
258
|
+
}
|
|
259
|
+
const assistantInfo = m.info as { text?: string }
|
|
260
|
+
return assistantInfo.text?.slice(0, 500)
|
|
261
|
+
})()
|
|
262
|
+
return `[${role}]: ${text || '(no text)'}`
|
|
263
|
+
})
|
|
264
|
+
.join('\n\n')
|
|
265
|
+
} catch (e) {
|
|
266
|
+
voiceLogger.log(`Could not get session messages:`, e)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const transcription = await processVoiceAttachment({
|
|
271
|
+
message,
|
|
272
|
+
thread,
|
|
273
|
+
projectDirectory,
|
|
274
|
+
appId: currentAppId,
|
|
275
|
+
sessionMessages: sessionMessagesText,
|
|
276
|
+
})
|
|
277
|
+
if (transcription) {
|
|
278
|
+
messageContent = transcription
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const fileAttachments = getFileAttachments(message)
|
|
282
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
283
|
+
const promptWithAttachments = textAttachmentsContent
|
|
284
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
285
|
+
: messageContent
|
|
286
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
287
|
+
await handleOpencodeSession({
|
|
288
|
+
prompt: promptWithAttachments,
|
|
289
|
+
thread,
|
|
290
|
+
projectDirectory,
|
|
291
|
+
originalMessage: message,
|
|
292
|
+
images: fileAttachments,
|
|
293
|
+
parsedCommand,
|
|
294
|
+
channelId: parent?.id,
|
|
295
|
+
})
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (channel.type === ChannelType.GuildText) {
|
|
300
|
+
const textChannel = channel as TextChannel
|
|
301
|
+
voiceLogger.log(
|
|
302
|
+
`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if (!textChannel.topic) {
|
|
306
|
+
voiceLogger.log(
|
|
307
|
+
`[IGNORED] Channel #${textChannel.name} has no description`,
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const extracted = extractTagsArrays({
|
|
313
|
+
xml: textChannel.topic,
|
|
314
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
318
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
319
|
+
|
|
320
|
+
if (!projectDirectory) {
|
|
321
|
+
voiceLogger.log(
|
|
322
|
+
`[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`,
|
|
323
|
+
)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
328
|
+
voiceLogger.log(
|
|
329
|
+
`[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
330
|
+
)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
discordLogger.log(
|
|
335
|
+
`DIRECTORY: Found kimaki.directory: ${projectDirectory}`,
|
|
336
|
+
)
|
|
337
|
+
if (channelAppId) {
|
|
338
|
+
discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
342
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
343
|
+
await message.reply({
|
|
344
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
345
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
346
|
+
})
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const hasVoice = message.attachments.some((a) =>
|
|
351
|
+
a.contentType?.startsWith('audio/'),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
const threadName = hasVoice
|
|
355
|
+
? 'Voice Message'
|
|
356
|
+
: message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread'
|
|
357
|
+
|
|
358
|
+
const thread = await message.startThread({
|
|
359
|
+
name: threadName.slice(0, 80),
|
|
360
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
361
|
+
reason: 'Start Claude session',
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
|
|
365
|
+
|
|
366
|
+
let messageContent = message.content || ''
|
|
367
|
+
|
|
368
|
+
const transcription = await processVoiceAttachment({
|
|
369
|
+
message,
|
|
370
|
+
thread,
|
|
371
|
+
projectDirectory,
|
|
372
|
+
isNewThread: true,
|
|
373
|
+
appId: currentAppId,
|
|
374
|
+
})
|
|
375
|
+
if (transcription) {
|
|
376
|
+
messageContent = transcription
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const fileAttachments = getFileAttachments(message)
|
|
380
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
381
|
+
const promptWithAttachments = textAttachmentsContent
|
|
382
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
383
|
+
: messageContent
|
|
384
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
385
|
+
await handleOpencodeSession({
|
|
386
|
+
prompt: promptWithAttachments,
|
|
387
|
+
thread,
|
|
388
|
+
projectDirectory,
|
|
389
|
+
originalMessage: message,
|
|
390
|
+
images: fileAttachments,
|
|
391
|
+
parsedCommand,
|
|
392
|
+
channelId: textChannel.id,
|
|
393
|
+
})
|
|
394
|
+
} else {
|
|
395
|
+
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
voiceLogger.error('Discord handler error:', error)
|
|
399
|
+
try {
|
|
400
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
401
|
+
await message.reply({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
|
|
402
|
+
} catch {
|
|
403
|
+
voiceLogger.error('Discord handler error (fallback):', error)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
await discordClient.login(token)
|
|
409
|
+
|
|
410
|
+
const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
|
|
411
|
+
discordLogger.log(`Received ${signal}, cleaning up...`)
|
|
412
|
+
|
|
413
|
+
if ((global as any).shuttingDown) {
|
|
414
|
+
discordLogger.log('Already shutting down, ignoring duplicate signal')
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
;(global as any).shuttingDown = true
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const cleanupPromises: Promise<void>[] = []
|
|
421
|
+
for (const [guildId] of voiceConnections) {
|
|
422
|
+
voiceLogger.log(
|
|
423
|
+
`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
|
|
424
|
+
)
|
|
425
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (cleanupPromises.length > 0) {
|
|
429
|
+
voiceLogger.log(
|
|
430
|
+
`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
|
|
431
|
+
)
|
|
432
|
+
await Promise.allSettled(cleanupPromises)
|
|
433
|
+
discordLogger.log(`All voice connections cleaned up`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const [dir, server] of getOpencodeServers()) {
|
|
437
|
+
if (!server.process.killed) {
|
|
438
|
+
voiceLogger.log(
|
|
439
|
+
`[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
|
|
440
|
+
)
|
|
441
|
+
server.process.kill('SIGTERM')
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
getOpencodeServers().clear()
|
|
445
|
+
|
|
446
|
+
discordLogger.log('Closing database...')
|
|
447
|
+
closeDatabase()
|
|
448
|
+
|
|
449
|
+
discordLogger.log('Destroying Discord client...')
|
|
450
|
+
discordClient.destroy()
|
|
451
|
+
|
|
452
|
+
discordLogger.log('Cleanup complete.')
|
|
453
|
+
if (!skipExit) {
|
|
454
|
+
process.exit(0)
|
|
455
|
+
}
|
|
456
|
+
} catch (error) {
|
|
457
|
+
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
|
|
458
|
+
if (!skipExit) {
|
|
459
|
+
process.exit(1)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
process.on('SIGTERM', async () => {
|
|
465
|
+
try {
|
|
466
|
+
await handleShutdown('SIGTERM')
|
|
467
|
+
} catch (error) {
|
|
468
|
+
voiceLogger.error('[SIGTERM] Error during shutdown:', error)
|
|
469
|
+
process.exit(1)
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
process.on('SIGINT', async () => {
|
|
474
|
+
try {
|
|
475
|
+
await handleShutdown('SIGINT')
|
|
476
|
+
} catch (error) {
|
|
477
|
+
voiceLogger.error('[SIGINT] Error during shutdown:', error)
|
|
478
|
+
process.exit(1)
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
process.on('SIGUSR2', async () => {
|
|
483
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...')
|
|
484
|
+
try {
|
|
485
|
+
await handleShutdown('SIGUSR2', { skipExit: true })
|
|
486
|
+
} catch (error) {
|
|
487
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
|
|
488
|
+
}
|
|
489
|
+
const { spawn } = await import('node:child_process')
|
|
490
|
+
spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
|
|
491
|
+
stdio: 'inherit',
|
|
492
|
+
detached: true,
|
|
493
|
+
cwd: process.cwd(),
|
|
494
|
+
env: process.env,
|
|
495
|
+
}).unref()
|
|
496
|
+
process.exit(0)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
500
|
+
if ((global as any).shuttingDown) {
|
|
501
|
+
discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
discordLogger.error('Unhandled Rejection at:', promise, 'reason:', reason)
|
|
505
|
+
})
|
|
506
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChannelType,
|
|
3
|
+
type Message,
|
|
4
|
+
type TextChannel,
|
|
5
|
+
type ThreadChannel,
|
|
6
|
+
} from 'discord.js'
|
|
7
|
+
import { Lexer } from 'marked'
|
|
8
|
+
import { extractTagsArrays } from './xml.js'
|
|
9
|
+
import { formatMarkdownTables } from './format-tables.js'
|
|
10
|
+
import { createLogger } from './logger.js'
|
|
11
|
+
|
|
12
|
+
const discordLogger = createLogger('DISCORD')
|
|
13
|
+
|
|
14
|
+
export const SILENT_MESSAGE_FLAGS = 4 | 4096
|
|
15
|
+
|
|
16
|
+
export function escapeBackticksInCodeBlocks(markdown: string): string {
|
|
17
|
+
const lexer = new Lexer()
|
|
18
|
+
const tokens = lexer.lex(markdown)
|
|
19
|
+
|
|
20
|
+
let result = ''
|
|
21
|
+
|
|
22
|
+
for (const token of tokens) {
|
|
23
|
+
if (token.type === 'code') {
|
|
24
|
+
const escapedCode = token.text.replace(/`/g, '\\`')
|
|
25
|
+
result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
|
|
26
|
+
} else {
|
|
27
|
+
result += token.raw
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type LineInfo = {
|
|
35
|
+
text: string
|
|
36
|
+
inCodeBlock: boolean
|
|
37
|
+
lang: string
|
|
38
|
+
isOpeningFence: boolean
|
|
39
|
+
isClosingFence: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function splitMarkdownForDiscord({
|
|
43
|
+
content,
|
|
44
|
+
maxLength,
|
|
45
|
+
}: {
|
|
46
|
+
content: string
|
|
47
|
+
maxLength: number
|
|
48
|
+
}): string[] {
|
|
49
|
+
if (content.length <= maxLength) {
|
|
50
|
+
return [content]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lexer = new Lexer()
|
|
54
|
+
const tokens = lexer.lex(content)
|
|
55
|
+
|
|
56
|
+
const lines: LineInfo[] = []
|
|
57
|
+
for (const token of tokens) {
|
|
58
|
+
if (token.type === 'code') {
|
|
59
|
+
const lang = token.lang || ''
|
|
60
|
+
lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
|
|
61
|
+
const codeLines = token.text.split('\n')
|
|
62
|
+
for (const codeLine of codeLines) {
|
|
63
|
+
lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
|
|
64
|
+
}
|
|
65
|
+
lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
|
|
66
|
+
} else {
|
|
67
|
+
const rawLines = token.raw.split('\n')
|
|
68
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
69
|
+
const isLast = i === rawLines.length - 1
|
|
70
|
+
const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
|
|
71
|
+
if (text) {
|
|
72
|
+
lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const chunks: string[] = []
|
|
79
|
+
let currentChunk = ''
|
|
80
|
+
let currentLang: string | null = null
|
|
81
|
+
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const wouldExceed = currentChunk.length + line.text.length > maxLength
|
|
84
|
+
|
|
85
|
+
if (wouldExceed && currentChunk) {
|
|
86
|
+
if (currentLang !== null) {
|
|
87
|
+
currentChunk += '```\n'
|
|
88
|
+
}
|
|
89
|
+
chunks.push(currentChunk)
|
|
90
|
+
|
|
91
|
+
if (line.isClosingFence && currentLang !== null) {
|
|
92
|
+
currentChunk = ''
|
|
93
|
+
currentLang = null
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
98
|
+
const lang = line.lang
|
|
99
|
+
currentChunk = '```' + lang + '\n'
|
|
100
|
+
if (!line.isOpeningFence) {
|
|
101
|
+
currentChunk += line.text
|
|
102
|
+
}
|
|
103
|
+
currentLang = lang
|
|
104
|
+
} else {
|
|
105
|
+
currentChunk = line.text
|
|
106
|
+
currentLang = null
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
currentChunk += line.text
|
|
110
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
111
|
+
currentLang = line.lang
|
|
112
|
+
} else if (line.isClosingFence) {
|
|
113
|
+
currentLang = null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (currentChunk) {
|
|
119
|
+
chunks.push(currentChunk)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return chunks
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function sendThreadMessage(
|
|
126
|
+
thread: ThreadChannel,
|
|
127
|
+
content: string,
|
|
128
|
+
): Promise<Message> {
|
|
129
|
+
const MAX_LENGTH = 2000
|
|
130
|
+
|
|
131
|
+
content = formatMarkdownTables(content)
|
|
132
|
+
content = escapeBackticksInCodeBlocks(content)
|
|
133
|
+
|
|
134
|
+
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
|
|
135
|
+
|
|
136
|
+
if (chunks.length > 1) {
|
|
137
|
+
discordLogger.log(
|
|
138
|
+
`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let firstMessage: Message | undefined
|
|
143
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
144
|
+
const chunk = chunks[i]
|
|
145
|
+
if (!chunk) {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const message = await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
149
|
+
if (i === 0) {
|
|
150
|
+
firstMessage = message
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return firstMessage!
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function resolveTextChannel(
|
|
158
|
+
channel: TextChannel | ThreadChannel | null | undefined,
|
|
159
|
+
): Promise<TextChannel | null> {
|
|
160
|
+
if (!channel) {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (channel.type === ChannelType.GuildText) {
|
|
165
|
+
return channel as TextChannel
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (
|
|
169
|
+
channel.type === ChannelType.PublicThread ||
|
|
170
|
+
channel.type === ChannelType.PrivateThread ||
|
|
171
|
+
channel.type === ChannelType.AnnouncementThread
|
|
172
|
+
) {
|
|
173
|
+
const parentId = channel.parentId
|
|
174
|
+
if (parentId) {
|
|
175
|
+
const parent = await channel.guild.channels.fetch(parentId)
|
|
176
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
177
|
+
return parent as TextChannel
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function escapeDiscordFormatting(text: string): string {
|
|
186
|
+
return text
|
|
187
|
+
.replace(/```/g, '\\`\\`\\`')
|
|
188
|
+
.replace(/````/g, '\\`\\`\\`\\`')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getKimakiMetadata(textChannel: TextChannel | null): {
|
|
192
|
+
projectDirectory?: string
|
|
193
|
+
channelAppId?: string
|
|
194
|
+
} {
|
|
195
|
+
if (!textChannel?.topic) {
|
|
196
|
+
return {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const extracted = extractTagsArrays({
|
|
200
|
+
xml: textChannel.topic,
|
|
201
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
205
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
206
|
+
|
|
207
|
+
return { projectDirectory, channelAppId }
|
|
208
|
+
}
|