kimaki 0.10.2 → 0.12.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/agent-model.e2e.test.js +91 -1
- package/dist/cli-commands/misc.js +76 -2
- package/dist/cli-runner.js +28 -10
- package/dist/cli.js +10 -0
- package/dist/commands/agent.js +116 -4
- package/dist/commands/gemini-apikey.js +24 -5
- package/dist/commands/mention-mode.js +0 -1
- package/dist/commands/model-variant.js +2 -2
- package/dist/commands/model.js +47 -27
- package/dist/commands/unset-model.js +2 -2
- package/dist/commands/verbosity.js +0 -1
- package/dist/commands/worktree-settings.js +0 -1
- package/dist/database.js +16 -0
- package/dist/discord-bot.js +10 -5
- package/dist/discord-command-registration.js +9 -46
- package/dist/discord-utils.js +43 -0
- package/dist/discord-utils.test.js +118 -2
- package/dist/errors.js +5 -0
- package/dist/external-opencode-sync.js +119 -54
- package/dist/interaction-handler.js +82 -1
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
- package/dist/message-formatting.js +91 -0
- package/dist/message-formatting.test.js +206 -1
- package/dist/opencode.js +34 -158
- package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
- package/dist/session-handler/thread-session-runtime.js +19 -4
- package/dist/store.js +2 -0
- package/dist/system-message.js +16 -0
- package/dist/system-message.test.js +16 -0
- package/dist/voice-handler.js +98 -79
- package/dist/voice.js +126 -1
- package/package.json +6 -6
- package/skills/goke/SKILL.md +39 -0
- package/skills/new-skill/SKILL.md +1 -0
- package/skills/npm-package/SKILL.md +57 -2
- package/skills/spiceflow/SKILL.md +2 -0
- package/skills/termcast/SKILL.md +32 -846
- package/skills/tuistory/SKILL.md +117 -17
- package/src/agent-model.e2e.test.ts +117 -0
- package/src/cli-commands/misc.ts +90 -2
- package/src/cli-runner.ts +28 -10
- package/src/cli.ts +23 -0
- package/src/commands/agent.ts +147 -4
- package/src/commands/gemini-apikey.ts +38 -6
- package/src/commands/mention-mode.ts +0 -1
- package/src/commands/model-variant.ts +2 -2
- package/src/commands/model.ts +63 -37
- package/src/commands/unset-model.ts +1 -2
- package/src/commands/verbosity.ts +0 -1
- package/src/commands/worktree-settings.ts +0 -1
- package/src/database.ts +16 -0
- package/src/discord-bot.ts +11 -4
- package/src/discord-command-registration.ts +11 -71
- package/src/discord-utils.test.ts +144 -3
- package/src/discord-utils.ts +53 -0
- package/src/errors.ts +9 -0
- package/src/external-opencode-sync.ts +147 -64
- package/src/interaction-handler.ts +83 -1
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
- package/src/message-formatting.test.ts +247 -1
- package/src/message-formatting.ts +93 -1
- package/src/opencode.ts +36 -152
- package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
- package/src/session-handler/thread-session-runtime.ts +22 -3
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +16 -0
- package/src/system-message.ts +16 -0
- package/src/voice-handler.ts +111 -94
- package/src/voice.ts +217 -0
package/dist/commands/model.js
CHANGED
|
@@ -11,6 +11,20 @@ import { createLogger, LogPrefix } from '../logger.js';
|
|
|
11
11
|
import * as errore from 'errore';
|
|
12
12
|
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
|
|
13
13
|
const modelLogger = createLogger(LogPrefix.MODEL);
|
|
14
|
+
function buildSafeSelectOption({ label, value, description, }) {
|
|
15
|
+
const trimmedLabel = label?.trim();
|
|
16
|
+
const trimmedValue = value?.trim();
|
|
17
|
+
const safeLabel = (trimmedLabel || trimmedValue || 'Unknown').slice(0, 100);
|
|
18
|
+
const safeValue = trimmedValue || trimmedLabel || '';
|
|
19
|
+
if (!safeLabel || !safeValue) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
label: safeLabel,
|
|
24
|
+
value: safeValue,
|
|
25
|
+
description: description?.slice(0, 100),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
14
28
|
// Store context by hash to avoid customId length limits (Discord max: 100 chars).
|
|
15
29
|
// Entries are TTL'd to prevent unbounded growth when users open /model and never
|
|
16
30
|
// interact with the select menu.
|
|
@@ -172,7 +186,7 @@ export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPr
|
|
|
172
186
|
export async function handleModelCommand({ interaction, appId, }) {
|
|
173
187
|
modelLogger.log('[MODEL] handleModelCommand called');
|
|
174
188
|
// Defer reply immediately to avoid 3-second timeout
|
|
175
|
-
await interaction.deferReply(
|
|
189
|
+
await interaction.deferReply();
|
|
176
190
|
modelLogger.log('[MODEL] Deferred reply');
|
|
177
191
|
const channel = interaction.channel;
|
|
178
192
|
if (!channel) {
|
|
@@ -307,15 +321,16 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
307
321
|
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
308
322
|
setModelContext(contextHash, context);
|
|
309
323
|
const allProviderOptions = [...availableProviders]
|
|
310
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
324
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
|
|
311
325
|
.map((provider) => {
|
|
312
326
|
const modelCount = Object.keys(provider.models || {}).length;
|
|
313
|
-
return {
|
|
314
|
-
label: provider.name
|
|
327
|
+
return buildSafeSelectOption({
|
|
328
|
+
label: provider.name,
|
|
315
329
|
value: provider.id,
|
|
316
|
-
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available
|
|
317
|
-
};
|
|
318
|
-
})
|
|
330
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
|
|
331
|
+
});
|
|
332
|
+
})
|
|
333
|
+
.filter((option) => !!option);
|
|
319
334
|
const { options } = buildPaginatedOptions({
|
|
320
335
|
allOptions: allProviderOptions,
|
|
321
336
|
page: 0,
|
|
@@ -383,15 +398,16 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
383
398
|
const { all: allProviders, connected } = providersResponse.data;
|
|
384
399
|
const availableProviders = allProviders.filter((p) => connected.includes(p.id));
|
|
385
400
|
const allProviderOptions = [...availableProviders]
|
|
386
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
401
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
|
|
387
402
|
.map((p) => {
|
|
388
403
|
const modelCount = Object.keys(p.models || {}).length;
|
|
389
|
-
return {
|
|
390
|
-
label: p.name
|
|
404
|
+
return buildSafeSelectOption({
|
|
405
|
+
label: p.name,
|
|
391
406
|
value: p.id,
|
|
392
|
-
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available
|
|
393
|
-
};
|
|
394
|
-
})
|
|
407
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
|
|
408
|
+
});
|
|
409
|
+
})
|
|
410
|
+
.filter((option) => !!option);
|
|
395
411
|
const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage });
|
|
396
412
|
const selectMenu = new StringSelectMenuBuilder()
|
|
397
413
|
.setCustomId(`model_provider:${contextHash}`)
|
|
@@ -434,10 +450,11 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
434
450
|
const models = Object.entries(provider.models || {})
|
|
435
451
|
.map(([modelId, model]) => ({
|
|
436
452
|
id: modelId,
|
|
437
|
-
name: model.name,
|
|
453
|
+
name: model.name || modelId,
|
|
438
454
|
releaseDate: model.release_date,
|
|
439
455
|
}))
|
|
440
|
-
.
|
|
456
|
+
.filter((model) => model.id && model.name)
|
|
457
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''));
|
|
441
458
|
if (models.length === 0) {
|
|
442
459
|
await interaction.editReply({
|
|
443
460
|
content: `No models available for ${provider.name}`,
|
|
@@ -447,19 +464,21 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
447
464
|
}
|
|
448
465
|
// Update context with provider info and reuse the same hash
|
|
449
466
|
context.providerId = selectedProviderId;
|
|
450
|
-
context.providerName = provider.name;
|
|
467
|
+
context.providerName = provider.name?.trim() || provider.id;
|
|
451
468
|
context.modelPage = 0;
|
|
452
469
|
setModelContext(contextHash, context);
|
|
453
|
-
const allModelOptions = models
|
|
470
|
+
const allModelOptions = models
|
|
471
|
+
.map((model) => {
|
|
454
472
|
const dateStr = model.releaseDate
|
|
455
473
|
? new Date(model.releaseDate).toLocaleDateString()
|
|
456
474
|
: 'Unknown date';
|
|
457
|
-
return {
|
|
458
|
-
label: model.name
|
|
475
|
+
return buildSafeSelectOption({
|
|
476
|
+
label: model.name,
|
|
459
477
|
value: model.id,
|
|
460
|
-
description: dateStr
|
|
461
|
-
};
|
|
462
|
-
})
|
|
478
|
+
description: dateStr,
|
|
479
|
+
});
|
|
480
|
+
})
|
|
481
|
+
.filter((option) => !!option);
|
|
463
482
|
const { options } = buildPaginatedOptions({
|
|
464
483
|
allOptions: allModelOptions,
|
|
465
484
|
page: 0,
|
|
@@ -527,13 +546,14 @@ export async function handleModelSelectMenu(interaction) {
|
|
|
527
546
|
return;
|
|
528
547
|
}
|
|
529
548
|
const allModelOptions = Object.entries(provider.models || {})
|
|
530
|
-
.map(([modelId, model]) => ({
|
|
531
|
-
label: model.name
|
|
549
|
+
.map(([modelId, model]) => buildSafeSelectOption({
|
|
550
|
+
label: model.name || modelId,
|
|
532
551
|
value: modelId,
|
|
533
|
-
description:
|
|
552
|
+
description: model.release_date
|
|
534
553
|
? new Date(model.release_date).toLocaleDateString()
|
|
535
|
-
: 'Unknown date'
|
|
554
|
+
: 'Unknown date',
|
|
536
555
|
}))
|
|
556
|
+
.filter((option) => !!option)
|
|
537
557
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
538
558
|
const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage });
|
|
539
559
|
const selectMenu = new StringSelectMenuBuilder()
|
|
@@ -708,7 +728,7 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
708
728
|
const modelDisplay = modelId.split('/')[1] || modelId;
|
|
709
729
|
const variant = context.selectedVariant ?? null;
|
|
710
730
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
711
|
-
const agentTip = '\n_Tip: create [agent .md files](https://
|
|
731
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
712
732
|
try {
|
|
713
733
|
if (selectedScope === 'session') {
|
|
714
734
|
if (!context.sessionId) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// /unset-model-override command - Remove model overrides and use default instead.
|
|
2
|
-
import { ChatInputCommandInteraction, ChannelType,
|
|
2
|
+
import { ChatInputCommandInteraction, ChannelType, } from 'discord.js';
|
|
3
3
|
import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
|
|
4
4
|
import { getDb } from '../db.js';
|
|
5
5
|
import * as orm from 'drizzle-orm';
|
|
@@ -35,7 +35,7 @@ function formatModelSource(type, agentName) {
|
|
|
35
35
|
*/
|
|
36
36
|
export async function handleUnsetModelCommand({ interaction, appId, }) {
|
|
37
37
|
unsetModelLogger.log('[UNSET-MODEL] handleUnsetModelCommand called');
|
|
38
|
-
await interaction.deferReply(
|
|
38
|
+
await interaction.deferReply();
|
|
39
39
|
const channel = interaction.channel;
|
|
40
40
|
if (!channel) {
|
|
41
41
|
await interaction.editReply({
|
|
@@ -37,6 +37,5 @@ export async function handleToggleWorktreesCommand({ command, }) {
|
|
|
37
37
|
content: nextEnabled
|
|
38
38
|
? `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will now automatically create git worktrees.\n\nNew setting for **#${channel.name}**: **enabled**.`
|
|
39
39
|
: `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will use the main project directory.\n\nNew setting for **#${channel.name}**: **disabled**.`,
|
|
40
|
-
flags: MessageFlags.Ephemeral,
|
|
41
40
|
});
|
|
42
41
|
}
|
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) {
|
package/dist/discord-bot.js
CHANGED
|
@@ -6,7 +6,7 @@ import { stopOpencodeServer, } from './opencode.js';
|
|
|
6
6
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
7
7
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
8
8
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
9
|
-
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
9
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
10
10
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
12
|
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
@@ -304,7 +304,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
304
304
|
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
305
305
|
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
306
306
|
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
307
|
-
|
|
307
|
+
const member = await resolveGuildMessageMember(message);
|
|
308
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
308
309
|
return;
|
|
309
310
|
}
|
|
310
311
|
}
|
|
@@ -343,15 +344,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
343
344
|
}
|
|
344
345
|
}
|
|
345
346
|
}
|
|
346
|
-
if (!isCliInjectedPrompt && message.guild
|
|
347
|
-
|
|
347
|
+
if (!isCliInjectedPrompt && message.guild) {
|
|
348
|
+
const member = await resolveGuildMessageMember(message);
|
|
349
|
+
if (!member) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (hasNoKimakiRole(member)) {
|
|
348
353
|
await message.reply({
|
|
349
354
|
content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
|
|
350
355
|
flags: SILENT_MESSAGE_FLAGS,
|
|
351
356
|
});
|
|
352
357
|
return;
|
|
353
358
|
}
|
|
354
|
-
if (!hasKimakiBotPermission(message.
|
|
359
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
355
360
|
await message.reply({
|
|
356
361
|
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
357
362
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -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 prompt with this agent')
|
|
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,9 @@ export function hasKimakiBotPermission(member, guild) {
|
|
|
32
33
|
if (hasNoKimakiRole) {
|
|
33
34
|
return false;
|
|
34
35
|
}
|
|
36
|
+
if (store.getState().allowAllUsers) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
35
39
|
const memberPermissions = member instanceof GuildMember
|
|
36
40
|
? member.permissions
|
|
37
41
|
: new PermissionsBitField(BigInt(member.permissions));
|
|
@@ -43,6 +47,45 @@ export function hasKimakiBotPermission(member, guild) {
|
|
|
43
47
|
const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
|
|
44
48
|
return isOwner || isAdmin || canManageServer || hasKimakiRole;
|
|
45
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
|
+
}
|
|
64
|
+
const memberPermissions = member instanceof GuildMember
|
|
65
|
+
? member.permissions
|
|
66
|
+
: new PermissionsBitField(BigInt(member.permissions));
|
|
67
|
+
const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId;
|
|
68
|
+
const memberId = member instanceof GuildMember ? member.id : member.user.id;
|
|
69
|
+
const isOwner = ownerId ? memberId === ownerId : false;
|
|
70
|
+
const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator);
|
|
71
|
+
const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
72
|
+
const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
|
|
73
|
+
return isOwner || isAdmin || canManageServer || hasKimakiRole;
|
|
74
|
+
}
|
|
75
|
+
export async function resolveGuildMessageMember(message) {
|
|
76
|
+
if (!message.guild)
|
|
77
|
+
return null;
|
|
78
|
+
if (message.member)
|
|
79
|
+
return message.member;
|
|
80
|
+
const fetchedMember = await message.guild.members
|
|
81
|
+
.fetch(message.author.id)
|
|
82
|
+
.catch((e) => new Error('Failed to fetch guild member', { cause: e }));
|
|
83
|
+
if (fetchedMember instanceof Error) {
|
|
84
|
+
discordLogger.warn(`[PERMISSION] Denying message ${message.id}: ${fetchedMember.message}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return fetchedMember;
|
|
88
|
+
}
|
|
46
89
|
function hasRoleByName(member, roleName, guild) {
|
|
47
90
|
const target = roleName.toLowerCase();
|
|
48
91
|
if (member instanceof GuildMember) {
|
|
@@ -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, resolveGuildMessageMember, 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,84 @@ 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
|
+
});
|
|
201
|
+
describe('resolveGuildMessageMember', () => {
|
|
202
|
+
test('uses hydrated message member without fetching', async () => {
|
|
203
|
+
const member = { id: 'member-id' };
|
|
204
|
+
const message = {
|
|
205
|
+
guild: {
|
|
206
|
+
members: {
|
|
207
|
+
fetch() {
|
|
208
|
+
throw new Error('should not fetch');
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
member,
|
|
213
|
+
author: { id: 'member-id' },
|
|
214
|
+
id: 'message-id',
|
|
215
|
+
};
|
|
216
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
|
|
217
|
+
});
|
|
218
|
+
test('fetches missing guild message member', async () => {
|
|
219
|
+
const member = { id: 'member-id' };
|
|
220
|
+
const message = {
|
|
221
|
+
guild: {
|
|
222
|
+
members: {
|
|
223
|
+
fetch(id) {
|
|
224
|
+
expect(id).toBe('member-id');
|
|
225
|
+
return Promise.resolve(member);
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
member: null,
|
|
230
|
+
author: { id: 'member-id' },
|
|
231
|
+
id: 'message-id',
|
|
232
|
+
};
|
|
233
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
|
|
234
|
+
});
|
|
235
|
+
test('denies when missing guild message member cannot be fetched', async () => {
|
|
236
|
+
const message = {
|
|
237
|
+
guild: {
|
|
238
|
+
members: {
|
|
239
|
+
fetch() {
|
|
240
|
+
return Promise.reject(new Error('missing member'));
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
member: null,
|
|
245
|
+
author: { id: 'member-id' },
|
|
246
|
+
id: 'message-id',
|
|
247
|
+
};
|
|
248
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
|
|
249
|
+
});
|
|
250
|
+
});
|
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',
|