kimaki 0.10.1 → 0.11.0
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/cli-commands/misc.js +76 -2
- package/dist/cli-runner.js +10 -29
- package/dist/cli.js +5 -0
- package/dist/commands/agent.js +116 -2
- package/dist/commands/gemini-apikey.js +24 -5
- package/dist/database.js +16 -0
- package/dist/discord-command-registration.js +9 -46
- package/dist/discord-utils.js +29 -0
- package/dist/discord-utils.test.js +68 -2
- package/dist/errors.js +5 -0
- package/dist/interaction-handler.js +78 -1
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
- package/dist/opencode.js +162 -30
- package/dist/session-handler/thread-session-runtime.js +27 -3
- package/dist/store.js +1 -0
- package/dist/system-message.js +16 -0
- package/dist/voice-handler.js +7 -11
- package/dist/voice.js +126 -1
- package/package.json +5 -6
- package/skills/goke/SKILL.md +192 -0
- package/skills/new-skill/SKILL.md +1 -0
- package/skills/npm-package/SKILL.md +45 -0
- package/skills/spiceflow/SKILL.md +2 -0
- package/skills/tuistory/SKILL.md +48 -16
- package/src/cli-commands/misc.ts +90 -2
- package/src/cli-runner.ts +10 -29
- package/src/cli.ts +12 -0
- package/src/commands/agent.ts +147 -1
- package/src/commands/gemini-apikey.ts +38 -6
- package/src/database.ts +16 -0
- package/src/discord-command-registration.ts +11 -71
- package/src/discord-utils.test.ts +82 -2
- package/src/discord-utils.ts +34 -0
- package/src/errors.ts +9 -0
- package/src/interaction-handler.ts +78 -1
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
- package/src/opencode.ts +157 -32
- package/src/session-handler/thread-session-runtime.ts +32 -3
- package/src/store.ts +8 -0
- package/src/system-message.ts +16 -0
- package/src/voice-handler.ts +5 -16
- package/src/voice.ts +217 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// File upload terminal
|
|
1
|
+
// File upload and TTS terminal commands for sharing local files into Discord threads.
|
|
2
2
|
import { goke } from 'goke';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { note } from '@clack/prompts';
|
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
import { spawn, execSync } from 'node:child_process';
|
|
12
12
|
import { createLogger, LogPrefix, initLogFile } from '../logger.js';
|
|
13
13
|
import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
|
|
14
|
-
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js';
|
|
14
|
+
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getAnyAudioApiKey } from '../database.js';
|
|
15
15
|
import { ShareMarkdown } from '../markdown.js';
|
|
16
16
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
|
|
17
17
|
import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
|
|
@@ -73,4 +73,78 @@ cli
|
|
|
73
73
|
process.exit(EXIT_NO_RESTART);
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
|
+
cli
|
|
77
|
+
.command('tts [text]', 'Generate speech audio from text. Reads from stdin if no text given.')
|
|
78
|
+
.option('-o, --output [path]', 'Output file path (default: speech.mp3)')
|
|
79
|
+
.option('-v, --voice [voice]', 'Voice ID, defaults to alloy (OpenAI) or Kore (Gemini)')
|
|
80
|
+
.option('-i, --instructions [text]', 'Style instructions, e.g. "Speak calmly" (OpenAI only)')
|
|
81
|
+
.option('--speed [speed]', 'Speed multiplier 0.25-4.0 (OpenAI only)')
|
|
82
|
+
.option('-p, --provider [provider]', 'openai or gemini (auto-detected from key)')
|
|
83
|
+
.option('-k, --api-key [key]', 'API key (falls back to stored key or env var)')
|
|
84
|
+
.action(async (text, options) => {
|
|
85
|
+
const { generateSpeech } = await import('../voice.js');
|
|
86
|
+
// Read from stdin if no positional text argument
|
|
87
|
+
if (!text) {
|
|
88
|
+
if (process.stdin.isTTY) {
|
|
89
|
+
cliLogger.error('No text provided. Pass text as argument or pipe via stdin.');
|
|
90
|
+
process.exit(EXIT_NO_RESTART);
|
|
91
|
+
}
|
|
92
|
+
const chunks = [];
|
|
93
|
+
for await (const chunk of process.stdin) {
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
}
|
|
96
|
+
text = Buffer.concat(chunks).toString('utf-8').trim();
|
|
97
|
+
if (!text) {
|
|
98
|
+
cliLogger.error('Empty input from stdin.');
|
|
99
|
+
process.exit(EXIT_NO_RESTART);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await initDatabase();
|
|
103
|
+
// Resolve API key: flag → DB → env
|
|
104
|
+
let apiKey = options.apiKey;
|
|
105
|
+
let provider = options.provider === 'openai' || options.provider === 'gemini'
|
|
106
|
+
? options.provider
|
|
107
|
+
: undefined;
|
|
108
|
+
if (!apiKey) {
|
|
109
|
+
const stored = await getAnyAudioApiKey();
|
|
110
|
+
if (stored) {
|
|
111
|
+
apiKey = stored.apiKey;
|
|
112
|
+
provider = provider || stored.provider;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!apiKey) {
|
|
116
|
+
if (process.env.OPENAI_API_KEY) {
|
|
117
|
+
apiKey = process.env.OPENAI_API_KEY;
|
|
118
|
+
provider = provider || 'openai';
|
|
119
|
+
}
|
|
120
|
+
else if (process.env.GEMINI_API_KEY) {
|
|
121
|
+
apiKey = process.env.GEMINI_API_KEY;
|
|
122
|
+
provider = provider || 'gemini';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
cliLogger.error('No API key found. Use --api-key, set OPENAI_API_KEY/GEMINI_API_KEY, or run /transcription-key in Discord.');
|
|
127
|
+
process.exit(EXIT_NO_RESTART);
|
|
128
|
+
}
|
|
129
|
+
const speed = options.speed ? Number(options.speed) : 1.25;
|
|
130
|
+
cliLogger.log(`Generating speech with ${provider || 'auto-detected provider'}...`);
|
|
131
|
+
const result = await generateSpeech({
|
|
132
|
+
text,
|
|
133
|
+
voice: options.voice || undefined,
|
|
134
|
+
apiKey,
|
|
135
|
+
provider,
|
|
136
|
+
instructions: options.instructions || undefined,
|
|
137
|
+
speed,
|
|
138
|
+
});
|
|
139
|
+
if (result instanceof Error) {
|
|
140
|
+
cliLogger.error(`Speech generation failed: ${result.message}`);
|
|
141
|
+
process.exit(EXIT_NO_RESTART);
|
|
142
|
+
}
|
|
143
|
+
const ext = result.mediaType === 'audio/mp3' ? 'mp3' : 'wav';
|
|
144
|
+
const outputPath = options.output || `speech.${ext}`;
|
|
145
|
+
const resolvedOutput = path.resolve(outputPath);
|
|
146
|
+
await fs.promises.writeFile(resolvedOutput, result.audio);
|
|
147
|
+
cliLogger.log(`Audio saved to ${resolvedOutput} (${(result.audio.length / 1024).toFixed(1)} KB)`);
|
|
148
|
+
process.exit(0);
|
|
149
|
+
});
|
|
76
150
|
export default cli;
|
package/dist/cli-runner.js
CHANGED
|
@@ -938,35 +938,16 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
938
938
|
startCaffeinate();
|
|
939
939
|
const forceRestartOnboarding = Boolean(restartOnboarding);
|
|
940
940
|
const forceGateway = Boolean(gateway);
|
|
941
|
-
// Step 0: Ensure
|
|
942
|
-
//
|
|
943
|
-
await
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
'~/.opencode/bin/opencode',
|
|
952
|
-
'/usr/local/bin/opencode',
|
|
953
|
-
'/opt/opencode/bin/opencode',
|
|
954
|
-
],
|
|
955
|
-
possiblePathsWindows: [
|
|
956
|
-
'~\\.local\\bin\\opencode.exe',
|
|
957
|
-
'~\\AppData\\Local\\opencode\\opencode.exe',
|
|
958
|
-
'~\\.opencode\\bin\\opencode.exe',
|
|
959
|
-
],
|
|
960
|
-
}),
|
|
961
|
-
ensureCommandAvailable({
|
|
962
|
-
name: 'bun',
|
|
963
|
-
envPathKey: 'BUN_PATH',
|
|
964
|
-
installUnix: 'curl -fsSL https://bun.sh/install | bash',
|
|
965
|
-
installWindows: 'irm bun.sh/install.ps1 | iex',
|
|
966
|
-
possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
|
|
967
|
-
possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
|
|
968
|
-
}),
|
|
969
|
-
]);
|
|
941
|
+
// Step 0: Ensure bun is installed. OpenCode is downloaded on first run
|
|
942
|
+
// to ~/.kimaki/bin/ (pinned version), so no install check is needed.
|
|
943
|
+
await ensureCommandAvailable({
|
|
944
|
+
name: 'bun',
|
|
945
|
+
envPathKey: 'BUN_PATH',
|
|
946
|
+
installUnix: 'curl -fsSL https://bun.sh/install | bash',
|
|
947
|
+
installWindows: 'irm bun.sh/install.ps1 | iex',
|
|
948
|
+
possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
|
|
949
|
+
possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
|
|
950
|
+
});
|
|
970
951
|
void backgroundUpgradeKimaki();
|
|
971
952
|
// Start in-process Hrana server before database init. Required for the bot
|
|
972
953
|
// process because it serves as both the DB server and the single-instance
|
package/dist/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ cli
|
|
|
39
39
|
.option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
|
|
40
40
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
41
41
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
42
|
+
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
42
43
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
43
44
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
44
45
|
.option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
|
|
@@ -134,6 +135,7 @@ cli
|
|
|
134
135
|
}),
|
|
135
136
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
136
137
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
138
|
+
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
137
139
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
138
140
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
139
141
|
});
|
|
@@ -143,6 +145,9 @@ cli
|
|
|
143
145
|
if (disabledSkills.length > 0) {
|
|
144
146
|
cliLogger.log(`Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`);
|
|
145
147
|
}
|
|
148
|
+
if (options.allowAllUsers) {
|
|
149
|
+
cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
|
|
150
|
+
}
|
|
146
151
|
if (options.verbosity) {
|
|
147
152
|
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
148
153
|
}
|
package/dist/commands/agent.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// /agent command - Set the preferred agent for this channel or session.
|
|
2
2
|
// Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
|
|
3
|
-
|
|
3
|
+
// When a prompt is provided to a quick agent command (e.g. /plan-agent "fix the bug"),
|
|
4
|
+
// the prompt is sent as a one-shot with that agent without switching the persistent preference.
|
|
5
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
|
|
4
6
|
import crypto from 'node:crypto';
|
|
5
7
|
import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
|
|
6
8
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
|
-
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
9
|
+
import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
10
|
+
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
8
11
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
12
|
import { getCurrentModelInfo } from './model.js';
|
|
10
13
|
const agentLogger = createLogger(LogPrefix.AGENT);
|
|
@@ -286,6 +289,13 @@ export async function handleAgentSelectMenu(interaction) {
|
|
|
286
289
|
*/
|
|
287
290
|
export async function handleQuickAgentCommand({ command, appId, }) {
|
|
288
291
|
const fallbackAgentName = command.commandName.replace(/-agent$/, '');
|
|
292
|
+
const prompt = command.options.getString('prompt') || undefined;
|
|
293
|
+
// One-shot prompt mode: send the prompt with a temporary agent override
|
|
294
|
+
// without changing the persistent agent preference.
|
|
295
|
+
if (prompt) {
|
|
296
|
+
return handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt });
|
|
297
|
+
}
|
|
298
|
+
// No prompt: switch the persistent agent preference (original behavior).
|
|
289
299
|
await command.deferReply({ flags: MessageFlags.Ephemeral });
|
|
290
300
|
const context = await resolveAgentCommandContext({
|
|
291
301
|
interaction: command,
|
|
@@ -352,3 +362,107 @@ export async function handleQuickAgentCommand({ command, appId, }) {
|
|
|
352
362
|
});
|
|
353
363
|
}
|
|
354
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* Handle one-shot prompt mode: send a prompt with a temporary agent override.
|
|
367
|
+
* In a thread: enqueue the prompt with the agent override on the existing session.
|
|
368
|
+
* In a channel: create a new thread and enqueue the prompt with the agent override.
|
|
369
|
+
* Neither case changes the persistent agent preference.
|
|
370
|
+
*/
|
|
371
|
+
async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, prompt, }) {
|
|
372
|
+
const channel = command.channel;
|
|
373
|
+
if (!channel) {
|
|
374
|
+
await command.reply({
|
|
375
|
+
content: 'This command can only be used in a channel',
|
|
376
|
+
flags: MessageFlags.Ephemeral,
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const resolvedAgentName = (await resolveQuickAgentNameFromInteraction({ command })) ||
|
|
381
|
+
fallbackAgentName;
|
|
382
|
+
const isThread = [
|
|
383
|
+
ChannelType.PublicThread,
|
|
384
|
+
ChannelType.PrivateThread,
|
|
385
|
+
ChannelType.AnnouncementThread,
|
|
386
|
+
].includes(channel.type);
|
|
387
|
+
const displayText = `${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
|
|
388
|
+
if (isThread) {
|
|
389
|
+
// In a thread: enqueue the prompt on the existing session with agent override.
|
|
390
|
+
const thread = channel;
|
|
391
|
+
const resolved = await resolveWorkingDirectory({ channel: thread });
|
|
392
|
+
if (!resolved) {
|
|
393
|
+
await command.reply({
|
|
394
|
+
content: 'Could not determine project directory for this channel',
|
|
395
|
+
flags: MessageFlags.Ephemeral,
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const runtime = getOrCreateRuntime({
|
|
400
|
+
threadId: thread.id,
|
|
401
|
+
thread,
|
|
402
|
+
projectDirectory: resolved.projectDirectory,
|
|
403
|
+
sdkDirectory: resolved.workingDirectory,
|
|
404
|
+
channelId: thread.parentId || thread.id,
|
|
405
|
+
appId,
|
|
406
|
+
});
|
|
407
|
+
// Visible reply showing the one-shot prompt (not ephemeral, so it appears in thread).
|
|
408
|
+
await command.reply({
|
|
409
|
+
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
410
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
411
|
+
});
|
|
412
|
+
await runtime.enqueueIncoming({
|
|
413
|
+
prompt,
|
|
414
|
+
userId: command.user.id,
|
|
415
|
+
username: command.user.displayName,
|
|
416
|
+
agent: resolvedAgentName,
|
|
417
|
+
appId,
|
|
418
|
+
mode: 'opencode',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
else if (channel.type === ChannelType.GuildText) {
|
|
422
|
+
// In a channel: create a new thread and enqueue with the agent override.
|
|
423
|
+
const textChannel = channel;
|
|
424
|
+
const metadata = await getKimakiMetadata(textChannel);
|
|
425
|
+
const projectDirectory = metadata.projectDirectory;
|
|
426
|
+
if (!projectDirectory) {
|
|
427
|
+
await command.reply({
|
|
428
|
+
content: 'This channel is not configured with a project directory',
|
|
429
|
+
flags: MessageFlags.Ephemeral,
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
await command.deferReply();
|
|
434
|
+
const starterMessage = await textChannel.send({
|
|
435
|
+
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
436
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
437
|
+
});
|
|
438
|
+
const thread = await starterMessage.startThread({
|
|
439
|
+
name: prompt.slice(0, 80),
|
|
440
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
441
|
+
reason: `One-shot ${resolvedAgentName} agent prompt`,
|
|
442
|
+
});
|
|
443
|
+
await thread.members.add(command.user.id);
|
|
444
|
+
await command.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`);
|
|
445
|
+
const runtime = getOrCreateRuntime({
|
|
446
|
+
threadId: thread.id,
|
|
447
|
+
thread,
|
|
448
|
+
projectDirectory,
|
|
449
|
+
sdkDirectory: projectDirectory,
|
|
450
|
+
channelId: textChannel.id,
|
|
451
|
+
appId,
|
|
452
|
+
});
|
|
453
|
+
await runtime.enqueueIncoming({
|
|
454
|
+
prompt,
|
|
455
|
+
userId: command.user.id,
|
|
456
|
+
username: command.user.displayName,
|
|
457
|
+
agent: resolvedAgentName,
|
|
458
|
+
appId,
|
|
459
|
+
mode: 'opencode',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
await command.reply({
|
|
464
|
+
content: 'This command can only be used in text channels or threads',
|
|
465
|
+
flags: MessageFlags.Ephemeral,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Audio API key button, slash command, and modal handlers.
|
|
2
|
+
// Used for both transcription and speech generation — same OpenAI/Gemini keys.
|
|
2
3
|
// Auto-detects provider from key prefix: sk-* = OpenAI, otherwise Gemini.
|
|
3
|
-
import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags, } from 'discord.js';
|
|
4
|
+
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags, } from 'discord.js';
|
|
4
5
|
import { setGeminiApiKey, setOpenAIApiKey } from '../database.js';
|
|
6
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
5
7
|
function buildTranscriptionApiKeyModal(appId) {
|
|
6
8
|
const modal = new ModalBuilder()
|
|
7
9
|
.setCustomId(`transcription_apikey_modal:${appId}`)
|
|
8
|
-
.setTitle('
|
|
10
|
+
.setTitle('Audio API Key');
|
|
9
11
|
const apiKeyInput = new TextInputBuilder()
|
|
10
12
|
.setCustomId('apikey')
|
|
11
13
|
.setLabel('OpenAI or Gemini API Key')
|
|
@@ -16,6 +18,23 @@ function buildTranscriptionApiKeyModal(appId) {
|
|
|
16
18
|
modal.addComponents(actionRow);
|
|
17
19
|
return modal;
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Show a "Set API Key" button in a Discord thread.
|
|
23
|
+
* Reusable for both transcription and TTS — both use the same stored keys.
|
|
24
|
+
* The button opens a modal where the user can enter an OpenAI or Gemini key.
|
|
25
|
+
*/
|
|
26
|
+
export async function showApiKeyRequiredButton({ thread, appId, message, }) {
|
|
27
|
+
const button = new ButtonBuilder()
|
|
28
|
+
.setCustomId(`transcription_apikey:${appId}`)
|
|
29
|
+
.setLabel('Set API Key')
|
|
30
|
+
.setStyle(ButtonStyle.Primary);
|
|
31
|
+
const row = new ActionRowBuilder().addComponents(button);
|
|
32
|
+
await thread.send({
|
|
33
|
+
content: message || 'An API key (OpenAI or Gemini) is required. Set one to continue.',
|
|
34
|
+
components: [row],
|
|
35
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
19
38
|
export async function handleTranscriptionApiKeyButton(interaction) {
|
|
20
39
|
if (!interaction.customId.startsWith('transcription_apikey:'))
|
|
21
40
|
return;
|
|
@@ -58,13 +77,13 @@ export async function handleTranscriptionApiKeyModalSubmit(interaction) {
|
|
|
58
77
|
if (apiKey.startsWith('sk-')) {
|
|
59
78
|
await setOpenAIApiKey(appId, apiKey);
|
|
60
79
|
await interaction.editReply({
|
|
61
|
-
content: 'OpenAI API key saved. Voice
|
|
80
|
+
content: 'OpenAI API key saved. Voice transcription and speech generation are now enabled.',
|
|
62
81
|
});
|
|
63
82
|
}
|
|
64
83
|
else {
|
|
65
84
|
await setGeminiApiKey(appId, apiKey);
|
|
66
85
|
await interaction.editReply({
|
|
67
|
-
content: 'Gemini API key saved. Voice
|
|
86
|
+
content: 'Gemini API key saved. Voice transcription and speech generation are now enabled.',
|
|
68
87
|
});
|
|
69
88
|
}
|
|
70
89
|
}
|
package/dist/database.js
CHANGED
|
@@ -500,6 +500,22 @@ export async function getTranscriptionApiKey(appId) {
|
|
|
500
500
|
return { provider: 'gemini', apiKey: row.gemini_api_key };
|
|
501
501
|
return null;
|
|
502
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* Get any stored audio API key (OpenAI or Gemini) without requiring a specific appId.
|
|
505
|
+
* Used by the plugin process which doesn't have direct access to the bot's appId.
|
|
506
|
+
* Returns the first available key found, preferring OpenAI.
|
|
507
|
+
*/
|
|
508
|
+
export async function getAnyAudioApiKey() {
|
|
509
|
+
const db = await getDb();
|
|
510
|
+
const row = await db.query.bot_api_keys.findFirst();
|
|
511
|
+
if (!row)
|
|
512
|
+
return null;
|
|
513
|
+
if (row.openai_api_key)
|
|
514
|
+
return { provider: 'openai', apiKey: row.openai_api_key, appId: row.app_id };
|
|
515
|
+
if (row.gemini_api_key)
|
|
516
|
+
return { provider: 'gemini', apiKey: row.gemini_api_key, appId: row.app_id };
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
503
519
|
export async function setChannelDirectory({ channelId, directory, channelType, skipIfExists = false }) {
|
|
504
520
|
const db = await getDb();
|
|
505
521
|
if (skipIfExists) {
|
|
@@ -19,46 +19,13 @@ function getDiscordCommandSuffix(command) {
|
|
|
19
19
|
}
|
|
20
20
|
return '-cmd';
|
|
21
21
|
}
|
|
22
|
-
function
|
|
23
|
-
if (typeof value !== 'object' || value === null) {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
const id = Reflect.get(value, 'id');
|
|
27
|
-
const name = Reflect.get(value, 'name');
|
|
28
|
-
return typeof id === 'string' && typeof name === 'string';
|
|
29
|
-
}
|
|
30
|
-
async function deleteLegacyGlobalCommands({ rest, appId, commandNames, }) {
|
|
22
|
+
async function clearGlobalCommands({ rest, appId, }) {
|
|
31
23
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
cliLogger.warn('COMMANDS: Unexpected global command payload while cleaning legacy global commands');
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
const legacyGlobalCommands = response
|
|
38
|
-
.filter(isDiscordCommandSummary)
|
|
39
|
-
.filter((command) => {
|
|
40
|
-
return commandNames.has(command.name);
|
|
41
|
-
});
|
|
42
|
-
if (legacyGlobalCommands.length === 0) {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const deletionResults = await Promise.allSettled(legacyGlobalCommands.map(async (command) => {
|
|
46
|
-
await rest.delete(Routes.applicationCommand(appId, command.id));
|
|
47
|
-
return command;
|
|
48
|
-
}));
|
|
49
|
-
const failedDeletions = deletionResults.filter((result) => {
|
|
50
|
-
return result.status === 'rejected';
|
|
51
|
-
});
|
|
52
|
-
if (failedDeletions.length > 0) {
|
|
53
|
-
cliLogger.warn(`COMMANDS: Failed to delete ${failedDeletions.length} legacy global command(s)`);
|
|
54
|
-
}
|
|
55
|
-
const deletedCount = deletionResults.length - failedDeletions.length;
|
|
56
|
-
if (deletedCount > 0) {
|
|
57
|
-
cliLogger.info(`COMMANDS: Deleted ${deletedCount} legacy global command(s) to avoid guild/global duplicates`);
|
|
58
|
-
}
|
|
24
|
+
await rest.put(Routes.applicationCommands(appId), { body: [] });
|
|
25
|
+
cliLogger.info('COMMANDS: Cleared global slash commands');
|
|
59
26
|
}
|
|
60
27
|
catch (error) {
|
|
61
|
-
cliLogger.warn(`COMMANDS: Could not
|
|
28
|
+
cliLogger.warn(`COMMANDS: Could not clear global slash commands: ${error instanceof Error ? error.stack : String(error)}`);
|
|
62
29
|
}
|
|
63
30
|
}
|
|
64
31
|
// Discord slash command descriptions must be 1-100 chars.
|
|
@@ -426,6 +393,10 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
426
393
|
.setName(commandName)
|
|
427
394
|
.setDescription(truncateCommandDescription(description))
|
|
428
395
|
.setDMPermission(false)
|
|
396
|
+
.addStringOption((opt) => opt
|
|
397
|
+
.setName('prompt')
|
|
398
|
+
.setDescription('Send a one-shot prompt with this agent without switching')
|
|
399
|
+
.setRequired(false))
|
|
429
400
|
.toJSON());
|
|
430
401
|
}
|
|
431
402
|
// 2. User-defined commands, skills, and MCP prompts (ordered by priority)
|
|
@@ -488,13 +459,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
488
459
|
}
|
|
489
460
|
const rest = createDiscordRest(token);
|
|
490
461
|
const uniqueGuildIds = Array.from(new Set(guildIds.filter((guildId) => guildId)));
|
|
491
|
-
const guildCommandNames = new Set(commands
|
|
492
|
-
.map((command) => {
|
|
493
|
-
return command.name;
|
|
494
|
-
})
|
|
495
|
-
.filter((name) => {
|
|
496
|
-
return typeof name === 'string';
|
|
497
|
-
}));
|
|
498
462
|
if (uniqueGuildIds.length === 0) {
|
|
499
463
|
cliLogger.warn('COMMANDS: No guilds available, skipping slash command registration');
|
|
500
464
|
return;
|
|
@@ -543,10 +507,9 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
543
507
|
// exist for self-hosted bots that previously registered commands globally.
|
|
544
508
|
const isGateway = store.getState().discordBaseUrl !== 'https://discord.com';
|
|
545
509
|
if (!isGateway) {
|
|
546
|
-
await
|
|
510
|
+
await clearGlobalCommands({
|
|
547
511
|
rest,
|
|
548
512
|
appId,
|
|
549
|
-
commandNames: guildCommandNames,
|
|
550
513
|
});
|
|
551
514
|
}
|
|
552
515
|
cliLogger.info(`COMMANDS: Successfully registered ${registeredCommandCount} slash commands for ${successfulGuilds} guild(s)`);
|
package/dist/discord-utils.js
CHANGED
|
@@ -14,6 +14,7 @@ import { limitHeadingDepth } from './limit-heading-depth.js';
|
|
|
14
14
|
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
|
|
15
15
|
import { createLogger, LogPrefix } from './logger.js';
|
|
16
16
|
import * as errore from 'errore';
|
|
17
|
+
import { store } from './store.js';
|
|
17
18
|
import mime from 'mime';
|
|
18
19
|
import fs from 'node:fs';
|
|
19
20
|
import path from 'node:path';
|
|
@@ -32,6 +33,34 @@ export function hasKimakiBotPermission(member, guild) {
|
|
|
32
33
|
if (hasNoKimakiRole) {
|
|
33
34
|
return false;
|
|
34
35
|
}
|
|
36
|
+
if (store.getState().allowAllUsers) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const memberPermissions = member instanceof GuildMember
|
|
40
|
+
? member.permissions
|
|
41
|
+
: new PermissionsBitField(BigInt(member.permissions));
|
|
42
|
+
const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId;
|
|
43
|
+
const memberId = member instanceof GuildMember ? member.id : member.user.id;
|
|
44
|
+
const isOwner = ownerId ? memberId === ownerId : false;
|
|
45
|
+
const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator);
|
|
46
|
+
const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
47
|
+
const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
|
|
48
|
+
return isOwner || isAdmin || canManageServer || hasKimakiRole;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stricter permission check that ignores allowAllUsers.
|
|
52
|
+
* Use for admin-only commands like /login and /transcription-key that
|
|
53
|
+
* configure shared credentials. Always requires owner, admin, manage
|
|
54
|
+
* server, or Kimaki role regardless of --allow-all-users flag.
|
|
55
|
+
*/
|
|
56
|
+
export function hasKimakiAdminPermission(member, guild) {
|
|
57
|
+
if (!member) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const hasNoKimaki = hasRoleByName(member, 'no-kimaki', guild);
|
|
61
|
+
if (hasNoKimaki) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
35
64
|
const memberPermissions = member instanceof GuildMember
|
|
36
65
|
? member.permissions
|
|
37
66
|
: new PermissionsBitField(BigInt(member.permissions));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PermissionsBitField } from 'discord.js';
|
|
2
|
-
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import { hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
|
|
2
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
3
|
+
import { hasKimakiAdminPermission, hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
|
|
4
|
+
import { store } from './store.js';
|
|
4
5
|
describe('splitMarkdownForDiscord', () => {
|
|
5
6
|
test('never returns chunks over the max length with code fences', () => {
|
|
6
7
|
const maxLength = 2000;
|
|
@@ -90,6 +91,40 @@ describe('splitMarkdownForDiscord', () => {
|
|
|
90
91
|
});
|
|
91
92
|
});
|
|
92
93
|
describe('hasKimakiBotPermission', () => {
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
store.setState({ allowAllUsers: false });
|
|
96
|
+
});
|
|
97
|
+
test('allows any member when allowAllUsers is enabled', () => {
|
|
98
|
+
store.setState({ allowAllUsers: true });
|
|
99
|
+
const guild = {
|
|
100
|
+
ownerId: 'owner-id',
|
|
101
|
+
roles: { cache: new Map() },
|
|
102
|
+
};
|
|
103
|
+
const member = {
|
|
104
|
+
user: { id: 'member-id' },
|
|
105
|
+
permissions: '0',
|
|
106
|
+
roles: [],
|
|
107
|
+
};
|
|
108
|
+
expect(hasKimakiBotPermission(member, guild)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
test('still blocks no-kimaki role even when allowAllUsers is enabled', () => {
|
|
111
|
+
store.setState({ allowAllUsers: true });
|
|
112
|
+
const noKimakiRoleId = '222';
|
|
113
|
+
const guild = {
|
|
114
|
+
ownerId: 'owner-id',
|
|
115
|
+
roles: {
|
|
116
|
+
cache: new Map([
|
|
117
|
+
[noKimakiRoleId, { id: noKimakiRoleId, name: 'no-kimaki' }],
|
|
118
|
+
]),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const member = {
|
|
122
|
+
user: { id: 'member-id' },
|
|
123
|
+
permissions: '0',
|
|
124
|
+
roles: [noKimakiRoleId],
|
|
125
|
+
};
|
|
126
|
+
expect(hasKimakiBotPermission(member, guild)).toBe(false);
|
|
127
|
+
});
|
|
93
128
|
test('allows API interaction member when kimaki role exists', () => {
|
|
94
129
|
const kimakiRoleId = '111';
|
|
95
130
|
const guild = {
|
|
@@ -132,3 +167,34 @@ describe('hasKimakiBotPermission', () => {
|
|
|
132
167
|
expect(hasKimakiBotPermission(member, guild)).toBe(false);
|
|
133
168
|
});
|
|
134
169
|
});
|
|
170
|
+
describe('hasKimakiAdminPermission', () => {
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
store.setState({ allowAllUsers: false });
|
|
173
|
+
});
|
|
174
|
+
test('denies unprivileged member even when allowAllUsers is enabled', () => {
|
|
175
|
+
store.setState({ allowAllUsers: true });
|
|
176
|
+
const guild = {
|
|
177
|
+
ownerId: 'owner-id',
|
|
178
|
+
roles: { cache: new Map() },
|
|
179
|
+
};
|
|
180
|
+
const member = {
|
|
181
|
+
user: { id: 'member-id' },
|
|
182
|
+
permissions: '0',
|
|
183
|
+
roles: [],
|
|
184
|
+
};
|
|
185
|
+
expect(hasKimakiAdminPermission(member, guild)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
test('allows admin even when allowAllUsers is enabled', () => {
|
|
188
|
+
store.setState({ allowAllUsers: true });
|
|
189
|
+
const guild = {
|
|
190
|
+
ownerId: 'owner-id',
|
|
191
|
+
roles: { cache: new Map() },
|
|
192
|
+
};
|
|
193
|
+
const member = {
|
|
194
|
+
user: { id: 'member-id' },
|
|
195
|
+
permissions: PermissionsBitField.Flags.Administrator.toString(),
|
|
196
|
+
roles: [],
|
|
197
|
+
};
|
|
198
|
+
expect(hasKimakiAdminPermission(member, guild)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
});
|
package/dist/errors.js
CHANGED
|
@@ -58,6 +58,11 @@ export class TranscriptionError extends createTaggedError({
|
|
|
58
58
|
message: 'Transcription failed: $reason',
|
|
59
59
|
}) {
|
|
60
60
|
}
|
|
61
|
+
export class SpeechGenerationError extends createTaggedError({
|
|
62
|
+
name: 'SpeechGenerationError',
|
|
63
|
+
message: 'Speech generation failed: $reason',
|
|
64
|
+
}) {
|
|
65
|
+
}
|
|
61
66
|
export class GrepSearchError extends createTaggedError({
|
|
62
67
|
name: 'GrepSearchError',
|
|
63
68
|
message: 'Grep search failed for pattern: $pattern',
|