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,92 @@
|
|
|
1
|
+
import { ChannelType, } from 'discord.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getDatabase } from './database.js';
|
|
4
|
+
import { extractTagsArrays } from './xml.js';
|
|
5
|
+
export async function ensureKimakiCategory(guild) {
|
|
6
|
+
const existingCategory = guild.channels.cache.find((channel) => {
|
|
7
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return channel.name.toLowerCase() === 'kimaki';
|
|
11
|
+
});
|
|
12
|
+
if (existingCategory) {
|
|
13
|
+
return existingCategory;
|
|
14
|
+
}
|
|
15
|
+
return guild.channels.create({
|
|
16
|
+
name: 'Kimaki',
|
|
17
|
+
type: ChannelType.GuildCategory,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function ensureKimakiAudioCategory(guild) {
|
|
21
|
+
const existingCategory = guild.channels.cache.find((channel) => {
|
|
22
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return channel.name.toLowerCase() === 'kimaki audio';
|
|
26
|
+
});
|
|
27
|
+
if (existingCategory) {
|
|
28
|
+
return existingCategory;
|
|
29
|
+
}
|
|
30
|
+
return guild.channels.create({
|
|
31
|
+
name: 'Kimaki Audio',
|
|
32
|
+
type: ChannelType.GuildCategory,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export async function createProjectChannels({ guild, projectDirectory, appId, }) {
|
|
36
|
+
const baseName = path.basename(projectDirectory);
|
|
37
|
+
const channelName = `${baseName}`
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
40
|
+
.slice(0, 100);
|
|
41
|
+
const kimakiCategory = await ensureKimakiCategory(guild);
|
|
42
|
+
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild);
|
|
43
|
+
const textChannel = await guild.channels.create({
|
|
44
|
+
name: channelName,
|
|
45
|
+
type: ChannelType.GuildText,
|
|
46
|
+
parent: kimakiCategory,
|
|
47
|
+
topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
|
|
48
|
+
});
|
|
49
|
+
const voiceChannel = await guild.channels.create({
|
|
50
|
+
name: channelName,
|
|
51
|
+
type: ChannelType.GuildVoice,
|
|
52
|
+
parent: kimakiAudioCategory,
|
|
53
|
+
});
|
|
54
|
+
getDatabase()
|
|
55
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
56
|
+
.run(textChannel.id, projectDirectory, 'text');
|
|
57
|
+
getDatabase()
|
|
58
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
59
|
+
.run(voiceChannel.id, projectDirectory, 'voice');
|
|
60
|
+
return {
|
|
61
|
+
textChannelId: textChannel.id,
|
|
62
|
+
voiceChannelId: voiceChannel.id,
|
|
63
|
+
channelName,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function getChannelsWithDescriptions(guild) {
|
|
67
|
+
const channels = [];
|
|
68
|
+
guild.channels.cache
|
|
69
|
+
.filter((channel) => channel.isTextBased())
|
|
70
|
+
.forEach((channel) => {
|
|
71
|
+
const textChannel = channel;
|
|
72
|
+
const description = textChannel.topic || null;
|
|
73
|
+
let kimakiDirectory;
|
|
74
|
+
let kimakiApp;
|
|
75
|
+
if (description) {
|
|
76
|
+
const extracted = extractTagsArrays({
|
|
77
|
+
xml: description,
|
|
78
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
79
|
+
});
|
|
80
|
+
kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
81
|
+
kimakiApp = extracted['kimaki.app']?.[0]?.trim();
|
|
82
|
+
}
|
|
83
|
+
channels.push({
|
|
84
|
+
id: textChannel.id,
|
|
85
|
+
name: textChannel.name,
|
|
86
|
+
description,
|
|
87
|
+
kimakiDirectory,
|
|
88
|
+
kimakiApp,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
return channels;
|
|
92
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { cac } from 'cac';
|
|
3
3
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
4
4
|
import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
|
|
5
|
-
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './
|
|
5
|
+
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
|
|
6
6
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import fs from 'node:fs';
|
|
@@ -117,6 +117,14 @@ async function registerCommands(token, appId) {
|
|
|
117
117
|
.setName('share')
|
|
118
118
|
.setDescription('Share the current session as a public URL')
|
|
119
119
|
.toJSON(),
|
|
120
|
+
new SlashCommandBuilder()
|
|
121
|
+
.setName('fork')
|
|
122
|
+
.setDescription('Fork the session from a past user message')
|
|
123
|
+
.toJSON(),
|
|
124
|
+
new SlashCommandBuilder()
|
|
125
|
+
.setName('model')
|
|
126
|
+
.setDescription('Set the preferred model for this channel or session')
|
|
127
|
+
.toJSON(),
|
|
120
128
|
];
|
|
121
129
|
const rest = new REST().setToken(token);
|
|
122
130
|
try {
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
const dbLogger = createLogger('DB');
|
|
7
|
+
let db = null;
|
|
8
|
+
export function getDatabase() {
|
|
9
|
+
if (!db) {
|
|
10
|
+
const kimakiDir = path.join(os.homedir(), '.kimaki');
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(kimakiDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
dbLogger.error('Failed to create ~/.kimaki directory:', error);
|
|
16
|
+
}
|
|
17
|
+
const dbPath = path.join(kimakiDir, 'discord-sessions.db');
|
|
18
|
+
dbLogger.log(`Opening database at: ${dbPath}`);
|
|
19
|
+
db = new Database(dbPath);
|
|
20
|
+
db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS thread_sessions (
|
|
22
|
+
thread_id TEXT PRIMARY KEY,
|
|
23
|
+
session_id TEXT NOT NULL,
|
|
24
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS part_messages (
|
|
29
|
+
part_id TEXT PRIMARY KEY,
|
|
30
|
+
message_id TEXT NOT NULL,
|
|
31
|
+
thread_id TEXT NOT NULL,
|
|
32
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
33
|
+
)
|
|
34
|
+
`);
|
|
35
|
+
db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS bot_tokens (
|
|
37
|
+
app_id TEXT PRIMARY KEY,
|
|
38
|
+
token TEXT NOT NULL,
|
|
39
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
40
|
+
)
|
|
41
|
+
`);
|
|
42
|
+
db.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS channel_directories (
|
|
44
|
+
channel_id TEXT PRIMARY KEY,
|
|
45
|
+
directory TEXT NOT NULL,
|
|
46
|
+
channel_type TEXT NOT NULL,
|
|
47
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
48
|
+
)
|
|
49
|
+
`);
|
|
50
|
+
db.exec(`
|
|
51
|
+
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
52
|
+
app_id TEXT PRIMARY KEY,
|
|
53
|
+
gemini_api_key TEXT,
|
|
54
|
+
xai_api_key TEXT,
|
|
55
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
56
|
+
)
|
|
57
|
+
`);
|
|
58
|
+
runModelMigrations(db);
|
|
59
|
+
}
|
|
60
|
+
return db;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run migrations for model preferences tables.
|
|
64
|
+
* Called on startup and can be called on-demand.
|
|
65
|
+
*/
|
|
66
|
+
export function runModelMigrations(database) {
|
|
67
|
+
const targetDb = database || getDatabase();
|
|
68
|
+
targetDb.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS channel_models (
|
|
70
|
+
channel_id TEXT PRIMARY KEY,
|
|
71
|
+
model_id TEXT NOT NULL,
|
|
72
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
73
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
74
|
+
)
|
|
75
|
+
`);
|
|
76
|
+
targetDb.exec(`
|
|
77
|
+
CREATE TABLE IF NOT EXISTS session_models (
|
|
78
|
+
session_id TEXT PRIMARY KEY,
|
|
79
|
+
model_id TEXT NOT NULL,
|
|
80
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
81
|
+
)
|
|
82
|
+
`);
|
|
83
|
+
dbLogger.log('Model preferences migrations complete');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get the model preference for a channel.
|
|
87
|
+
* @returns Model ID in format "provider_id/model_id" or undefined
|
|
88
|
+
*/
|
|
89
|
+
export function getChannelModel(channelId) {
|
|
90
|
+
const db = getDatabase();
|
|
91
|
+
const row = db
|
|
92
|
+
.prepare('SELECT model_id FROM channel_models WHERE channel_id = ?')
|
|
93
|
+
.get(channelId);
|
|
94
|
+
return row?.model_id;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Set the model preference for a channel.
|
|
98
|
+
* @param modelId Model ID in format "provider_id/model_id"
|
|
99
|
+
*/
|
|
100
|
+
export function setChannelModel(channelId, modelId) {
|
|
101
|
+
const db = getDatabase();
|
|
102
|
+
db.prepare(`INSERT INTO channel_models (channel_id, model_id, updated_at)
|
|
103
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
104
|
+
ON CONFLICT(channel_id) DO UPDATE SET model_id = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, modelId, modelId);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get the model preference for a session.
|
|
108
|
+
* @returns Model ID in format "provider_id/model_id" or undefined
|
|
109
|
+
*/
|
|
110
|
+
export function getSessionModel(sessionId) {
|
|
111
|
+
const db = getDatabase();
|
|
112
|
+
const row = db
|
|
113
|
+
.prepare('SELECT model_id FROM session_models WHERE session_id = ?')
|
|
114
|
+
.get(sessionId);
|
|
115
|
+
return row?.model_id;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Set the model preference for a session.
|
|
119
|
+
* @param modelId Model ID in format "provider_id/model_id"
|
|
120
|
+
*/
|
|
121
|
+
export function setSessionModel(sessionId, modelId) {
|
|
122
|
+
const db = getDatabase();
|
|
123
|
+
db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
|
|
124
|
+
}
|
|
125
|
+
export function closeDatabase() {
|
|
126
|
+
if (db) {
|
|
127
|
+
db.close();
|
|
128
|
+
db = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { getDatabase, closeDatabase } from './database.js';
|
|
2
|
+
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
|
|
3
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
|
|
4
|
+
import { getOpencodeSystemMessage } from './system-message.js';
|
|
5
|
+
import { getFileAttachments, getTextAttachments } from './message-formatting.js';
|
|
6
|
+
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
7
|
+
import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, registerVoiceStateHandler, } from './voice-handler.js';
|
|
8
|
+
import { handleOpencodeSession, parseSlashCommand, } from './session-handler.js';
|
|
9
|
+
import { registerInteractionHandler } from './interaction-handler.js';
|
|
10
|
+
export { getDatabase, closeDatabase } from './database.js';
|
|
11
|
+
export { initializeOpencodeForDirectory } from './opencode.js';
|
|
12
|
+
export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js';
|
|
13
|
+
export { getOpencodeSystemMessage } from './system-message.js';
|
|
14
|
+
export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions } from './channel-management.js';
|
|
15
|
+
import { ChannelType, Client, Events, GatewayIntentBits, Partials, PermissionsBitField, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import { extractTagsArrays } from './xml.js';
|
|
18
|
+
import { createLogger } from './logger.js';
|
|
19
|
+
import { setGlobalDispatcher, Agent } from 'undici';
|
|
20
|
+
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
|
|
21
|
+
const discordLogger = createLogger('DISCORD');
|
|
22
|
+
const voiceLogger = createLogger('VOICE');
|
|
23
|
+
export async function createDiscordClient() {
|
|
24
|
+
return new Client({
|
|
25
|
+
intents: [
|
|
26
|
+
GatewayIntentBits.Guilds,
|
|
27
|
+
GatewayIntentBits.GuildMessages,
|
|
28
|
+
GatewayIntentBits.MessageContent,
|
|
29
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
30
|
+
],
|
|
31
|
+
partials: [
|
|
32
|
+
Partials.Channel,
|
|
33
|
+
Partials.Message,
|
|
34
|
+
Partials.User,
|
|
35
|
+
Partials.ThreadMember,
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
40
|
+
if (!discordClient) {
|
|
41
|
+
discordClient = await createDiscordClient();
|
|
42
|
+
}
|
|
43
|
+
let currentAppId = appId;
|
|
44
|
+
const setupHandlers = async (c) => {
|
|
45
|
+
discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
|
|
46
|
+
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`);
|
|
47
|
+
discordLogger.log(`Bot user ID: ${c.user.id}`);
|
|
48
|
+
if (!currentAppId) {
|
|
49
|
+
await c.application?.fetch();
|
|
50
|
+
currentAppId = c.application?.id;
|
|
51
|
+
if (!currentAppId) {
|
|
52
|
+
discordLogger.error('Could not get application ID');
|
|
53
|
+
throw new Error('Failed to get bot application ID');
|
|
54
|
+
}
|
|
55
|
+
discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
discordLogger.log(`Bot Application ID (provided): ${currentAppId}`);
|
|
59
|
+
}
|
|
60
|
+
for (const guild of c.guilds.cache.values()) {
|
|
61
|
+
discordLogger.log(`${guild.name} (${guild.id})`);
|
|
62
|
+
const channels = await getChannelsWithDescriptions(guild);
|
|
63
|
+
const kimakiChannels = channels.filter((ch) => ch.kimakiDirectory &&
|
|
64
|
+
(!ch.kimakiApp || ch.kimakiApp === currentAppId));
|
|
65
|
+
if (kimakiChannels.length > 0) {
|
|
66
|
+
discordLogger.log(` Found ${kimakiChannels.length} channel(s) for this bot:`);
|
|
67
|
+
for (const channel of kimakiChannels) {
|
|
68
|
+
discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
discordLogger.log(` No channels for this bot`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
voiceLogger.log(`[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`);
|
|
76
|
+
registerInteractionHandler({ discordClient: c, appId: currentAppId });
|
|
77
|
+
registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
|
|
78
|
+
};
|
|
79
|
+
// If client is already ready (was logged in before being passed to us),
|
|
80
|
+
// run setup immediately. Otherwise wait for the ClientReady event.
|
|
81
|
+
if (discordClient.isReady()) {
|
|
82
|
+
await setupHandlers(discordClient);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
discordClient.once(Events.ClientReady, setupHandlers);
|
|
86
|
+
}
|
|
87
|
+
discordClient.on(Events.MessageCreate, async (message) => {
|
|
88
|
+
try {
|
|
89
|
+
if (message.author?.bot) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (message.partial) {
|
|
93
|
+
discordLogger.log(`Fetching partial message ${message.id}`);
|
|
94
|
+
try {
|
|
95
|
+
await message.fetch();
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
discordLogger.log(`Failed to fetch partial message ${message.id}:`, error);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (message.guild && message.member) {
|
|
103
|
+
const isOwner = message.member.id === message.guild.ownerId;
|
|
104
|
+
const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
105
|
+
const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
106
|
+
const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
|
|
107
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
108
|
+
await message.react('🔒');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const channel = message.channel;
|
|
113
|
+
const isThread = [
|
|
114
|
+
ChannelType.PublicThread,
|
|
115
|
+
ChannelType.PrivateThread,
|
|
116
|
+
ChannelType.AnnouncementThread,
|
|
117
|
+
].includes(channel.type);
|
|
118
|
+
if (isThread) {
|
|
119
|
+
const thread = channel;
|
|
120
|
+
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
121
|
+
const row = getDatabase()
|
|
122
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
123
|
+
.get(thread.id);
|
|
124
|
+
if (!row) {
|
|
125
|
+
discordLogger.log(`No session found for thread ${thread.id}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
|
|
129
|
+
const parent = thread.parent;
|
|
130
|
+
let projectDirectory;
|
|
131
|
+
let channelAppId;
|
|
132
|
+
if (parent?.topic) {
|
|
133
|
+
const extracted = extractTagsArrays({
|
|
134
|
+
xml: parent.topic,
|
|
135
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
136
|
+
});
|
|
137
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
138
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
139
|
+
}
|
|
140
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
141
|
+
voiceLogger.log(`[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
145
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`);
|
|
146
|
+
await message.reply({
|
|
147
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
148
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let messageContent = message.content || '';
|
|
153
|
+
let sessionMessagesText;
|
|
154
|
+
if (projectDirectory && row.session_id) {
|
|
155
|
+
try {
|
|
156
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
157
|
+
const messagesResponse = await getClient().session.messages({
|
|
158
|
+
path: { id: row.session_id },
|
|
159
|
+
});
|
|
160
|
+
const messages = messagesResponse.data || [];
|
|
161
|
+
const recentMessages = messages.slice(-10);
|
|
162
|
+
sessionMessagesText = recentMessages
|
|
163
|
+
.map((m) => {
|
|
164
|
+
const role = m.info.role === 'user' ? 'User' : 'Assistant';
|
|
165
|
+
const text = (() => {
|
|
166
|
+
if (m.info.role === 'user') {
|
|
167
|
+
const textParts = (m.parts || []).filter((p) => p.type === 'text');
|
|
168
|
+
return textParts
|
|
169
|
+
.map((p) => ('text' in p ? p.text : ''))
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join('\n');
|
|
172
|
+
}
|
|
173
|
+
const assistantInfo = m.info;
|
|
174
|
+
return assistantInfo.text?.slice(0, 500);
|
|
175
|
+
})();
|
|
176
|
+
return `[${role}]: ${text || '(no text)'}`;
|
|
177
|
+
})
|
|
178
|
+
.join('\n\n');
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
voiceLogger.log(`Could not get session messages:`, e);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const transcription = await processVoiceAttachment({
|
|
185
|
+
message,
|
|
186
|
+
thread,
|
|
187
|
+
projectDirectory,
|
|
188
|
+
appId: currentAppId,
|
|
189
|
+
sessionMessages: sessionMessagesText,
|
|
190
|
+
});
|
|
191
|
+
if (transcription) {
|
|
192
|
+
messageContent = transcription;
|
|
193
|
+
}
|
|
194
|
+
const fileAttachments = getFileAttachments(message);
|
|
195
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
196
|
+
const promptWithAttachments = textAttachmentsContent
|
|
197
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
198
|
+
: messageContent;
|
|
199
|
+
const parsedCommand = parseSlashCommand(messageContent);
|
|
200
|
+
await handleOpencodeSession({
|
|
201
|
+
prompt: promptWithAttachments,
|
|
202
|
+
thread,
|
|
203
|
+
projectDirectory,
|
|
204
|
+
originalMessage: message,
|
|
205
|
+
images: fileAttachments,
|
|
206
|
+
parsedCommand,
|
|
207
|
+
channelId: parent?.id,
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (channel.type === ChannelType.GuildText) {
|
|
212
|
+
const textChannel = channel;
|
|
213
|
+
voiceLogger.log(`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`);
|
|
214
|
+
if (!textChannel.topic) {
|
|
215
|
+
voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no description`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const extracted = extractTagsArrays({
|
|
219
|
+
xml: textChannel.topic,
|
|
220
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
221
|
+
});
|
|
222
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
223
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
224
|
+
if (!projectDirectory) {
|
|
225
|
+
voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
229
|
+
voiceLogger.log(`[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`);
|
|
233
|
+
if (channelAppId) {
|
|
234
|
+
discordLogger.log(`APP: Channel app ID: ${channelAppId}`);
|
|
235
|
+
}
|
|
236
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
237
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`);
|
|
238
|
+
await message.reply({
|
|
239
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
240
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const hasVoice = message.attachments.some((a) => a.contentType?.startsWith('audio/'));
|
|
245
|
+
const threadName = hasVoice
|
|
246
|
+
? 'Voice Message'
|
|
247
|
+
: message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread';
|
|
248
|
+
const thread = await message.startThread({
|
|
249
|
+
name: threadName.slice(0, 80),
|
|
250
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
251
|
+
reason: 'Start Claude session',
|
|
252
|
+
});
|
|
253
|
+
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
|
|
254
|
+
let messageContent = message.content || '';
|
|
255
|
+
const transcription = await processVoiceAttachment({
|
|
256
|
+
message,
|
|
257
|
+
thread,
|
|
258
|
+
projectDirectory,
|
|
259
|
+
isNewThread: true,
|
|
260
|
+
appId: currentAppId,
|
|
261
|
+
});
|
|
262
|
+
if (transcription) {
|
|
263
|
+
messageContent = transcription;
|
|
264
|
+
}
|
|
265
|
+
const fileAttachments = getFileAttachments(message);
|
|
266
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
267
|
+
const promptWithAttachments = textAttachmentsContent
|
|
268
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
269
|
+
: messageContent;
|
|
270
|
+
const parsedCommand = parseSlashCommand(messageContent);
|
|
271
|
+
await handleOpencodeSession({
|
|
272
|
+
prompt: promptWithAttachments,
|
|
273
|
+
thread,
|
|
274
|
+
projectDirectory,
|
|
275
|
+
originalMessage: message,
|
|
276
|
+
images: fileAttachments,
|
|
277
|
+
parsedCommand,
|
|
278
|
+
channelId: textChannel.id,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
discordLogger.log(`Channel type ${channel.type} is not supported`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
voiceLogger.error('Discord handler error:', error);
|
|
287
|
+
try {
|
|
288
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
289
|
+
await message.reply({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS });
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
voiceLogger.error('Discord handler error (fallback):', error);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
await discordClient.login(token);
|
|
297
|
+
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
298
|
+
discordLogger.log(`Received ${signal}, cleaning up...`);
|
|
299
|
+
if (global.shuttingDown) {
|
|
300
|
+
discordLogger.log('Already shutting down, ignoring duplicate signal');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
;
|
|
304
|
+
global.shuttingDown = true;
|
|
305
|
+
try {
|
|
306
|
+
const cleanupPromises = [];
|
|
307
|
+
for (const [guildId] of voiceConnections) {
|
|
308
|
+
voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
|
|
309
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId));
|
|
310
|
+
}
|
|
311
|
+
if (cleanupPromises.length > 0) {
|
|
312
|
+
voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
|
|
313
|
+
await Promise.allSettled(cleanupPromises);
|
|
314
|
+
discordLogger.log(`All voice connections cleaned up`);
|
|
315
|
+
}
|
|
316
|
+
for (const [dir, server] of getOpencodeServers()) {
|
|
317
|
+
if (!server.process.killed) {
|
|
318
|
+
voiceLogger.log(`[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`);
|
|
319
|
+
server.process.kill('SIGTERM');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
getOpencodeServers().clear();
|
|
323
|
+
discordLogger.log('Closing database...');
|
|
324
|
+
closeDatabase();
|
|
325
|
+
discordLogger.log('Destroying Discord client...');
|
|
326
|
+
discordClient.destroy();
|
|
327
|
+
discordLogger.log('Cleanup complete.');
|
|
328
|
+
if (!skipExit) {
|
|
329
|
+
process.exit(0);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
|
|
334
|
+
if (!skipExit) {
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
process.on('SIGTERM', async () => {
|
|
340
|
+
try {
|
|
341
|
+
await handleShutdown('SIGTERM');
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
voiceLogger.error('[SIGTERM] Error during shutdown:', error);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
process.on('SIGINT', async () => {
|
|
349
|
+
try {
|
|
350
|
+
await handleShutdown('SIGINT');
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
voiceLogger.error('[SIGINT] Error during shutdown:', error);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
process.on('SIGUSR2', async () => {
|
|
358
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
|
359
|
+
try {
|
|
360
|
+
await handleShutdown('SIGUSR2', { skipExit: true });
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error);
|
|
364
|
+
}
|
|
365
|
+
const { spawn } = await import('node:child_process');
|
|
366
|
+
spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
|
|
367
|
+
stdio: 'inherit',
|
|
368
|
+
detached: true,
|
|
369
|
+
cwd: process.cwd(),
|
|
370
|
+
env: process.env,
|
|
371
|
+
}).unref();
|
|
372
|
+
process.exit(0);
|
|
373
|
+
});
|
|
374
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
375
|
+
if (global.shuttingDown) {
|
|
376
|
+
discordLogger.log('Ignoring unhandled rejection during shutdown:', reason);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
discordLogger.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
380
|
+
});
|
|
381
|
+
}
|