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.
@@ -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
+ }