kimaki 0.4.6 → 0.4.9
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/README.md +67 -5
- package/dist/cli.js +158 -0
- package/dist/discordBot.js +143 -0
- package/dist/opencode-plugin.js +53 -0
- package/package.json +13 -11
- package/src/cli.ts +224 -0
- package/src/discordBot.ts +174 -0
- package/src/opencode-command.md +4 -0
- package/src/opencode-plugin.ts +71 -0
package/README.md
CHANGED
|
@@ -1,7 +1,69 @@
|
|
|
1
|
-
|
|
1
|
+
# Kimaki Discord Bot
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
- get bot app id
|
|
5
|
-
- get bot token in Discord clicking reset bot token. show a password input
|
|
3
|
+
A Discord bot that integrates OpenCode coding sessions with Discord channels and voice.
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g kimaki
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Run the interactive setup:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
kimaki
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This will guide you through:
|
|
20
|
+
1. Creating a Discord application at https://discord.com/developers/applications
|
|
21
|
+
2. Getting your bot token
|
|
22
|
+
3. Installing the bot to your Discord server
|
|
23
|
+
4. Creating channels for your OpenCode projects
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
### Start the bot
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
kimaki
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Send a session to Discord
|
|
34
|
+
|
|
35
|
+
Send an OpenCode session to Discord from the CLI:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
kimaki send-to-discord <session-id>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
- `-d, --directory <dir>` - Project directory (defaults to current working directory)
|
|
43
|
+
|
|
44
|
+
### OpenCode Integration
|
|
45
|
+
|
|
46
|
+
To use the `/send-to-kimaki-discord` command in OpenCode:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx kimaki install-plugin
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then use `/send-to-kimaki-discord` in OpenCode to send the current session to Discord.
|
|
53
|
+
|
|
54
|
+
## Discord Slash Commands
|
|
55
|
+
|
|
56
|
+
Once the bot is running, you can use these commands in Discord:
|
|
57
|
+
|
|
58
|
+
- `/session <prompt>` - Start a new OpenCode session
|
|
59
|
+
- `/resume <session>` - Resume an existing session
|
|
60
|
+
- `/add-project <project>` - Add a new project to Discord
|
|
61
|
+
- `/accept` - Accept a permission request
|
|
62
|
+
- `/accept-always` - Accept and auto-approve similar requests
|
|
63
|
+
- `/reject` - Reject a permission request
|
|
64
|
+
|
|
65
|
+
## Voice Support
|
|
66
|
+
|
|
67
|
+
Join a voice channel that has an associated project directory, and the bot will join with Jarvis-like voice interaction powered by Gemini.
|
|
68
|
+
|
|
69
|
+
Requires a Gemini API key (prompted during setup).
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,8 @@ import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDis
|
|
|
6
6
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import os from 'node:os';
|
|
9
11
|
import { createLogger } from './logger.js';
|
|
10
12
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
11
13
|
const cliLogger = createLogger('CLI');
|
|
@@ -70,6 +72,18 @@ async function registerCommands(token, appId) {
|
|
|
70
72
|
return option;
|
|
71
73
|
})
|
|
72
74
|
.toJSON(),
|
|
75
|
+
new SlashCommandBuilder()
|
|
76
|
+
.setName('accept')
|
|
77
|
+
.setDescription('Accept a pending permission request (this request only)')
|
|
78
|
+
.toJSON(),
|
|
79
|
+
new SlashCommandBuilder()
|
|
80
|
+
.setName('accept-always')
|
|
81
|
+
.setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
|
|
82
|
+
.toJSON(),
|
|
83
|
+
new SlashCommandBuilder()
|
|
84
|
+
.setName('reject')
|
|
85
|
+
.setDescription('Reject a pending permission request')
|
|
86
|
+
.toJSON(),
|
|
73
87
|
];
|
|
74
88
|
const rest = new REST().setToken(token);
|
|
75
89
|
try {
|
|
@@ -423,5 +437,149 @@ cli
|
|
|
423
437
|
process.exit(EXIT_NO_RESTART);
|
|
424
438
|
}
|
|
425
439
|
});
|
|
440
|
+
cli
|
|
441
|
+
.command('send-to-discord <sessionId>', 'Send an OpenCode session to Discord and create a thread for it')
|
|
442
|
+
.option('-d, --directory <dir>', 'Project directory (defaults to current working directory)')
|
|
443
|
+
.action(async (sessionId, options) => {
|
|
444
|
+
try {
|
|
445
|
+
const directory = options.directory || process.cwd();
|
|
446
|
+
const db = getDatabase();
|
|
447
|
+
const botRow = db
|
|
448
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
449
|
+
.get();
|
|
450
|
+
if (!botRow) {
|
|
451
|
+
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.');
|
|
452
|
+
process.exit(EXIT_NO_RESTART);
|
|
453
|
+
}
|
|
454
|
+
const channelRow = db
|
|
455
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
456
|
+
.get(directory, 'text');
|
|
457
|
+
if (!channelRow) {
|
|
458
|
+
cliLogger.error(`No Discord channel found for directory: ${directory}\n` +
|
|
459
|
+
'Run `kimaki --add-channels` to create a channel for this project.');
|
|
460
|
+
process.exit(EXIT_NO_RESTART);
|
|
461
|
+
}
|
|
462
|
+
const s = spinner();
|
|
463
|
+
s.start('Connecting to Discord...');
|
|
464
|
+
const discordClient = await createDiscordClient();
|
|
465
|
+
await new Promise((resolve, reject) => {
|
|
466
|
+
discordClient.once(Events.ClientReady, () => {
|
|
467
|
+
resolve();
|
|
468
|
+
});
|
|
469
|
+
discordClient.once(Events.Error, reject);
|
|
470
|
+
discordClient.login(botRow.token).catch(reject);
|
|
471
|
+
});
|
|
472
|
+
s.stop('Connected to Discord!');
|
|
473
|
+
const channel = await discordClient.channels.fetch(channelRow.channel_id);
|
|
474
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
475
|
+
cliLogger.error('Could not find the text channel or it is not a text channel');
|
|
476
|
+
discordClient.destroy();
|
|
477
|
+
process.exit(EXIT_NO_RESTART);
|
|
478
|
+
}
|
|
479
|
+
const textChannel = channel;
|
|
480
|
+
s.start('Fetching session from OpenCode...');
|
|
481
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
482
|
+
const sessionResponse = await getClient().session.get({
|
|
483
|
+
path: { id: sessionId },
|
|
484
|
+
});
|
|
485
|
+
if (!sessionResponse.data) {
|
|
486
|
+
s.stop('Session not found');
|
|
487
|
+
discordClient.destroy();
|
|
488
|
+
process.exit(EXIT_NO_RESTART);
|
|
489
|
+
}
|
|
490
|
+
const session = sessionResponse.data;
|
|
491
|
+
s.stop(`Found session: ${session.title}`);
|
|
492
|
+
s.start('Creating Discord thread...');
|
|
493
|
+
const thread = await textChannel.threads.create({
|
|
494
|
+
name: `Resume: ${session.title}`.slice(0, 100),
|
|
495
|
+
autoArchiveDuration: 1440,
|
|
496
|
+
reason: `Resuming session ${sessionId} from CLI`,
|
|
497
|
+
});
|
|
498
|
+
db.prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)').run(thread.id, sessionId);
|
|
499
|
+
s.stop('Created Discord thread!');
|
|
500
|
+
s.start('Loading session messages...');
|
|
501
|
+
const messagesResponse = await getClient().session.messages({
|
|
502
|
+
path: { id: sessionId },
|
|
503
|
+
});
|
|
504
|
+
if (!messagesResponse.data) {
|
|
505
|
+
s.stop('Failed to fetch session messages');
|
|
506
|
+
discordClient.destroy();
|
|
507
|
+
process.exit(EXIT_NO_RESTART);
|
|
508
|
+
}
|
|
509
|
+
const messages = messagesResponse.data;
|
|
510
|
+
await thread.send(`📂 **Resumed session:** ${session.title}\n📅 **Created:** ${new Date(session.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
|
|
511
|
+
let messageCount = 0;
|
|
512
|
+
for (const message of messages) {
|
|
513
|
+
if (message.info.role === 'user') {
|
|
514
|
+
const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
|
|
515
|
+
const userTexts = userParts
|
|
516
|
+
.map((p) => {
|
|
517
|
+
if (p.type === 'text') {
|
|
518
|
+
return p.text;
|
|
519
|
+
}
|
|
520
|
+
return '';
|
|
521
|
+
})
|
|
522
|
+
.filter((t) => t.trim());
|
|
523
|
+
const userText = userTexts.join('\n\n');
|
|
524
|
+
if (userText) {
|
|
525
|
+
const truncated = userText.length > 1900 ? userText.slice(0, 1900) + '…' : userText;
|
|
526
|
+
await thread.send(`**User:**\n${truncated}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else if (message.info.role === 'assistant') {
|
|
530
|
+
const textParts = message.parts.filter((p) => p.type === 'text');
|
|
531
|
+
const texts = textParts
|
|
532
|
+
.map((p) => {
|
|
533
|
+
if (p.type === 'text') {
|
|
534
|
+
return p.text;
|
|
535
|
+
}
|
|
536
|
+
return '';
|
|
537
|
+
})
|
|
538
|
+
.filter((t) => t?.trim());
|
|
539
|
+
if (texts.length > 0) {
|
|
540
|
+
const combinedText = texts.join('\n\n');
|
|
541
|
+
const truncated = combinedText.length > 1900 ? combinedText.slice(0, 1900) + '…' : combinedText;
|
|
542
|
+
await thread.send(truncated);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
messageCount++;
|
|
546
|
+
}
|
|
547
|
+
await thread.send(`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
|
|
548
|
+
s.stop(`Loaded ${messageCount} messages`);
|
|
549
|
+
const guildId = textChannel.guildId;
|
|
550
|
+
const threadUrl = `https://discord.com/channels/${guildId}/${thread.id}`;
|
|
551
|
+
note(`Session "${session.title}" has been sent to Discord!\n\nThread: ${threadUrl}`, '✅ Success');
|
|
552
|
+
discordClient.destroy();
|
|
553
|
+
process.exit(0);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
557
|
+
process.exit(EXIT_NO_RESTART);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
cli
|
|
561
|
+
.command('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord command')
|
|
562
|
+
.action(async () => {
|
|
563
|
+
try {
|
|
564
|
+
const require = createRequire(import.meta.url);
|
|
565
|
+
const pluginSrc = require.resolve('./opencode-plugin.ts');
|
|
566
|
+
const commandSrc = require.resolve('./opencode-command.md');
|
|
567
|
+
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode');
|
|
568
|
+
const pluginDir = path.join(opencodeConfig, 'plugin');
|
|
569
|
+
const commandDir = path.join(opencodeConfig, 'command');
|
|
570
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
571
|
+
fs.mkdirSync(commandDir, { recursive: true });
|
|
572
|
+
const pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts');
|
|
573
|
+
const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md');
|
|
574
|
+
fs.copyFileSync(pluginSrc, pluginDest);
|
|
575
|
+
fs.copyFileSync(commandSrc, commandDest);
|
|
576
|
+
note(`Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`, '✅ Installed');
|
|
577
|
+
process.exit(0);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
581
|
+
process.exit(EXIT_NO_RESTART);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
426
584
|
cli.help();
|
|
427
585
|
cli.parse();
|
package/dist/discordBot.js
CHANGED
|
@@ -35,6 +35,8 @@ const abortControllers = new Map();
|
|
|
35
35
|
const voiceConnections = new Map();
|
|
36
36
|
// Map of directory to retry count for server restarts
|
|
37
37
|
const serverRetryCount = new Map();
|
|
38
|
+
// Map of thread ID to pending permission (only one pending permission per thread)
|
|
39
|
+
const pendingPermissions = new Map();
|
|
38
40
|
let db = null;
|
|
39
41
|
function convertToMono16k(buffer) {
|
|
40
42
|
// Parameters
|
|
@@ -1160,6 +1162,38 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1160
1162
|
}
|
|
1161
1163
|
break;
|
|
1162
1164
|
}
|
|
1165
|
+
else if (event.type === 'permission.updated') {
|
|
1166
|
+
const permission = event.properties;
|
|
1167
|
+
if (permission.sessionID !== session.id) {
|
|
1168
|
+
voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
sessionLogger.log(`Permission requested: type=${permission.type}, title=${permission.title}`);
|
|
1172
|
+
const patternStr = Array.isArray(permission.pattern)
|
|
1173
|
+
? permission.pattern.join(', ')
|
|
1174
|
+
: permission.pattern || '';
|
|
1175
|
+
const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
|
|
1176
|
+
`**Type:** \`${permission.type}\`\n` +
|
|
1177
|
+
`**Action:** ${permission.title}\n` +
|
|
1178
|
+
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
1179
|
+
`\nUse \`/accept\` or \`/reject\` to respond.`);
|
|
1180
|
+
pendingPermissions.set(thread.id, {
|
|
1181
|
+
permission,
|
|
1182
|
+
messageId: permissionMessage.id,
|
|
1183
|
+
directory,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
else if (event.type === 'permission.replied') {
|
|
1187
|
+
const { permissionID, response, sessionID } = event.properties;
|
|
1188
|
+
if (sessionID !== session.id) {
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
sessionLogger.log(`Permission ${permissionID} replied with: ${response}`);
|
|
1192
|
+
const pending = pendingPermissions.get(thread.id);
|
|
1193
|
+
if (pending && pending.permission.id === permissionID) {
|
|
1194
|
+
pendingPermissions.delete(thread.id);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1163
1197
|
else if (event.type === 'file.edited') {
|
|
1164
1198
|
sessionLogger.log(`File edited event received`);
|
|
1165
1199
|
}
|
|
@@ -1927,6 +1961,115 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1927
1961
|
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
1928
1962
|
}
|
|
1929
1963
|
}
|
|
1964
|
+
else if (command.commandName === 'accept' ||
|
|
1965
|
+
command.commandName === 'accept-always') {
|
|
1966
|
+
const scope = command.commandName === 'accept-always' ? 'always' : 'once';
|
|
1967
|
+
const channel = command.channel;
|
|
1968
|
+
if (!channel) {
|
|
1969
|
+
await command.reply({
|
|
1970
|
+
content: 'This command can only be used in a channel',
|
|
1971
|
+
ephemeral: true,
|
|
1972
|
+
});
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
const isThread = [
|
|
1976
|
+
ChannelType.PublicThread,
|
|
1977
|
+
ChannelType.PrivateThread,
|
|
1978
|
+
ChannelType.AnnouncementThread,
|
|
1979
|
+
].includes(channel.type);
|
|
1980
|
+
if (!isThread) {
|
|
1981
|
+
await command.reply({
|
|
1982
|
+
content: 'This command can only be used in a thread with an active session',
|
|
1983
|
+
ephemeral: true,
|
|
1984
|
+
});
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const pending = pendingPermissions.get(channel.id);
|
|
1988
|
+
if (!pending) {
|
|
1989
|
+
await command.reply({
|
|
1990
|
+
content: 'No pending permission request in this thread',
|
|
1991
|
+
ephemeral: true,
|
|
1992
|
+
});
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
try {
|
|
1996
|
+
const getClient = await initializeOpencodeForDirectory(pending.directory);
|
|
1997
|
+
await getClient().postSessionIdPermissionsPermissionId({
|
|
1998
|
+
path: {
|
|
1999
|
+
id: pending.permission.sessionID,
|
|
2000
|
+
permissionID: pending.permission.id,
|
|
2001
|
+
},
|
|
2002
|
+
body: {
|
|
2003
|
+
response: scope,
|
|
2004
|
+
},
|
|
2005
|
+
});
|
|
2006
|
+
pendingPermissions.delete(channel.id);
|
|
2007
|
+
const msg = scope === 'always'
|
|
2008
|
+
? `✅ Permission **accepted** (auto-approve similar requests)`
|
|
2009
|
+
: `✅ Permission **accepted**`;
|
|
2010
|
+
await command.reply(msg);
|
|
2011
|
+
sessionLogger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`);
|
|
2012
|
+
}
|
|
2013
|
+
catch (error) {
|
|
2014
|
+
voiceLogger.error('[ACCEPT] Error:', error);
|
|
2015
|
+
await command.reply({
|
|
2016
|
+
content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2017
|
+
ephemeral: true,
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
else if (command.commandName === 'reject') {
|
|
2022
|
+
const channel = command.channel;
|
|
2023
|
+
if (!channel) {
|
|
2024
|
+
await command.reply({
|
|
2025
|
+
content: 'This command can only be used in a channel',
|
|
2026
|
+
ephemeral: true,
|
|
2027
|
+
});
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const isThread = [
|
|
2031
|
+
ChannelType.PublicThread,
|
|
2032
|
+
ChannelType.PrivateThread,
|
|
2033
|
+
ChannelType.AnnouncementThread,
|
|
2034
|
+
].includes(channel.type);
|
|
2035
|
+
if (!isThread) {
|
|
2036
|
+
await command.reply({
|
|
2037
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2038
|
+
ephemeral: true,
|
|
2039
|
+
});
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const pending = pendingPermissions.get(channel.id);
|
|
2043
|
+
if (!pending) {
|
|
2044
|
+
await command.reply({
|
|
2045
|
+
content: 'No pending permission request in this thread',
|
|
2046
|
+
ephemeral: true,
|
|
2047
|
+
});
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
try {
|
|
2051
|
+
const getClient = await initializeOpencodeForDirectory(pending.directory);
|
|
2052
|
+
await getClient().postSessionIdPermissionsPermissionId({
|
|
2053
|
+
path: {
|
|
2054
|
+
id: pending.permission.sessionID,
|
|
2055
|
+
permissionID: pending.permission.id,
|
|
2056
|
+
},
|
|
2057
|
+
body: {
|
|
2058
|
+
response: 'reject',
|
|
2059
|
+
},
|
|
2060
|
+
});
|
|
2061
|
+
pendingPermissions.delete(channel.id);
|
|
2062
|
+
await command.reply(`❌ Permission **rejected**`);
|
|
2063
|
+
sessionLogger.log(`Permission ${pending.permission.id} rejected`);
|
|
2064
|
+
}
|
|
2065
|
+
catch (error) {
|
|
2066
|
+
voiceLogger.error('[REJECT] Error:', error);
|
|
2067
|
+
await command.reply({
|
|
2068
|
+
content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2069
|
+
ephemeral: true,
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
1930
2073
|
}
|
|
1931
2074
|
}
|
|
1932
2075
|
catch (error) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kimaki Discord Plugin for OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Adds /send-to-kimaki-discord command that sends the current session to Discord.
|
|
5
|
+
*
|
|
6
|
+
* Installation:
|
|
7
|
+
* kimaki install-plugin
|
|
8
|
+
*
|
|
9
|
+
* Use in OpenCode TUI:
|
|
10
|
+
* /send-to-kimaki-discord
|
|
11
|
+
*/
|
|
12
|
+
export const KimakiDiscordPlugin = async ({ client, $, directory, }) => {
|
|
13
|
+
return {
|
|
14
|
+
event: async ({ event }) => {
|
|
15
|
+
if (event.type !== 'command.executed') {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { name, sessionID } = event.properties;
|
|
19
|
+
if (name !== 'send-to-kimaki-discord') {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!sessionID) {
|
|
23
|
+
await client.tui.showToast({
|
|
24
|
+
body: { message: 'No session ID available', variant: 'error' },
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const result = await $ `npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text();
|
|
30
|
+
const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/);
|
|
31
|
+
const url = urlMatch ? urlMatch[0] : null;
|
|
32
|
+
await client.tui.showToast({
|
|
33
|
+
body: {
|
|
34
|
+
message: url ? `Sent to Discord: ${url}` : 'Session sent to Discord',
|
|
35
|
+
variant: 'success',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
const message = error.stderr?.toString().trim() ||
|
|
41
|
+
error.stdout?.toString().trim() ||
|
|
42
|
+
error.message ||
|
|
43
|
+
String(error);
|
|
44
|
+
await client.tui.showToast({
|
|
45
|
+
body: {
|
|
46
|
+
message: `Failed: ${message.slice(0, 100)}`,
|
|
47
|
+
variant: 'error',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.9",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
|
+
"prepublishOnly": "pnpm tsc",
|
|
9
|
+
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
10
|
+
"watch": "tsx scripts/watch-session.ts",
|
|
11
|
+
"test:events": "tsx test-events.ts",
|
|
12
|
+
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
13
|
+
"test:send": "tsx send-test-message.ts",
|
|
14
|
+
"register-commands": "tsx scripts/register-commands.ts"
|
|
15
|
+
},
|
|
6
16
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
17
|
"bin": "bin.js",
|
|
8
18
|
"files": [
|
|
@@ -11,6 +21,7 @@
|
|
|
11
21
|
"bin.js"
|
|
12
22
|
],
|
|
13
23
|
"devDependencies": {
|
|
24
|
+
"@opencode-ai/plugin": "^1.0.119",
|
|
14
25
|
"@types/better-sqlite3": "^7.6.13",
|
|
15
26
|
"@types/bun": "latest",
|
|
16
27
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -43,14 +54,5 @@
|
|
|
43
54
|
"string-dedent": "^3.0.2",
|
|
44
55
|
"undici": "^7.16.0",
|
|
45
56
|
"zod": "^4.0.17"
|
|
46
|
-
},
|
|
47
|
-
"scripts": {
|
|
48
|
-
"dev": "tsx --env-file .env src/cli.ts",
|
|
49
|
-
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
50
|
-
"watch": "tsx scripts/watch-session.ts",
|
|
51
|
-
"test:events": "tsx test-events.ts",
|
|
52
|
-
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
53
|
-
"test:send": "tsx send-test-message.ts",
|
|
54
|
-
"register-commands": "tsx scripts/register-commands.ts"
|
|
55
57
|
}
|
|
56
|
-
}
|
|
58
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
} from 'discord.js'
|
|
37
37
|
import path from 'node:path'
|
|
38
38
|
import fs from 'node:fs'
|
|
39
|
+
import { createRequire } from 'node:module'
|
|
40
|
+
import os from 'node:os'
|
|
39
41
|
import { createLogger } from './logger.js'
|
|
40
42
|
import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
|
|
41
43
|
|
|
@@ -126,6 +128,18 @@ async function registerCommands(token: string, appId: string) {
|
|
|
126
128
|
return option
|
|
127
129
|
})
|
|
128
130
|
.toJSON(),
|
|
131
|
+
new SlashCommandBuilder()
|
|
132
|
+
.setName('accept')
|
|
133
|
+
.setDescription('Accept a pending permission request (this request only)')
|
|
134
|
+
.toJSON(),
|
|
135
|
+
new SlashCommandBuilder()
|
|
136
|
+
.setName('accept-always')
|
|
137
|
+
.setDescription('Accept and auto-approve future requests matching this pattern (e.g. "git *" approves all git commands)')
|
|
138
|
+
.toJSON(),
|
|
139
|
+
new SlashCommandBuilder()
|
|
140
|
+
.setName('reject')
|
|
141
|
+
.setDescription('Reject a pending permission request')
|
|
142
|
+
.toJSON(),
|
|
129
143
|
]
|
|
130
144
|
|
|
131
145
|
const rest = new REST().setToken(token)
|
|
@@ -643,5 +657,215 @@ cli
|
|
|
643
657
|
}
|
|
644
658
|
})
|
|
645
659
|
|
|
660
|
+
cli
|
|
661
|
+
.command(
|
|
662
|
+
'send-to-discord <sessionId>',
|
|
663
|
+
'Send an OpenCode session to Discord and create a thread for it',
|
|
664
|
+
)
|
|
665
|
+
.option('-d, --directory <dir>', 'Project directory (defaults to current working directory)')
|
|
666
|
+
.action(async (sessionId: string, options: { directory?: string }) => {
|
|
667
|
+
try {
|
|
668
|
+
const directory = options.directory || process.cwd()
|
|
669
|
+
|
|
670
|
+
const db = getDatabase()
|
|
671
|
+
|
|
672
|
+
const botRow = db
|
|
673
|
+
.prepare(
|
|
674
|
+
'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
675
|
+
)
|
|
676
|
+
.get() as { app_id: string; token: string } | undefined
|
|
677
|
+
|
|
678
|
+
if (!botRow) {
|
|
679
|
+
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.')
|
|
680
|
+
process.exit(EXIT_NO_RESTART)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const channelRow = db
|
|
684
|
+
.prepare(
|
|
685
|
+
'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
686
|
+
)
|
|
687
|
+
.get(directory, 'text') as { channel_id: string } | undefined
|
|
688
|
+
|
|
689
|
+
if (!channelRow) {
|
|
690
|
+
cliLogger.error(
|
|
691
|
+
`No Discord channel found for directory: ${directory}\n` +
|
|
692
|
+
'Run `kimaki --add-channels` to create a channel for this project.',
|
|
693
|
+
)
|
|
694
|
+
process.exit(EXIT_NO_RESTART)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const s = spinner()
|
|
698
|
+
s.start('Connecting to Discord...')
|
|
699
|
+
|
|
700
|
+
const discordClient = await createDiscordClient()
|
|
701
|
+
|
|
702
|
+
await new Promise<void>((resolve, reject) => {
|
|
703
|
+
discordClient.once(Events.ClientReady, () => {
|
|
704
|
+
resolve()
|
|
705
|
+
})
|
|
706
|
+
discordClient.once(Events.Error, reject)
|
|
707
|
+
discordClient.login(botRow.token).catch(reject)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
s.stop('Connected to Discord!')
|
|
711
|
+
|
|
712
|
+
const channel = await discordClient.channels.fetch(channelRow.channel_id)
|
|
713
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
714
|
+
cliLogger.error('Could not find the text channel or it is not a text channel')
|
|
715
|
+
discordClient.destroy()
|
|
716
|
+
process.exit(EXIT_NO_RESTART)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const textChannel = channel as import('discord.js').TextChannel
|
|
720
|
+
|
|
721
|
+
s.start('Fetching session from OpenCode...')
|
|
722
|
+
|
|
723
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
724
|
+
const sessionResponse = await getClient().session.get({
|
|
725
|
+
path: { id: sessionId },
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
if (!sessionResponse.data) {
|
|
729
|
+
s.stop('Session not found')
|
|
730
|
+
discordClient.destroy()
|
|
731
|
+
process.exit(EXIT_NO_RESTART)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const session = sessionResponse.data
|
|
735
|
+
s.stop(`Found session: ${session.title}`)
|
|
736
|
+
|
|
737
|
+
s.start('Creating Discord thread...')
|
|
738
|
+
|
|
739
|
+
const thread = await textChannel.threads.create({
|
|
740
|
+
name: `Resume: ${session.title}`.slice(0, 100),
|
|
741
|
+
autoArchiveDuration: 1440,
|
|
742
|
+
reason: `Resuming session ${sessionId} from CLI`,
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
db.prepare(
|
|
746
|
+
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
|
|
747
|
+
).run(thread.id, sessionId)
|
|
748
|
+
|
|
749
|
+
s.stop('Created Discord thread!')
|
|
750
|
+
|
|
751
|
+
s.start('Loading session messages...')
|
|
752
|
+
|
|
753
|
+
const messagesResponse = await getClient().session.messages({
|
|
754
|
+
path: { id: sessionId },
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
if (!messagesResponse.data) {
|
|
758
|
+
s.stop('Failed to fetch session messages')
|
|
759
|
+
discordClient.destroy()
|
|
760
|
+
process.exit(EXIT_NO_RESTART)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const messages = messagesResponse.data
|
|
764
|
+
|
|
765
|
+
await thread.send(
|
|
766
|
+
`📂 **Resumed session:** ${session.title}\n📅 **Created:** ${new Date(session.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
let messageCount = 0
|
|
770
|
+
for (const message of messages) {
|
|
771
|
+
if (message.info.role === 'user') {
|
|
772
|
+
const userParts = message.parts.filter(
|
|
773
|
+
(p) => p.type === 'text' && !p.synthetic,
|
|
774
|
+
)
|
|
775
|
+
const userTexts = userParts
|
|
776
|
+
.map((p) => {
|
|
777
|
+
if (p.type === 'text') {
|
|
778
|
+
return p.text
|
|
779
|
+
}
|
|
780
|
+
return ''
|
|
781
|
+
})
|
|
782
|
+
.filter((t) => t.trim())
|
|
783
|
+
|
|
784
|
+
const userText = userTexts.join('\n\n')
|
|
785
|
+
if (userText) {
|
|
786
|
+
const truncated = userText.length > 1900 ? userText.slice(0, 1900) + '…' : userText
|
|
787
|
+
await thread.send(`**User:**\n${truncated}`)
|
|
788
|
+
}
|
|
789
|
+
} else if (message.info.role === 'assistant') {
|
|
790
|
+
const textParts = message.parts.filter((p) => p.type === 'text')
|
|
791
|
+
const texts = textParts
|
|
792
|
+
.map((p) => {
|
|
793
|
+
if (p.type === 'text') {
|
|
794
|
+
return p.text
|
|
795
|
+
}
|
|
796
|
+
return ''
|
|
797
|
+
})
|
|
798
|
+
.filter((t) => t?.trim())
|
|
799
|
+
|
|
800
|
+
if (texts.length > 0) {
|
|
801
|
+
const combinedText = texts.join('\n\n')
|
|
802
|
+
const truncated = combinedText.length > 1900 ? combinedText.slice(0, 1900) + '…' : combinedText
|
|
803
|
+
await thread.send(truncated)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
messageCount++
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
await thread.send(
|
|
810
|
+
`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
s.stop(`Loaded ${messageCount} messages`)
|
|
814
|
+
|
|
815
|
+
const guildId = textChannel.guildId
|
|
816
|
+
const threadUrl = `https://discord.com/channels/${guildId}/${thread.id}`
|
|
817
|
+
|
|
818
|
+
note(
|
|
819
|
+
`Session "${session.title}" has been sent to Discord!\n\nThread: ${threadUrl}`,
|
|
820
|
+
'✅ Success',
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
discordClient.destroy()
|
|
824
|
+
process.exit(0)
|
|
825
|
+
} catch (error) {
|
|
826
|
+
cliLogger.error(
|
|
827
|
+
'Error:',
|
|
828
|
+
error instanceof Error ? error.message : String(error),
|
|
829
|
+
)
|
|
830
|
+
process.exit(EXIT_NO_RESTART)
|
|
831
|
+
}
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
cli
|
|
835
|
+
.command('install-plugin', 'Install the OpenCode plugin for /send-to-kimaki-discord command')
|
|
836
|
+
.action(async () => {
|
|
837
|
+
try {
|
|
838
|
+
const require = createRequire(import.meta.url)
|
|
839
|
+
const pluginSrc = require.resolve('./opencode-plugin.ts')
|
|
840
|
+
const commandSrc = require.resolve('./opencode-command.md')
|
|
841
|
+
|
|
842
|
+
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode')
|
|
843
|
+
const pluginDir = path.join(opencodeConfig, 'plugin')
|
|
844
|
+
const commandDir = path.join(opencodeConfig, 'command')
|
|
845
|
+
|
|
846
|
+
fs.mkdirSync(pluginDir, { recursive: true })
|
|
847
|
+
fs.mkdirSync(commandDir, { recursive: true })
|
|
848
|
+
|
|
849
|
+
const pluginDest = path.join(pluginDir, 'send-to-kimaki-discord.ts')
|
|
850
|
+
const commandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
|
|
851
|
+
|
|
852
|
+
fs.copyFileSync(pluginSrc, pluginDest)
|
|
853
|
+
fs.copyFileSync(commandSrc, commandDest)
|
|
854
|
+
|
|
855
|
+
note(
|
|
856
|
+
`Plugin: ${pluginDest}\nCommand: ${commandDest}\n\nUse /send-to-kimaki-discord in OpenCode to send the current session to Discord.`,
|
|
857
|
+
'✅ Installed',
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
process.exit(0)
|
|
861
|
+
} catch (error) {
|
|
862
|
+
cliLogger.error(
|
|
863
|
+
'Error:',
|
|
864
|
+
error instanceof Error ? error.message : String(error),
|
|
865
|
+
)
|
|
866
|
+
process.exit(EXIT_NO_RESTART)
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
|
|
646
870
|
cli.help()
|
|
647
871
|
cli.parse()
|
package/src/discordBot.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type Part,
|
|
5
5
|
type Config,
|
|
6
6
|
type FilePartInput,
|
|
7
|
+
type Permission,
|
|
7
8
|
} from '@opencode-ai/sdk'
|
|
8
9
|
|
|
9
10
|
import { createGenAIWorker, type GenAIWorker } from './genai-worker-wrapper.js'
|
|
@@ -90,6 +91,12 @@ const voiceConnections = new Map<
|
|
|
90
91
|
// Map of directory to retry count for server restarts
|
|
91
92
|
const serverRetryCount = new Map<string, number>()
|
|
92
93
|
|
|
94
|
+
// Map of thread ID to pending permission (only one pending permission per thread)
|
|
95
|
+
const pendingPermissions = new Map<
|
|
96
|
+
string,
|
|
97
|
+
{ permission: Permission; messageId: string; directory: string }
|
|
98
|
+
>()
|
|
99
|
+
|
|
93
100
|
let db: Database.Database | null = null
|
|
94
101
|
|
|
95
102
|
function convertToMono16k(buffer: Buffer): Buffer {
|
|
@@ -1547,6 +1554,51 @@ async function handleOpencodeSession({
|
|
|
1547
1554
|
)
|
|
1548
1555
|
}
|
|
1549
1556
|
break
|
|
1557
|
+
} else if (event.type === 'permission.updated') {
|
|
1558
|
+
const permission = event.properties
|
|
1559
|
+
if (permission.sessionID !== session.id) {
|
|
1560
|
+
voiceLogger.log(
|
|
1561
|
+
`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
|
|
1562
|
+
)
|
|
1563
|
+
continue
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
sessionLogger.log(
|
|
1567
|
+
`Permission requested: type=${permission.type}, title=${permission.title}`,
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
const patternStr = Array.isArray(permission.pattern)
|
|
1571
|
+
? permission.pattern.join(', ')
|
|
1572
|
+
: permission.pattern || ''
|
|
1573
|
+
|
|
1574
|
+
const permissionMessage = await sendThreadMessage(
|
|
1575
|
+
thread,
|
|
1576
|
+
`⚠️ **Permission Required**\n\n` +
|
|
1577
|
+
`**Type:** \`${permission.type}\`\n` +
|
|
1578
|
+
`**Action:** ${permission.title}\n` +
|
|
1579
|
+
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
1580
|
+
`\nUse \`/accept\` or \`/reject\` to respond.`,
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
pendingPermissions.set(thread.id, {
|
|
1584
|
+
permission,
|
|
1585
|
+
messageId: permissionMessage.id,
|
|
1586
|
+
directory,
|
|
1587
|
+
})
|
|
1588
|
+
} else if (event.type === 'permission.replied') {
|
|
1589
|
+
const { permissionID, response, sessionID } = event.properties
|
|
1590
|
+
if (sessionID !== session.id) {
|
|
1591
|
+
continue
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
sessionLogger.log(
|
|
1595
|
+
`Permission ${permissionID} replied with: ${response}`,
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
const pending = pendingPermissions.get(thread.id)
|
|
1599
|
+
if (pending && pending.permission.id === permissionID) {
|
|
1600
|
+
pendingPermissions.delete(thread.id)
|
|
1601
|
+
}
|
|
1550
1602
|
} else if (event.type === 'file.edited') {
|
|
1551
1603
|
sessionLogger.log(`File edited event received`)
|
|
1552
1604
|
} else {
|
|
@@ -2599,6 +2651,128 @@ export async function startDiscordBot({
|
|
|
2599
2651
|
`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2600
2652
|
)
|
|
2601
2653
|
}
|
|
2654
|
+
} else if (
|
|
2655
|
+
command.commandName === 'accept' ||
|
|
2656
|
+
command.commandName === 'accept-always'
|
|
2657
|
+
) {
|
|
2658
|
+
const scope = command.commandName === 'accept-always' ? 'always' : 'once'
|
|
2659
|
+
const channel = command.channel
|
|
2660
|
+
|
|
2661
|
+
if (!channel) {
|
|
2662
|
+
await command.reply({
|
|
2663
|
+
content: 'This command can only be used in a channel',
|
|
2664
|
+
ephemeral: true,
|
|
2665
|
+
})
|
|
2666
|
+
return
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
const isThread = [
|
|
2670
|
+
ChannelType.PublicThread,
|
|
2671
|
+
ChannelType.PrivateThread,
|
|
2672
|
+
ChannelType.AnnouncementThread,
|
|
2673
|
+
].includes(channel.type)
|
|
2674
|
+
|
|
2675
|
+
if (!isThread) {
|
|
2676
|
+
await command.reply({
|
|
2677
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2678
|
+
ephemeral: true,
|
|
2679
|
+
})
|
|
2680
|
+
return
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const pending = pendingPermissions.get(channel.id)
|
|
2684
|
+
if (!pending) {
|
|
2685
|
+
await command.reply({
|
|
2686
|
+
content: 'No pending permission request in this thread',
|
|
2687
|
+
ephemeral: true,
|
|
2688
|
+
})
|
|
2689
|
+
return
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
try {
|
|
2693
|
+
const getClient = await initializeOpencodeForDirectory(pending.directory)
|
|
2694
|
+
await getClient().postSessionIdPermissionsPermissionId({
|
|
2695
|
+
path: {
|
|
2696
|
+
id: pending.permission.sessionID,
|
|
2697
|
+
permissionID: pending.permission.id,
|
|
2698
|
+
},
|
|
2699
|
+
body: {
|
|
2700
|
+
response: scope,
|
|
2701
|
+
},
|
|
2702
|
+
})
|
|
2703
|
+
|
|
2704
|
+
pendingPermissions.delete(channel.id)
|
|
2705
|
+
const msg =
|
|
2706
|
+
scope === 'always'
|
|
2707
|
+
? `✅ Permission **accepted** (auto-approve similar requests)`
|
|
2708
|
+
: `✅ Permission **accepted**`
|
|
2709
|
+
await command.reply(msg)
|
|
2710
|
+
sessionLogger.log(
|
|
2711
|
+
`Permission ${pending.permission.id} accepted with scope: ${scope}`,
|
|
2712
|
+
)
|
|
2713
|
+
} catch (error) {
|
|
2714
|
+
voiceLogger.error('[ACCEPT] Error:', error)
|
|
2715
|
+
await command.reply({
|
|
2716
|
+
content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2717
|
+
ephemeral: true,
|
|
2718
|
+
})
|
|
2719
|
+
}
|
|
2720
|
+
} else if (command.commandName === 'reject') {
|
|
2721
|
+
const channel = command.channel
|
|
2722
|
+
|
|
2723
|
+
if (!channel) {
|
|
2724
|
+
await command.reply({
|
|
2725
|
+
content: 'This command can only be used in a channel',
|
|
2726
|
+
ephemeral: true,
|
|
2727
|
+
})
|
|
2728
|
+
return
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
const isThread = [
|
|
2732
|
+
ChannelType.PublicThread,
|
|
2733
|
+
ChannelType.PrivateThread,
|
|
2734
|
+
ChannelType.AnnouncementThread,
|
|
2735
|
+
].includes(channel.type)
|
|
2736
|
+
|
|
2737
|
+
if (!isThread) {
|
|
2738
|
+
await command.reply({
|
|
2739
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2740
|
+
ephemeral: true,
|
|
2741
|
+
})
|
|
2742
|
+
return
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
const pending = pendingPermissions.get(channel.id)
|
|
2746
|
+
if (!pending) {
|
|
2747
|
+
await command.reply({
|
|
2748
|
+
content: 'No pending permission request in this thread',
|
|
2749
|
+
ephemeral: true,
|
|
2750
|
+
})
|
|
2751
|
+
return
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
try {
|
|
2755
|
+
const getClient = await initializeOpencodeForDirectory(pending.directory)
|
|
2756
|
+
await getClient().postSessionIdPermissionsPermissionId({
|
|
2757
|
+
path: {
|
|
2758
|
+
id: pending.permission.sessionID,
|
|
2759
|
+
permissionID: pending.permission.id,
|
|
2760
|
+
},
|
|
2761
|
+
body: {
|
|
2762
|
+
response: 'reject',
|
|
2763
|
+
},
|
|
2764
|
+
})
|
|
2765
|
+
|
|
2766
|
+
pendingPermissions.delete(channel.id)
|
|
2767
|
+
await command.reply(`❌ Permission **rejected**`)
|
|
2768
|
+
sessionLogger.log(`Permission ${pending.permission.id} rejected`)
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
voiceLogger.error('[REJECT] Error:', error)
|
|
2771
|
+
await command.reply({
|
|
2772
|
+
content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2773
|
+
ephemeral: true,
|
|
2774
|
+
})
|
|
2775
|
+
}
|
|
2602
2776
|
}
|
|
2603
2777
|
}
|
|
2604
2778
|
} catch (error) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kimaki Discord Plugin for OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Adds /send-to-kimaki-discord command that sends the current session to Discord.
|
|
5
|
+
*
|
|
6
|
+
* Installation:
|
|
7
|
+
* kimaki install-plugin
|
|
8
|
+
*
|
|
9
|
+
* Use in OpenCode TUI:
|
|
10
|
+
* /send-to-kimaki-discord
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
14
|
+
|
|
15
|
+
export const KimakiDiscordPlugin: Plugin = async ({
|
|
16
|
+
client,
|
|
17
|
+
$,
|
|
18
|
+
directory,
|
|
19
|
+
}) => {
|
|
20
|
+
return {
|
|
21
|
+
event: async ({ event }) => {
|
|
22
|
+
if (event.type !== 'command.executed') {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { name, sessionID } = event.properties as {
|
|
27
|
+
name: string
|
|
28
|
+
sessionID: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (name !== 'send-to-kimaki-discord') {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!sessionID) {
|
|
36
|
+
await client.tui.showToast({
|
|
37
|
+
body: { message: 'No session ID available', variant: 'error' },
|
|
38
|
+
})
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result =
|
|
44
|
+
await $`npx -y kimaki send-to-discord ${sessionID} -d ${directory}`.text()
|
|
45
|
+
|
|
46
|
+
const urlMatch = result.match(/https:\/\/discord\.com\/channels\/\S+/)
|
|
47
|
+
const url = urlMatch ? urlMatch[0] : null
|
|
48
|
+
|
|
49
|
+
await client.tui.showToast({
|
|
50
|
+
body: {
|
|
51
|
+
message: url ? `Sent to Discord: ${url}` : 'Session sent to Discord',
|
|
52
|
+
variant: 'success',
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
const message =
|
|
57
|
+
error.stderr?.toString().trim() ||
|
|
58
|
+
error.stdout?.toString().trim() ||
|
|
59
|
+
error.message ||
|
|
60
|
+
String(error)
|
|
61
|
+
|
|
62
|
+
await client.tui.showToast({
|
|
63
|
+
body: {
|
|
64
|
+
message: `Failed: ${message.slice(0, 100)}`,
|
|
65
|
+
variant: 'error',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|