kimaki 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-management.js +6 -8
- package/dist/cli-runner.js +17 -4
- package/dist/commands/last-sessions.js +3 -2
- package/dist/commands/worktrees.js +19 -3
- package/dist/format-tables.js +138 -1
- package/dist/format-tables.test.js +224 -1
- package/dist/interaction-handler.js +26 -7
- package/dist/voice-handler.js +6 -0
- package/package.json +3 -3
- package/src/channel-management.ts +6 -8
- package/src/cli-runner.ts +19 -6
- package/src/commands/last-sessions.ts +4 -2
- package/src/commands/worktrees.ts +20 -3
- package/src/format-tables.test.ts +246 -0
- package/src/format-tables.ts +176 -1
- package/src/interaction-handler.ts +25 -9
- package/src/voice-handler.ts +10 -0
|
@@ -153,7 +153,11 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
|
|
|
153
153
|
logger.log(`Default kimaki channel already exists: ${mappedChannelInGuild.id}`);
|
|
154
154
|
return null;
|
|
155
155
|
}
|
|
156
|
-
// 2. Fallback: detect existing channel by name+category
|
|
156
|
+
// 2. Fallback: detect existing channel by name+category.
|
|
157
|
+
// If a "kimaki" channel already exists in the guild but is NOT in our local
|
|
158
|
+
// DB, it was likely created by another kimaki instance (different machine).
|
|
159
|
+
// Do NOT adopt it — just skip channel creation entirely to avoid both
|
|
160
|
+
// instances fighting over the same channel.
|
|
157
161
|
const kimakiCategory = await ensureKimakiCategory(guild, botName);
|
|
158
162
|
const existingByName = guild.channels.cache.find((ch) => {
|
|
159
163
|
if (ch.type !== ChannelType.GuildText) {
|
|
@@ -165,13 +169,7 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
|
|
|
165
169
|
return ch.name === 'kimaki' || ch.name.startsWith('kimaki-');
|
|
166
170
|
});
|
|
167
171
|
if (existingByName) {
|
|
168
|
-
logger.log(`Found existing default kimaki channel by name: ${existingByName.id},
|
|
169
|
-
await setChannelDirectory({
|
|
170
|
-
channelId: existingByName.id,
|
|
171
|
-
directory: projectDirectory,
|
|
172
|
-
channelType: 'text',
|
|
173
|
-
skipIfExists: true,
|
|
174
|
-
});
|
|
172
|
+
logger.log(`Found existing default kimaki channel by name: ${existingByName.id}, but it is not in our DB — skipping (likely owned by another kimaki instance)`);
|
|
175
173
|
return null;
|
|
176
174
|
}
|
|
177
175
|
// Git init — gracefully skip if git is not installed
|
package/dist/cli-runner.js
CHANGED
|
@@ -848,6 +848,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
848
848
|
pollUrl.searchParams.set('secret', clientSecret);
|
|
849
849
|
let guildId;
|
|
850
850
|
let installerDiscordUserId;
|
|
851
|
+
let onboardingError;
|
|
851
852
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
852
853
|
await new Promise((resolve) => {
|
|
853
854
|
setTimeout(resolve, 3000);
|
|
@@ -860,7 +861,7 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
860
861
|
else if (attempt === 45) {
|
|
861
862
|
s?.message(`Still waiting... If you don't see any servers, create one first (+ button in Discord sidebar), then reopen the URL above`);
|
|
862
863
|
}
|
|
863
|
-
else if (attempt ===
|
|
864
|
+
else if (attempt === 75) {
|
|
864
865
|
s?.message(`Still waiting... Reopen the install URL if you closed it:\n${oauthUrl}`);
|
|
865
866
|
}
|
|
866
867
|
}
|
|
@@ -874,19 +875,31 @@ export async function resolveCredentials({ forceRestartOnboarding, forceGateway,
|
|
|
874
875
|
break;
|
|
875
876
|
}
|
|
876
877
|
}
|
|
878
|
+
else if (resp.status === 404) {
|
|
879
|
+
// Check if the server returned a specific onboarding error
|
|
880
|
+
// (e.g. guild_id missing from Discord callback)
|
|
881
|
+
const data = (await resp.json().catch(() => null));
|
|
882
|
+
if (data?.onboarding_error && data.error) {
|
|
883
|
+
onboardingError = data.error;
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
877
887
|
}
|
|
878
888
|
catch {
|
|
879
889
|
// Network error, retry
|
|
880
890
|
}
|
|
881
891
|
}
|
|
882
892
|
if (!guildId) {
|
|
893
|
+
const errorMsg = onboardingError
|
|
894
|
+
? `Authorization failed: ${onboardingError}`
|
|
895
|
+
: 'Bot authorization timed out after 5 minutes. Please try again.';
|
|
883
896
|
if (isInteractive) {
|
|
884
|
-
s?.stop('Authorization timed out');
|
|
897
|
+
s?.stop(onboardingError ? 'Authorization failed' : 'Authorization timed out');
|
|
885
898
|
}
|
|
886
899
|
else {
|
|
887
|
-
emitJsonEvent({ type: 'error', message:
|
|
900
|
+
emitJsonEvent({ type: 'error', message: errorMsg });
|
|
888
901
|
}
|
|
889
|
-
cliLogger.error(
|
|
902
|
+
cliLogger.error(errorMsg);
|
|
890
903
|
process.exit(EXIT_NO_RESTART);
|
|
891
904
|
}
|
|
892
905
|
if (isInteractive) {
|
|
@@ -5,7 +5,7 @@ import { ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'disco
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { getDb } from '../db.js';
|
|
7
7
|
import { getChannelDirectory } from '../database.js';
|
|
8
|
-
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
8
|
+
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
|
|
9
9
|
import { formatTimeAgo } from './worktrees.js';
|
|
10
10
|
const MAX_ROWS = 20;
|
|
11
11
|
async function fetchRecentSessions({ client, }) {
|
|
@@ -104,7 +104,7 @@ export async function handleLastSessionsCommand({ command, }) {
|
|
|
104
104
|
}
|
|
105
105
|
const tableMarkdown = buildSessionTable({ rows });
|
|
106
106
|
const segments = splitTablesFromMarkdown(tableMarkdown);
|
|
107
|
-
const
|
|
107
|
+
const allComponents = segments.flatMap((segment) => {
|
|
108
108
|
if (segment.type === 'components') {
|
|
109
109
|
return segment.components;
|
|
110
110
|
}
|
|
@@ -114,6 +114,7 @@ export async function handleLastSessionsCommand({ command, }) {
|
|
|
114
114
|
};
|
|
115
115
|
return [textDisplay];
|
|
116
116
|
});
|
|
117
|
+
const { components } = truncateComponents(allComponents);
|
|
117
118
|
await command.editReply({
|
|
118
119
|
components,
|
|
119
120
|
flags: MessageFlags.IsComponentsV2,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { ButtonInteraction, ChatInputCommandInteraction, ChannelType, ComponentType, MessageFlags, } from 'discord.js';
|
|
8
8
|
import { deleteThreadWorktree, } from '../database.js';
|
|
9
9
|
import { getDb } from '../db.js';
|
|
10
|
-
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
10
|
+
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
|
|
11
11
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
12
12
|
import * as errore from 'errore';
|
|
13
13
|
import crypto from 'node:crypto';
|
|
@@ -132,7 +132,9 @@ function buildWorktreeTable({ rows, gitStatuses, guildId, }) {
|
|
|
132
132
|
return parts.join(', ');
|
|
133
133
|
})();
|
|
134
134
|
const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-';
|
|
135
|
-
|
|
135
|
+
// Show only the last 2 path segments to keep text size under Discord's
|
|
136
|
+
// 4000-char displayable text limit. Full paths are too long.
|
|
137
|
+
const folder = `…/${path.basename(path.dirname(row.directory))}/${path.basename(row.directory)}`;
|
|
136
138
|
const action = buildActionCell({ row, gitStatus: gs });
|
|
137
139
|
return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`;
|
|
138
140
|
});
|
|
@@ -360,7 +362,7 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
360
362
|
return buildHtmlActionCustomId(actionId);
|
|
361
363
|
},
|
|
362
364
|
});
|
|
363
|
-
const
|
|
365
|
+
const allComponents = segments.flatMap((segment) => {
|
|
364
366
|
if (segment.type === 'components') {
|
|
365
367
|
return segment.components;
|
|
366
368
|
}
|
|
@@ -370,6 +372,20 @@ async function renderWorktreesReply({ guildId, userId, channelId, projectDirecto
|
|
|
370
372
|
};
|
|
371
373
|
return [textDisplay];
|
|
372
374
|
});
|
|
375
|
+
// Reserve budget for a truncation notice (1 component + its text length)
|
|
376
|
+
// so appending the notice doesn't push us over either Discord limit.
|
|
377
|
+
const truncatedNoticeContent = `*Some worktrees were not shown due to Discord's component limit. Use \`git worktree list\` for the full list.*`;
|
|
378
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
379
|
+
reserveCost: 1,
|
|
380
|
+
reserveTextSize: truncatedNoticeContent.length,
|
|
381
|
+
});
|
|
382
|
+
if (truncated) {
|
|
383
|
+
const truncatedNotice = {
|
|
384
|
+
type: ComponentType.TextDisplay,
|
|
385
|
+
content: truncatedNoticeContent,
|
|
386
|
+
};
|
|
387
|
+
components.push(truncatedNotice);
|
|
388
|
+
}
|
|
373
389
|
await editReply({
|
|
374
390
|
components,
|
|
375
391
|
flags: MessageFlags.IsComponentsV2,
|
package/dist/format-tables.js
CHANGED
|
@@ -8,7 +8,144 @@ import { parseInlineHtmlRenderables, } from './html-components.js';
|
|
|
8
8
|
// Max 40 components per message (nested components count toward the limit).
|
|
9
9
|
// Row cost is dynamic now because a table row can render as a plain TextDisplay
|
|
10
10
|
// or as a TextDisplay plus an Action Row holding one or more buttons.
|
|
11
|
-
const MAX_COMPONENTS = 40;
|
|
11
|
+
export const MAX_COMPONENTS = 40;
|
|
12
|
+
// Discord caps total displayable text across all components at 4000 chars.
|
|
13
|
+
export const MAX_TEXT_SIZE = 4000;
|
|
14
|
+
// Count cost of a single child inside a Container.
|
|
15
|
+
// ActionRow with N buttons = 1 + N, everything else = 1.
|
|
16
|
+
function childComponentCost(child) {
|
|
17
|
+
if ('components' in child && Array.isArray(child.components)) {
|
|
18
|
+
return 1 + child.components.length;
|
|
19
|
+
}
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
// Count displayable text size of a component tree.
|
|
23
|
+
// Discord counts all text content (TextDisplay, button labels) toward a 4000-char limit.
|
|
24
|
+
function componentTextSize(component) {
|
|
25
|
+
let size = 0;
|
|
26
|
+
if ('content' in component && typeof component.content === 'string') {
|
|
27
|
+
size += component.content.length;
|
|
28
|
+
}
|
|
29
|
+
if ('label' in component && typeof component.label === 'string') {
|
|
30
|
+
size += component.label.length;
|
|
31
|
+
}
|
|
32
|
+
if ('components' in component && Array.isArray(component.components)) {
|
|
33
|
+
for (const child of component.components) {
|
|
34
|
+
size += componentTextSize(child);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return size;
|
|
38
|
+
}
|
|
39
|
+
// Count total component cost of a top-level component including nested children.
|
|
40
|
+
// Discord counts every component toward the 40-component budget:
|
|
41
|
+
// Container(3 children) = 4, ActionRow(2 buttons) = 3, TextDisplay = 1.
|
|
42
|
+
export function countComponentCost(component) {
|
|
43
|
+
if (component.type === ComponentType.Container) {
|
|
44
|
+
let cost = 1;
|
|
45
|
+
for (const child of component.components) {
|
|
46
|
+
cost += childComponentCost(child);
|
|
47
|
+
}
|
|
48
|
+
return cost;
|
|
49
|
+
}
|
|
50
|
+
if ('components' in component && Array.isArray(component.components)) {
|
|
51
|
+
return 1 + component.components.length;
|
|
52
|
+
}
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
// Truncate an array of top-level components to stay within Discord limits:
|
|
56
|
+
// - 40 total components (nested children count)
|
|
57
|
+
// - 4000 chars total displayable text
|
|
58
|
+
// When a Container alone exceeds the budget, its children are truncated instead
|
|
59
|
+
// of dropping the entire Container (which would show nothing).
|
|
60
|
+
// reserveCost / reserveTextSize hold back budget for caller-appended components.
|
|
61
|
+
// maxComponents / maxTextSize override defaults (useful for testing).
|
|
62
|
+
export function truncateComponents(components, { reserveCost = 0, reserveTextSize = 0, maxComponents = MAX_COMPONENTS, maxTextSize = MAX_TEXT_SIZE, } = {}) {
|
|
63
|
+
const componentBudget = maxComponents - reserveCost;
|
|
64
|
+
const textBudget = maxTextSize - reserveTextSize;
|
|
65
|
+
let totalCost = 0;
|
|
66
|
+
let totalText = 0;
|
|
67
|
+
const result = [];
|
|
68
|
+
for (const component of components) {
|
|
69
|
+
const cost = countComponentCost(component);
|
|
70
|
+
const text = componentTextSize(component);
|
|
71
|
+
if (totalCost + cost <= componentBudget && totalText + text <= textBudget) {
|
|
72
|
+
result.push(component);
|
|
73
|
+
totalCost += cost;
|
|
74
|
+
totalText += text;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// The component doesn't fit. If it's a Container, truncate its children
|
|
78
|
+
// to fill the remaining budget instead of dropping the whole thing.
|
|
79
|
+
if (component.type === ComponentType.Container) {
|
|
80
|
+
const remainingComponentBudget = componentBudget - totalCost - 1;
|
|
81
|
+
const remainingTextBudget = textBudget - totalText;
|
|
82
|
+
if (remainingComponentBudget > 0 && remainingTextBudget > 0) {
|
|
83
|
+
const truncatedChildren = truncateContainerChildren(component.components, remainingComponentBudget, remainingTextBudget);
|
|
84
|
+
if (truncatedChildren.length > 0) {
|
|
85
|
+
result.push({
|
|
86
|
+
...component,
|
|
87
|
+
components: truncatedChildren,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { components: result, truncated: true };
|
|
93
|
+
}
|
|
94
|
+
return { components: result, truncated: false };
|
|
95
|
+
}
|
|
96
|
+
// Truncate a Container's children by separator-delimited row groups.
|
|
97
|
+
// A row group is everything between separators (e.g. [TextDisplay, ActionRow]).
|
|
98
|
+
// Either the full group fits or it's excluded — no trailing separators or
|
|
99
|
+
// partial rows (like a TextDisplay without its ActionRow button).
|
|
100
|
+
// Enforces both component count and text size budgets.
|
|
101
|
+
function truncateContainerChildren(children, componentBudget, textBudget) {
|
|
102
|
+
const groups = groupBySeparator(children);
|
|
103
|
+
let cost = 0;
|
|
104
|
+
let text = 0;
|
|
105
|
+
const result = [];
|
|
106
|
+
for (const group of groups) {
|
|
107
|
+
const groupCost = group.reduce((sum, child) => sum + childComponentCost(child), 0);
|
|
108
|
+
const groupText = group.reduce((sum, child) => sum + componentTextSize(child), 0);
|
|
109
|
+
if (cost + groupCost > componentBudget || text + groupText > textBudget) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
result.push(...group);
|
|
113
|
+
cost += groupCost;
|
|
114
|
+
text += groupText;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
// Split children into groups delimited by Separator components.
|
|
119
|
+
// Separators are prepended to the following group (they sit between rows).
|
|
120
|
+
// First group has no leading separator.
|
|
121
|
+
// Drops separator-only groups and strips leading separators from the first group
|
|
122
|
+
// to handle edge cases (children starting/ending with separators, consecutive separators).
|
|
123
|
+
function groupBySeparator(children) {
|
|
124
|
+
const rawGroups = [];
|
|
125
|
+
let current = [];
|
|
126
|
+
for (const child of children) {
|
|
127
|
+
if (child.type === ComponentType.Separator && current.length > 0) {
|
|
128
|
+
rawGroups.push(current);
|
|
129
|
+
current = [child];
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
current.push(child);
|
|
133
|
+
}
|
|
134
|
+
if (current.length > 0) {
|
|
135
|
+
rawGroups.push(current);
|
|
136
|
+
}
|
|
137
|
+
// Clean up: strip leading separators from first group, drop separator-only groups
|
|
138
|
+
return rawGroups
|
|
139
|
+
.map((group, index) => {
|
|
140
|
+
if (index === 0) {
|
|
141
|
+
return group.filter((child) => child.type !== ComponentType.Separator);
|
|
142
|
+
}
|
|
143
|
+
return group;
|
|
144
|
+
})
|
|
145
|
+
.filter((group) => {
|
|
146
|
+
return group.some((child) => child.type !== ComponentType.Separator);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
12
149
|
/**
|
|
13
150
|
* Split markdown into text and table component segments.
|
|
14
151
|
* Tables are rendered as CV2 Container components with bold key-value TextDisplay
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe } from 'vitest';
|
|
2
|
-
import { splitTablesFromMarkdown, buildTableComponents, } from './format-tables.js';
|
|
2
|
+
import { splitTablesFromMarkdown, buildTableComponents, truncateComponents, countComponentCost, } from './format-tables.js';
|
|
3
3
|
import { Lexer } from 'marked';
|
|
4
4
|
import { ComponentType } from 'discord.js';
|
|
5
5
|
function isTableToken(token) {
|
|
@@ -476,3 +476,226 @@ Still open`);
|
|
|
476
476
|
`);
|
|
477
477
|
});
|
|
478
478
|
});
|
|
479
|
+
describe('truncateComponents', () => {
|
|
480
|
+
// Build a worktree-like table with button rows to emulate real /worktrees output.
|
|
481
|
+
// Each row renders as: TextDisplay (1) + ActionRow with 1 Button (2) = 3 components.
|
|
482
|
+
// Plus separators between rows (1 each). Container itself costs 1.
|
|
483
|
+
// So N button rows = 1 (container) + N*3 (TD+AR+btn) + (N-1)*1 (seps) = 4N components.
|
|
484
|
+
function buildWorktreeTable(rowCount) {
|
|
485
|
+
const header = '| Source | Name | Status | Created | Folder | Action |';
|
|
486
|
+
const sep = '|---|---|---|---|---|---|';
|
|
487
|
+
const rows = Array.from({ length: rowCount }, (_, i) => {
|
|
488
|
+
return `| kimaki | wt-${i} | merged | ${i}m ago | /tmp/wt-${i} | <button id="del-${i}" variant="secondary">Delete</button> |`;
|
|
489
|
+
}).join('\n');
|
|
490
|
+
const markdown = `${header}\n${sep}\n${rows}`;
|
|
491
|
+
return splitTablesFromMarkdown(markdown, {
|
|
492
|
+
resolveButtonCustomId: ({ button }) => `action:${button.id}`,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
test('no truncation when components fit within budget', () => {
|
|
496
|
+
const segments = buildWorktreeTable(2);
|
|
497
|
+
const allComponents = segments.flatMap((s) => {
|
|
498
|
+
return s.type === 'components' ? s.components : [];
|
|
499
|
+
});
|
|
500
|
+
const totalCost = allComponents.reduce((sum, c) => sum + countComponentCost(c), 0);
|
|
501
|
+
// 2 rows: 1 + 2*3 + 1*1 = 8 components
|
|
502
|
+
expect(totalCost).toBe(8);
|
|
503
|
+
const { components, truncated } = truncateComponents(allComponents, { maxComponents: 10 });
|
|
504
|
+
expect(truncated).toBe(false);
|
|
505
|
+
expect(components).toHaveLength(1);
|
|
506
|
+
});
|
|
507
|
+
test('truncates Container children when single Container exceeds budget', () => {
|
|
508
|
+
// 5 button rows: cost = 1 + 5*3 + 4*1 = 20 components
|
|
509
|
+
const segments = buildWorktreeTable(5);
|
|
510
|
+
const allComponents = segments.flatMap((s) => {
|
|
511
|
+
return s.type === 'components' ? s.components : [];
|
|
512
|
+
});
|
|
513
|
+
expect(allComponents).toHaveLength(1); // single Container
|
|
514
|
+
expect(countComponentCost(allComponents[0])).toBe(20);
|
|
515
|
+
// Budget of 10: Container(1) + children that fit in 9
|
|
516
|
+
const { components, truncated } = truncateComponents(allComponents, { maxComponents: 10 });
|
|
517
|
+
expect(truncated).toBe(true);
|
|
518
|
+
expect(components).toHaveLength(1); // Container kept, not dropped
|
|
519
|
+
const container = components[0];
|
|
520
|
+
expect(container.type).toBe(ComponentType.Container);
|
|
521
|
+
// Should have truncated children to fit within budget
|
|
522
|
+
const truncatedCost = countComponentCost(container);
|
|
523
|
+
expect(truncatedCost).toBeLessThanOrEqual(10);
|
|
524
|
+
expect(truncatedCost).toBeGreaterThan(1); // not empty
|
|
525
|
+
// Snapshot the truncated container structure
|
|
526
|
+
expect(container).toMatchInlineSnapshot(`
|
|
527
|
+
{
|
|
528
|
+
"components": [
|
|
529
|
+
{
|
|
530
|
+
"content": "**Source** kimaki
|
|
531
|
+
**Name** wt-0
|
|
532
|
+
**Status** merged
|
|
533
|
+
**Created** 0m ago
|
|
534
|
+
**Folder** /tmp/wt-0",
|
|
535
|
+
"type": 10,
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
"components": [
|
|
539
|
+
{
|
|
540
|
+
"custom_id": "action:del-0",
|
|
541
|
+
"disabled": false,
|
|
542
|
+
"label": "Delete",
|
|
543
|
+
"style": 2,
|
|
544
|
+
"type": 2,
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
"type": 1,
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
"divider": true,
|
|
551
|
+
"spacing": 1,
|
|
552
|
+
"type": 14,
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
"content": "**Source** kimaki
|
|
556
|
+
**Name** wt-1
|
|
557
|
+
**Status** merged
|
|
558
|
+
**Created** 1m ago
|
|
559
|
+
**Folder** /tmp/wt-1",
|
|
560
|
+
"type": 10,
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
"components": [
|
|
564
|
+
{
|
|
565
|
+
"custom_id": "action:del-1",
|
|
566
|
+
"disabled": false,
|
|
567
|
+
"label": "Delete",
|
|
568
|
+
"style": 2,
|
|
569
|
+
"type": 2,
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
"type": 1,
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
"type": 17,
|
|
576
|
+
}
|
|
577
|
+
`);
|
|
578
|
+
});
|
|
579
|
+
test('reserveCost leaves room for caller-appended components', () => {
|
|
580
|
+
const segments = buildWorktreeTable(5);
|
|
581
|
+
const allComponents = segments.flatMap((s) => {
|
|
582
|
+
return s.type === 'components' ? s.components : [];
|
|
583
|
+
});
|
|
584
|
+
// Budget 12, reserve 2 → effective budget 10
|
|
585
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
586
|
+
maxComponents: 12,
|
|
587
|
+
reserveCost: 2,
|
|
588
|
+
});
|
|
589
|
+
expect(truncated).toBe(true);
|
|
590
|
+
const cost = components.reduce((sum, c) => sum + countComponentCost(c), 0);
|
|
591
|
+
expect(cost).toBeLessThanOrEqual(10);
|
|
592
|
+
expect(cost + 2).toBeLessThanOrEqual(12);
|
|
593
|
+
});
|
|
594
|
+
test('does not leave trailing separator or partial button row', () => {
|
|
595
|
+
// 10 button rows at real budget (40, reserve 1 = 39 effective).
|
|
596
|
+
// Each row group = separator(1) + TextDisplay(1) + ActionRow+Button(2) = 4 cost.
|
|
597
|
+
// First group has no separator so costs 3.
|
|
598
|
+
// 9 full rows = 3 + 8*4 = 35, next group costs 4 → 39, fits.
|
|
599
|
+
// So all 10 rows should fit in budget 40 with reserve 1.
|
|
600
|
+
// With budget 15: 3 + 4 + 4 = 11, next group (4) → 15, fits → 4 rows.
|
|
601
|
+
const segments = buildWorktreeTable(10);
|
|
602
|
+
const allComponents = segments.flatMap((s) => {
|
|
603
|
+
return s.type === 'components' ? s.components : [];
|
|
604
|
+
});
|
|
605
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
606
|
+
maxComponents: 15,
|
|
607
|
+
reserveCost: 1,
|
|
608
|
+
});
|
|
609
|
+
expect(truncated).toBe(true);
|
|
610
|
+
const container = components[0];
|
|
611
|
+
expect(container.type).toBe(ComponentType.Container);
|
|
612
|
+
if (container.type !== ComponentType.Container)
|
|
613
|
+
throw new Error('unreachable');
|
|
614
|
+
// Last child must not be a separator
|
|
615
|
+
const lastChild = container.components.at(-1);
|
|
616
|
+
expect(lastChild.type).not.toBe(ComponentType.Separator);
|
|
617
|
+
// Every TextDisplay row must be followed by its ActionRow (no orphaned text)
|
|
618
|
+
for (let i = 0; i < container.components.length; i++) {
|
|
619
|
+
const child = container.components[i];
|
|
620
|
+
if (child.type === ComponentType.TextDisplay) {
|
|
621
|
+
const next = container.components[i + 1];
|
|
622
|
+
// Next should be ActionRow or Separator (if this is a text-only row).
|
|
623
|
+
// For button rows, next must be ActionRow.
|
|
624
|
+
if (next && next.type === ComponentType.Separator) {
|
|
625
|
+
// text-only row followed by separator — fine
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (next && next.type === ComponentType.ActionRow) {
|
|
629
|
+
// button row followed by its action row — fine
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
// Last child is a TextDisplay — acceptable only if it's the final item
|
|
633
|
+
// (the group was included whole)
|
|
634
|
+
if (i === container.components.length - 1) {
|
|
635
|
+
// This means we have an orphaned TextDisplay at the end without its button.
|
|
636
|
+
// The group-based truncation should prevent this for button rows.
|
|
637
|
+
// For this test all rows have buttons, so this should not happen.
|
|
638
|
+
expect(next).toBeDefined();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const totalCost = components.reduce((sum, c) => sum + countComponentCost(c), 0);
|
|
643
|
+
expect(totalCost).toBeLessThanOrEqual(14); // 15 - 1 reserve
|
|
644
|
+
});
|
|
645
|
+
test('handles multiple top-level components before the large Container', () => {
|
|
646
|
+
const segments = buildWorktreeTable(3);
|
|
647
|
+
const allComponents = segments.flatMap((s) => {
|
|
648
|
+
return s.type === 'components' ? s.components : [];
|
|
649
|
+
});
|
|
650
|
+
// Prepend a TextDisplay (cost 1) before the Container
|
|
651
|
+
const textDisplay = { type: ComponentType.TextDisplay, content: 'Notice text' };
|
|
652
|
+
const combined = [textDisplay, ...allComponents];
|
|
653
|
+
// Budget 10: TextDisplay(1) + Container with truncated children(≤9)
|
|
654
|
+
const { components, truncated } = truncateComponents(combined, { maxComponents: 10 });
|
|
655
|
+
expect(truncated).toBe(true);
|
|
656
|
+
expect(components).toHaveLength(2); // TextDisplay + truncated Container
|
|
657
|
+
expect(components[0].type).toBe(ComponentType.TextDisplay);
|
|
658
|
+
expect(components[1].type).toBe(ComponentType.Container);
|
|
659
|
+
const totalCost = components.reduce((sum, c) => sum + countComponentCost(c), 0);
|
|
660
|
+
expect(totalCost).toBeLessThanOrEqual(10);
|
|
661
|
+
});
|
|
662
|
+
test('truncates when text size exceeds limit even if component count fits', () => {
|
|
663
|
+
// Build a table with long folder paths to simulate real /worktrees output
|
|
664
|
+
const header = '| Name | Folder |';
|
|
665
|
+
const sep = '|---|---|';
|
|
666
|
+
const rows = Array.from({ length: 10 }, (_, i) => {
|
|
667
|
+
// Each row has ~120 chars of text content
|
|
668
|
+
const longPath = `/Users/morse/.kimaki/worktrees/abcd1234/very-long-branch-name-${i}-feature`;
|
|
669
|
+
return `| worktree-${i} | ${longPath} |`;
|
|
670
|
+
}).join('\n');
|
|
671
|
+
const markdown = `${header}\n${sep}\n${rows}`;
|
|
672
|
+
const segments = splitTablesFromMarkdown(markdown);
|
|
673
|
+
const allComponents = segments.flatMap((s) => {
|
|
674
|
+
return s.type === 'components' ? s.components : [];
|
|
675
|
+
});
|
|
676
|
+
// Component count is fine (10 rows + 9 separators + 1 container = 20),
|
|
677
|
+
// but set text limit low to force text-based truncation
|
|
678
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
679
|
+
maxComponents: 40,
|
|
680
|
+
maxTextSize: 500,
|
|
681
|
+
});
|
|
682
|
+
expect(truncated).toBe(true);
|
|
683
|
+
expect(components).toHaveLength(1);
|
|
684
|
+
// Verify total text is under the limit
|
|
685
|
+
const container = components[0];
|
|
686
|
+
expect(container.type).toBe(ComponentType.Container);
|
|
687
|
+
if (container.type !== ComponentType.Container)
|
|
688
|
+
throw new Error('unreachable');
|
|
689
|
+
const totalText = container.components.reduce((sum, child) => {
|
|
690
|
+
if ('content' in child && typeof child.content === 'string') {
|
|
691
|
+
return sum + child.content.length;
|
|
692
|
+
}
|
|
693
|
+
return sum;
|
|
694
|
+
}, 0);
|
|
695
|
+
expect(totalText).toBeLessThanOrEqual(500);
|
|
696
|
+
// Should have fewer than 10 rows
|
|
697
|
+
const textDisplays = container.components.filter((c) => c.type === ComponentType.TextDisplay);
|
|
698
|
+
expect(textDisplays.length).toBeLessThan(10);
|
|
699
|
+
expect(textDisplays.length).toBeGreaterThan(0);
|
|
700
|
+
});
|
|
701
|
+
});
|
|
@@ -484,13 +484,32 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
484
484
|
interactionLogger.error('[INTERACTION] Error handling interaction:', error);
|
|
485
485
|
void notifyError(error, 'Interaction handler error');
|
|
486
486
|
try {
|
|
487
|
-
if (interaction.isRepliable() &&
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
487
|
+
if (interaction.isRepliable() && !interaction.replied) {
|
|
488
|
+
if (interaction.deferred) {
|
|
489
|
+
// Component interactions that used deferUpdate() set ephemeral
|
|
490
|
+
// to null. For those, followUp is correct (editReply would
|
|
491
|
+
// overwrite the original message). For deferReply() interactions
|
|
492
|
+
// (ephemeral is true/false), editReply resolves the pending reply.
|
|
493
|
+
const usedDeferUpdate = interaction.isMessageComponent() &&
|
|
494
|
+
interaction.ephemeral === null;
|
|
495
|
+
if (usedDeferUpdate) {
|
|
496
|
+
await interaction.followUp({
|
|
497
|
+
content: 'An error occurred processing this interaction.',
|
|
498
|
+
flags: MessageFlags.Ephemeral,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
await interaction.editReply({
|
|
503
|
+
content: 'An error occurred processing this command.',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
await interaction.reply({
|
|
509
|
+
content: 'An error occurred processing this command.',
|
|
510
|
+
flags: MessageFlags.Ephemeral,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
494
513
|
}
|
|
495
514
|
}
|
|
496
515
|
catch (replyError) {
|
package/dist/voice-handler.js
CHANGED
|
@@ -599,6 +599,12 @@ export function registerVoiceStateHandler({ discordClient, appId, }) {
|
|
|
599
599
|
}
|
|
600
600
|
return;
|
|
601
601
|
}
|
|
602
|
+
// Check for Gemini API key before joining — voice requires it for live audio
|
|
603
|
+
const geminiApiKey = (await getGeminiApiKey(appId)) || process.env.GEMINI_API_KEY;
|
|
604
|
+
if (!geminiApiKey) {
|
|
605
|
+
voiceLogger.log(`No Gemini API key available, skipping voice channel join. Use /audio-api-key in Discord to set one.`);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
602
608
|
try {
|
|
603
609
|
voiceLogger.log(`Attempting to join voice channel: ${voiceChannel.name} (${voiceChannel.id})`);
|
|
604
610
|
const connection = joinVoiceChannel({
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.17.
|
|
5
|
+
"version": "0.17.1",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
"lintcn": "^0.7.1",
|
|
25
25
|
"tsx": "^4.20.5",
|
|
26
26
|
"undici": "^8.0.2",
|
|
27
|
+
"db": "^0.0.0",
|
|
27
28
|
"discord-digital-twin": "^0.1.0",
|
|
28
|
-
"opencode-cached-provider": "^0.0.1",
|
|
29
29
|
"opencode-deterministic-provider": "^0.0.1",
|
|
30
|
-
"
|
|
30
|
+
"opencode-cached-provider": "^0.0.1"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@ai-sdk/google": "^3.0.53",
|
|
@@ -250,7 +250,11 @@ export async function createDefaultKimakiChannel({
|
|
|
250
250
|
return null
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
// 2. Fallback: detect existing channel by name+category
|
|
253
|
+
// 2. Fallback: detect existing channel by name+category.
|
|
254
|
+
// If a "kimaki" channel already exists in the guild but is NOT in our local
|
|
255
|
+
// DB, it was likely created by another kimaki instance (different machine).
|
|
256
|
+
// Do NOT adopt it — just skip channel creation entirely to avoid both
|
|
257
|
+
// instances fighting over the same channel.
|
|
254
258
|
const kimakiCategory = await ensureKimakiCategory(guild, botName)
|
|
255
259
|
const existingByName = guild.channels.cache.find((ch): ch is TextChannel => {
|
|
256
260
|
if (ch.type !== ChannelType.GuildText) {
|
|
@@ -263,14 +267,8 @@ export async function createDefaultKimakiChannel({
|
|
|
263
267
|
})
|
|
264
268
|
if (existingByName) {
|
|
265
269
|
logger.log(
|
|
266
|
-
`Found existing default kimaki channel by name: ${existingByName.id},
|
|
270
|
+
`Found existing default kimaki channel by name: ${existingByName.id}, but it is not in our DB — skipping (likely owned by another kimaki instance)`,
|
|
267
271
|
)
|
|
268
|
-
await setChannelDirectory({
|
|
269
|
-
channelId: existingByName.id,
|
|
270
|
-
directory: projectDirectory,
|
|
271
|
-
channelType: 'text',
|
|
272
|
-
skipIfExists: true,
|
|
273
|
-
})
|
|
274
272
|
return null
|
|
275
273
|
}
|
|
276
274
|
|
package/src/cli-runner.ts
CHANGED
|
@@ -1215,6 +1215,7 @@ export async function resolveCredentials({
|
|
|
1215
1215
|
|
|
1216
1216
|
let guildId: string | undefined
|
|
1217
1217
|
let installerDiscordUserId: string | undefined
|
|
1218
|
+
let onboardingError: string | undefined
|
|
1218
1219
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1219
1220
|
await new Promise((resolve) => {
|
|
1220
1221
|
setTimeout(resolve, 3000)
|
|
@@ -1230,7 +1231,7 @@ export async function resolveCredentials({
|
|
|
1230
1231
|
s?.message(
|
|
1231
1232
|
`Still waiting... If you don't see any servers, create one first (+ button in Discord sidebar), then reopen the URL above`,
|
|
1232
1233
|
)
|
|
1233
|
-
} else if (attempt ===
|
|
1234
|
+
} else if (attempt === 75) {
|
|
1234
1235
|
s?.message(
|
|
1235
1236
|
`Still waiting... Reopen the install URL if you closed it:\n${oauthUrl}`,
|
|
1236
1237
|
)
|
|
@@ -1249,6 +1250,17 @@ export async function resolveCredentials({
|
|
|
1249
1250
|
installerDiscordUserId = data.discord_user_id
|
|
1250
1251
|
break
|
|
1251
1252
|
}
|
|
1253
|
+
} else if (resp.status === 404) {
|
|
1254
|
+
// Check if the server returned a specific onboarding error
|
|
1255
|
+
// (e.g. guild_id missing from Discord callback)
|
|
1256
|
+
const data = (await resp.json().catch(() => null)) as {
|
|
1257
|
+
error?: string
|
|
1258
|
+
onboarding_error?: boolean
|
|
1259
|
+
} | null
|
|
1260
|
+
if (data?.onboarding_error && data.error) {
|
|
1261
|
+
onboardingError = data.error
|
|
1262
|
+
break
|
|
1263
|
+
}
|
|
1252
1264
|
}
|
|
1253
1265
|
} catch {
|
|
1254
1266
|
// Network error, retry
|
|
@@ -1256,14 +1268,15 @@ export async function resolveCredentials({
|
|
|
1256
1268
|
}
|
|
1257
1269
|
|
|
1258
1270
|
if (!guildId) {
|
|
1271
|
+
const errorMsg = onboardingError
|
|
1272
|
+
? `Authorization failed: ${onboardingError}`
|
|
1273
|
+
: 'Bot authorization timed out after 5 minutes. Please try again.'
|
|
1259
1274
|
if (isInteractive) {
|
|
1260
|
-
s?.stop('Authorization timed out')
|
|
1275
|
+
s?.stop(onboardingError ? 'Authorization failed' : 'Authorization timed out')
|
|
1261
1276
|
} else {
|
|
1262
|
-
emitJsonEvent({ type: 'error', message:
|
|
1277
|
+
emitJsonEvent({ type: 'error', message: errorMsg })
|
|
1263
1278
|
}
|
|
1264
|
-
cliLogger.error(
|
|
1265
|
-
'Bot authorization timed out after 5 minutes. Please try again.',
|
|
1266
|
-
)
|
|
1279
|
+
cliLogger.error(errorMsg)
|
|
1267
1280
|
process.exit(EXIT_NO_RESTART)
|
|
1268
1281
|
}
|
|
1269
1282
|
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import { getDb } from '../db.js'
|
|
15
15
|
import { getChannelDirectory } from '../database.js'
|
|
16
|
-
import { splitTablesFromMarkdown } from '../format-tables.js'
|
|
16
|
+
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js'
|
|
17
17
|
import { formatTimeAgo } from './worktrees.js'
|
|
18
18
|
|
|
19
19
|
const MAX_ROWS = 20
|
|
@@ -148,7 +148,7 @@ export async function handleLastSessionsCommand({
|
|
|
148
148
|
const tableMarkdown = buildSessionTable({ rows })
|
|
149
149
|
const segments = splitTablesFromMarkdown(tableMarkdown)
|
|
150
150
|
|
|
151
|
-
const
|
|
151
|
+
const allComponents: APIMessageTopLevelComponent[] = segments.flatMap(
|
|
152
152
|
(segment) => {
|
|
153
153
|
if (segment.type === 'components') {
|
|
154
154
|
return segment.components
|
|
@@ -161,6 +161,8 @@ export async function handleLastSessionsCommand({
|
|
|
161
161
|
},
|
|
162
162
|
)
|
|
163
163
|
|
|
164
|
+
const { components } = truncateComponents(allComponents)
|
|
165
|
+
|
|
164
166
|
await command.editReply({
|
|
165
167
|
components,
|
|
166
168
|
flags: MessageFlags.IsComponentsV2,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
type ThreadWorktree,
|
|
23
23
|
} from '../database.js'
|
|
24
24
|
import { getDb } from '../db.js'
|
|
25
|
-
import { splitTablesFromMarkdown } from '../format-tables.js'
|
|
25
|
+
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js'
|
|
26
26
|
import {
|
|
27
27
|
buildHtmlActionCustomId,
|
|
28
28
|
cancelHtmlActionsForOwner,
|
|
@@ -214,7 +214,9 @@ function buildWorktreeTable({
|
|
|
214
214
|
return parts.join(', ')
|
|
215
215
|
})()
|
|
216
216
|
const created = row.createdAt ? formatTimeAgo(row.createdAt) : '-'
|
|
217
|
-
|
|
217
|
+
// Show only the last 2 path segments to keep text size under Discord's
|
|
218
|
+
// 4000-char displayable text limit. Full paths are too long.
|
|
219
|
+
const folder = `…/${path.basename(path.dirname(row.directory))}/${path.basename(row.directory)}`
|
|
218
220
|
const action = buildActionCell({ row, gitStatus: gs })
|
|
219
221
|
return `| ${sourceCell} | ${name} | ${status} | ${created} | ${folder} | ${action} |`
|
|
220
222
|
})
|
|
@@ -514,7 +516,7 @@ async function renderWorktreesReply({
|
|
|
514
516
|
},
|
|
515
517
|
})
|
|
516
518
|
|
|
517
|
-
const
|
|
519
|
+
const allComponents: APIMessageTopLevelComponent[] = segments.flatMap((segment) => {
|
|
518
520
|
if (segment.type === 'components') {
|
|
519
521
|
return segment.components
|
|
520
522
|
}
|
|
@@ -526,6 +528,21 @@ async function renderWorktreesReply({
|
|
|
526
528
|
return [textDisplay]
|
|
527
529
|
})
|
|
528
530
|
|
|
531
|
+
// Reserve budget for a truncation notice (1 component + its text length)
|
|
532
|
+
// so appending the notice doesn't push us over either Discord limit.
|
|
533
|
+
const truncatedNoticeContent = `*Some worktrees were not shown due to Discord's component limit. Use \`git worktree list\` for the full list.*`
|
|
534
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
535
|
+
reserveCost: 1,
|
|
536
|
+
reserveTextSize: truncatedNoticeContent.length,
|
|
537
|
+
})
|
|
538
|
+
if (truncated) {
|
|
539
|
+
const truncatedNotice: APITextDisplayComponent = {
|
|
540
|
+
type: ComponentType.TextDisplay,
|
|
541
|
+
content: truncatedNoticeContent,
|
|
542
|
+
}
|
|
543
|
+
components.push(truncatedNotice)
|
|
544
|
+
}
|
|
545
|
+
|
|
529
546
|
await editReply({
|
|
530
547
|
components,
|
|
531
548
|
flags: MessageFlags.IsComponentsV2,
|
|
@@ -2,6 +2,8 @@ import { test, expect, describe } from 'vitest'
|
|
|
2
2
|
import {
|
|
3
3
|
splitTablesFromMarkdown,
|
|
4
4
|
buildTableComponents,
|
|
5
|
+
truncateComponents,
|
|
6
|
+
countComponentCost,
|
|
5
7
|
type ContentSegment,
|
|
6
8
|
} from './format-tables.js'
|
|
7
9
|
import { Lexer, type Tokens } from 'marked'
|
|
@@ -513,3 +515,247 @@ Still open`)
|
|
|
513
515
|
`)
|
|
514
516
|
})
|
|
515
517
|
})
|
|
518
|
+
|
|
519
|
+
describe('truncateComponents', () => {
|
|
520
|
+
// Build a worktree-like table with button rows to emulate real /worktrees output.
|
|
521
|
+
// Each row renders as: TextDisplay (1) + ActionRow with 1 Button (2) = 3 components.
|
|
522
|
+
// Plus separators between rows (1 each). Container itself costs 1.
|
|
523
|
+
// So N button rows = 1 (container) + N*3 (TD+AR+btn) + (N-1)*1 (seps) = 4N components.
|
|
524
|
+
function buildWorktreeTable(rowCount: number) {
|
|
525
|
+
const header = '| Source | Name | Status | Created | Folder | Action |'
|
|
526
|
+
const sep = '|---|---|---|---|---|---|'
|
|
527
|
+
const rows = Array.from({ length: rowCount }, (_, i) => {
|
|
528
|
+
return `| kimaki | wt-${i} | merged | ${i}m ago | /tmp/wt-${i} | <button id="del-${i}" variant="secondary">Delete</button> |`
|
|
529
|
+
}).join('\n')
|
|
530
|
+
const markdown = `${header}\n${sep}\n${rows}`
|
|
531
|
+
return splitTablesFromMarkdown(markdown, {
|
|
532
|
+
resolveButtonCustomId: ({ button }) => `action:${button.id}`,
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
test('no truncation when components fit within budget', () => {
|
|
537
|
+
const segments = buildWorktreeTable(2)
|
|
538
|
+
const allComponents = segments.flatMap((s) => {
|
|
539
|
+
return s.type === 'components' ? s.components : []
|
|
540
|
+
})
|
|
541
|
+
const totalCost = allComponents.reduce((sum, c) => sum + countComponentCost(c), 0)
|
|
542
|
+
// 2 rows: 1 + 2*3 + 1*1 = 8 components
|
|
543
|
+
expect(totalCost).toBe(8)
|
|
544
|
+
|
|
545
|
+
const { components, truncated } = truncateComponents(allComponents, { maxComponents: 10 })
|
|
546
|
+
expect(truncated).toBe(false)
|
|
547
|
+
expect(components).toHaveLength(1)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
test('truncates Container children when single Container exceeds budget', () => {
|
|
551
|
+
// 5 button rows: cost = 1 + 5*3 + 4*1 = 20 components
|
|
552
|
+
const segments = buildWorktreeTable(5)
|
|
553
|
+
const allComponents = segments.flatMap((s) => {
|
|
554
|
+
return s.type === 'components' ? s.components : []
|
|
555
|
+
})
|
|
556
|
+
expect(allComponents).toHaveLength(1) // single Container
|
|
557
|
+
expect(countComponentCost(allComponents[0]!)).toBe(20)
|
|
558
|
+
|
|
559
|
+
// Budget of 10: Container(1) + children that fit in 9
|
|
560
|
+
const { components, truncated } = truncateComponents(allComponents, { maxComponents: 10 })
|
|
561
|
+
expect(truncated).toBe(true)
|
|
562
|
+
expect(components).toHaveLength(1) // Container kept, not dropped
|
|
563
|
+
|
|
564
|
+
const container = components[0]!
|
|
565
|
+
expect(container.type).toBe(ComponentType.Container)
|
|
566
|
+
// Should have truncated children to fit within budget
|
|
567
|
+
const truncatedCost = countComponentCost(container)
|
|
568
|
+
expect(truncatedCost).toBeLessThanOrEqual(10)
|
|
569
|
+
expect(truncatedCost).toBeGreaterThan(1) // not empty
|
|
570
|
+
|
|
571
|
+
// Snapshot the truncated container structure
|
|
572
|
+
expect(container).toMatchInlineSnapshot(`
|
|
573
|
+
{
|
|
574
|
+
"components": [
|
|
575
|
+
{
|
|
576
|
+
"content": "**Source** kimaki
|
|
577
|
+
**Name** wt-0
|
|
578
|
+
**Status** merged
|
|
579
|
+
**Created** 0m ago
|
|
580
|
+
**Folder** /tmp/wt-0",
|
|
581
|
+
"type": 10,
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
"components": [
|
|
585
|
+
{
|
|
586
|
+
"custom_id": "action:del-0",
|
|
587
|
+
"disabled": false,
|
|
588
|
+
"label": "Delete",
|
|
589
|
+
"style": 2,
|
|
590
|
+
"type": 2,
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
"type": 1,
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
"divider": true,
|
|
597
|
+
"spacing": 1,
|
|
598
|
+
"type": 14,
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
"content": "**Source** kimaki
|
|
602
|
+
**Name** wt-1
|
|
603
|
+
**Status** merged
|
|
604
|
+
**Created** 1m ago
|
|
605
|
+
**Folder** /tmp/wt-1",
|
|
606
|
+
"type": 10,
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
"components": [
|
|
610
|
+
{
|
|
611
|
+
"custom_id": "action:del-1",
|
|
612
|
+
"disabled": false,
|
|
613
|
+
"label": "Delete",
|
|
614
|
+
"style": 2,
|
|
615
|
+
"type": 2,
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
"type": 1,
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
"type": 17,
|
|
622
|
+
}
|
|
623
|
+
`)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test('reserveCost leaves room for caller-appended components', () => {
|
|
627
|
+
const segments = buildWorktreeTable(5)
|
|
628
|
+
const allComponents = segments.flatMap((s) => {
|
|
629
|
+
return s.type === 'components' ? s.components : []
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
// Budget 12, reserve 2 → effective budget 10
|
|
633
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
634
|
+
maxComponents: 12,
|
|
635
|
+
reserveCost: 2,
|
|
636
|
+
})
|
|
637
|
+
expect(truncated).toBe(true)
|
|
638
|
+
const cost = components.reduce((sum, c) => sum + countComponentCost(c), 0)
|
|
639
|
+
expect(cost).toBeLessThanOrEqual(10)
|
|
640
|
+
expect(cost + 2).toBeLessThanOrEqual(12)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
test('does not leave trailing separator or partial button row', () => {
|
|
644
|
+
// 10 button rows at real budget (40, reserve 1 = 39 effective).
|
|
645
|
+
// Each row group = separator(1) + TextDisplay(1) + ActionRow+Button(2) = 4 cost.
|
|
646
|
+
// First group has no separator so costs 3.
|
|
647
|
+
// 9 full rows = 3 + 8*4 = 35, next group costs 4 → 39, fits.
|
|
648
|
+
// So all 10 rows should fit in budget 40 with reserve 1.
|
|
649
|
+
// With budget 15: 3 + 4 + 4 = 11, next group (4) → 15, fits → 4 rows.
|
|
650
|
+
const segments = buildWorktreeTable(10)
|
|
651
|
+
const allComponents = segments.flatMap((s) => {
|
|
652
|
+
return s.type === 'components' ? s.components : []
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
656
|
+
maxComponents: 15,
|
|
657
|
+
reserveCost: 1,
|
|
658
|
+
})
|
|
659
|
+
expect(truncated).toBe(true)
|
|
660
|
+
|
|
661
|
+
const container = components[0]!
|
|
662
|
+
expect(container.type).toBe(ComponentType.Container)
|
|
663
|
+
if (container.type !== ComponentType.Container) throw new Error('unreachable')
|
|
664
|
+
|
|
665
|
+
// Last child must not be a separator
|
|
666
|
+
const lastChild = container.components.at(-1)!
|
|
667
|
+
expect(lastChild.type).not.toBe(ComponentType.Separator)
|
|
668
|
+
|
|
669
|
+
// Every TextDisplay row must be followed by its ActionRow (no orphaned text)
|
|
670
|
+
for (let i = 0; i < container.components.length; i++) {
|
|
671
|
+
const child = container.components[i]!
|
|
672
|
+
if (child.type === ComponentType.TextDisplay) {
|
|
673
|
+
const next = container.components[i + 1]
|
|
674
|
+
// Next should be ActionRow or Separator (if this is a text-only row).
|
|
675
|
+
// For button rows, next must be ActionRow.
|
|
676
|
+
if (next && next.type === ComponentType.Separator) {
|
|
677
|
+
// text-only row followed by separator — fine
|
|
678
|
+
continue
|
|
679
|
+
}
|
|
680
|
+
if (next && next.type === ComponentType.ActionRow) {
|
|
681
|
+
// button row followed by its action row — fine
|
|
682
|
+
continue
|
|
683
|
+
}
|
|
684
|
+
// Last child is a TextDisplay — acceptable only if it's the final item
|
|
685
|
+
// (the group was included whole)
|
|
686
|
+
if (i === container.components.length - 1) {
|
|
687
|
+
// This means we have an orphaned TextDisplay at the end without its button.
|
|
688
|
+
// The group-based truncation should prevent this for button rows.
|
|
689
|
+
// For this test all rows have buttons, so this should not happen.
|
|
690
|
+
expect(next).toBeDefined()
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const totalCost = components.reduce((sum, c) => sum + countComponentCost(c), 0)
|
|
696
|
+
expect(totalCost).toBeLessThanOrEqual(14) // 15 - 1 reserve
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
test('handles multiple top-level components before the large Container', () => {
|
|
700
|
+
const segments = buildWorktreeTable(3)
|
|
701
|
+
const allComponents = segments.flatMap((s) => {
|
|
702
|
+
return s.type === 'components' ? s.components : []
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
// Prepend a TextDisplay (cost 1) before the Container
|
|
706
|
+
const textDisplay = { type: ComponentType.TextDisplay as const, content: 'Notice text' }
|
|
707
|
+
const combined = [textDisplay, ...allComponents]
|
|
708
|
+
|
|
709
|
+
// Budget 10: TextDisplay(1) + Container with truncated children(≤9)
|
|
710
|
+
const { components, truncated } = truncateComponents(combined, { maxComponents: 10 })
|
|
711
|
+
expect(truncated).toBe(true)
|
|
712
|
+
expect(components).toHaveLength(2) // TextDisplay + truncated Container
|
|
713
|
+
expect(components[0]!.type).toBe(ComponentType.TextDisplay)
|
|
714
|
+
expect(components[1]!.type).toBe(ComponentType.Container)
|
|
715
|
+
|
|
716
|
+
const totalCost = components.reduce((sum, c) => sum + countComponentCost(c), 0)
|
|
717
|
+
expect(totalCost).toBeLessThanOrEqual(10)
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
test('truncates when text size exceeds limit even if component count fits', () => {
|
|
721
|
+
// Build a table with long folder paths to simulate real /worktrees output
|
|
722
|
+
const header = '| Name | Folder |'
|
|
723
|
+
const sep = '|---|---|'
|
|
724
|
+
const rows = Array.from({ length: 10 }, (_, i) => {
|
|
725
|
+
// Each row has ~120 chars of text content
|
|
726
|
+
const longPath = `/Users/morse/.kimaki/worktrees/abcd1234/very-long-branch-name-${i}-feature`
|
|
727
|
+
return `| worktree-${i} | ${longPath} |`
|
|
728
|
+
}).join('\n')
|
|
729
|
+
const markdown = `${header}\n${sep}\n${rows}`
|
|
730
|
+
const segments = splitTablesFromMarkdown(markdown)
|
|
731
|
+
const allComponents = segments.flatMap((s) => {
|
|
732
|
+
return s.type === 'components' ? s.components : []
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
// Component count is fine (10 rows + 9 separators + 1 container = 20),
|
|
736
|
+
// but set text limit low to force text-based truncation
|
|
737
|
+
const { components, truncated } = truncateComponents(allComponents, {
|
|
738
|
+
maxComponents: 40,
|
|
739
|
+
maxTextSize: 500,
|
|
740
|
+
})
|
|
741
|
+
expect(truncated).toBe(true)
|
|
742
|
+
expect(components).toHaveLength(1)
|
|
743
|
+
|
|
744
|
+
// Verify total text is under the limit
|
|
745
|
+
const container = components[0]!
|
|
746
|
+
expect(container.type).toBe(ComponentType.Container)
|
|
747
|
+
if (container.type !== ComponentType.Container) throw new Error('unreachable')
|
|
748
|
+
|
|
749
|
+
const totalText = container.components.reduce((sum, child) => {
|
|
750
|
+
if ('content' in child && typeof child.content === 'string') {
|
|
751
|
+
return sum + child.content.length
|
|
752
|
+
}
|
|
753
|
+
return sum
|
|
754
|
+
}, 0)
|
|
755
|
+
expect(totalText).toBeLessThanOrEqual(500)
|
|
756
|
+
// Should have fewer than 10 rows
|
|
757
|
+
const textDisplays = container.components.filter((c) => c.type === ComponentType.TextDisplay)
|
|
758
|
+
expect(textDisplays.length).toBeLessThan(10)
|
|
759
|
+
expect(textDisplays.length).toBeGreaterThan(0)
|
|
760
|
+
})
|
|
761
|
+
})
|
package/src/format-tables.ts
CHANGED
|
@@ -58,7 +58,182 @@ type CalloutDescriptor = {
|
|
|
58
58
|
// Max 40 components per message (nested components count toward the limit).
|
|
59
59
|
// Row cost is dynamic now because a table row can render as a plain TextDisplay
|
|
60
60
|
// or as a TextDisplay plus an Action Row holding one or more buttons.
|
|
61
|
-
const MAX_COMPONENTS = 40
|
|
61
|
+
export const MAX_COMPONENTS = 40
|
|
62
|
+
|
|
63
|
+
// Discord caps total displayable text across all components at 4000 chars.
|
|
64
|
+
export const MAX_TEXT_SIZE = 4000
|
|
65
|
+
|
|
66
|
+
// Count cost of a single child inside a Container.
|
|
67
|
+
// ActionRow with N buttons = 1 + N, everything else = 1.
|
|
68
|
+
function childComponentCost(
|
|
69
|
+
child: APIComponentInContainer,
|
|
70
|
+
): number {
|
|
71
|
+
if ('components' in child && Array.isArray(child.components)) {
|
|
72
|
+
return 1 + child.components.length
|
|
73
|
+
}
|
|
74
|
+
return 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Count displayable text size of a component tree.
|
|
78
|
+
// Discord counts all text content (TextDisplay, button labels) toward a 4000-char limit.
|
|
79
|
+
function componentTextSize(component: { type: number; [key: string]: unknown }): number {
|
|
80
|
+
let size = 0
|
|
81
|
+
if ('content' in component && typeof component.content === 'string') {
|
|
82
|
+
size += component.content.length
|
|
83
|
+
}
|
|
84
|
+
if ('label' in component && typeof component.label === 'string') {
|
|
85
|
+
size += component.label.length
|
|
86
|
+
}
|
|
87
|
+
if ('components' in component && Array.isArray(component.components)) {
|
|
88
|
+
for (const child of component.components) {
|
|
89
|
+
size += componentTextSize(child as { type: number })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return size
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Count total component cost of a top-level component including nested children.
|
|
96
|
+
// Discord counts every component toward the 40-component budget:
|
|
97
|
+
// Container(3 children) = 4, ActionRow(2 buttons) = 3, TextDisplay = 1.
|
|
98
|
+
export function countComponentCost(
|
|
99
|
+
component: APIMessageTopLevelComponent,
|
|
100
|
+
): number {
|
|
101
|
+
if (component.type === ComponentType.Container) {
|
|
102
|
+
let cost = 1
|
|
103
|
+
for (const child of component.components) {
|
|
104
|
+
cost += childComponentCost(child)
|
|
105
|
+
}
|
|
106
|
+
return cost
|
|
107
|
+
}
|
|
108
|
+
if ('components' in component && Array.isArray(component.components)) {
|
|
109
|
+
return 1 + component.components.length
|
|
110
|
+
}
|
|
111
|
+
return 1
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Truncate an array of top-level components to stay within Discord limits:
|
|
115
|
+
// - 40 total components (nested children count)
|
|
116
|
+
// - 4000 chars total displayable text
|
|
117
|
+
// When a Container alone exceeds the budget, its children are truncated instead
|
|
118
|
+
// of dropping the entire Container (which would show nothing).
|
|
119
|
+
// reserveCost / reserveTextSize hold back budget for caller-appended components.
|
|
120
|
+
// maxComponents / maxTextSize override defaults (useful for testing).
|
|
121
|
+
export function truncateComponents(
|
|
122
|
+
components: APIMessageTopLevelComponent[],
|
|
123
|
+
{
|
|
124
|
+
reserveCost = 0,
|
|
125
|
+
reserveTextSize = 0,
|
|
126
|
+
maxComponents = MAX_COMPONENTS,
|
|
127
|
+
maxTextSize = MAX_TEXT_SIZE,
|
|
128
|
+
}: { reserveCost?: number; reserveTextSize?: number; maxComponents?: number; maxTextSize?: number } = {},
|
|
129
|
+
): { components: APIMessageTopLevelComponent[]; truncated: boolean } {
|
|
130
|
+
const componentBudget = maxComponents - reserveCost
|
|
131
|
+
const textBudget = maxTextSize - reserveTextSize
|
|
132
|
+
let totalCost = 0
|
|
133
|
+
let totalText = 0
|
|
134
|
+
const result: APIMessageTopLevelComponent[] = []
|
|
135
|
+
|
|
136
|
+
for (const component of components) {
|
|
137
|
+
const cost = countComponentCost(component)
|
|
138
|
+
const text = componentTextSize(component as { type: number })
|
|
139
|
+
|
|
140
|
+
if (totalCost + cost <= componentBudget && totalText + text <= textBudget) {
|
|
141
|
+
result.push(component)
|
|
142
|
+
totalCost += cost
|
|
143
|
+
totalText += text
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The component doesn't fit. If it's a Container, truncate its children
|
|
148
|
+
// to fill the remaining budget instead of dropping the whole thing.
|
|
149
|
+
if (component.type === ComponentType.Container) {
|
|
150
|
+
const remainingComponentBudget = componentBudget - totalCost - 1
|
|
151
|
+
const remainingTextBudget = textBudget - totalText
|
|
152
|
+
if (remainingComponentBudget > 0 && remainingTextBudget > 0) {
|
|
153
|
+
const truncatedChildren = truncateContainerChildren(
|
|
154
|
+
component.components,
|
|
155
|
+
remainingComponentBudget,
|
|
156
|
+
remainingTextBudget,
|
|
157
|
+
)
|
|
158
|
+
if (truncatedChildren.length > 0) {
|
|
159
|
+
result.push({
|
|
160
|
+
...component,
|
|
161
|
+
components: truncatedChildren,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { components: result, truncated: true }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { components: result, truncated: false }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Truncate a Container's children by separator-delimited row groups.
|
|
174
|
+
// A row group is everything between separators (e.g. [TextDisplay, ActionRow]).
|
|
175
|
+
// Either the full group fits or it's excluded — no trailing separators or
|
|
176
|
+
// partial rows (like a TextDisplay without its ActionRow button).
|
|
177
|
+
// Enforces both component count and text size budgets.
|
|
178
|
+
function truncateContainerChildren(
|
|
179
|
+
children: APIComponentInContainer[],
|
|
180
|
+
componentBudget: number,
|
|
181
|
+
textBudget: number,
|
|
182
|
+
): APIComponentInContainer[] {
|
|
183
|
+
const groups = groupBySeparator(children)
|
|
184
|
+
let cost = 0
|
|
185
|
+
let text = 0
|
|
186
|
+
const result: APIComponentInContainer[] = []
|
|
187
|
+
|
|
188
|
+
for (const group of groups) {
|
|
189
|
+
const groupCost = group.reduce((sum, child) => sum + childComponentCost(child), 0)
|
|
190
|
+
const groupText = group.reduce((sum, child) => sum + componentTextSize(child as { type: number }), 0)
|
|
191
|
+
if (cost + groupCost > componentBudget || text + groupText > textBudget) {
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
result.push(...group)
|
|
195
|
+
cost += groupCost
|
|
196
|
+
text += groupText
|
|
197
|
+
}
|
|
198
|
+
return result
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Split children into groups delimited by Separator components.
|
|
202
|
+
// Separators are prepended to the following group (they sit between rows).
|
|
203
|
+
// First group has no leading separator.
|
|
204
|
+
// Drops separator-only groups and strips leading separators from the first group
|
|
205
|
+
// to handle edge cases (children starting/ending with separators, consecutive separators).
|
|
206
|
+
function groupBySeparator(
|
|
207
|
+
children: APIComponentInContainer[],
|
|
208
|
+
): APIComponentInContainer[][] {
|
|
209
|
+
const rawGroups: APIComponentInContainer[][] = []
|
|
210
|
+
let current: APIComponentInContainer[] = []
|
|
211
|
+
|
|
212
|
+
for (const child of children) {
|
|
213
|
+
if (child.type === ComponentType.Separator && current.length > 0) {
|
|
214
|
+
rawGroups.push(current)
|
|
215
|
+
current = [child]
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
current.push(child)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (current.length > 0) {
|
|
222
|
+
rawGroups.push(current)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Clean up: strip leading separators from first group, drop separator-only groups
|
|
226
|
+
return rawGroups
|
|
227
|
+
.map((group, index) => {
|
|
228
|
+
if (index === 0) {
|
|
229
|
+
return group.filter((child) => child.type !== ComponentType.Separator)
|
|
230
|
+
}
|
|
231
|
+
return group
|
|
232
|
+
})
|
|
233
|
+
.filter((group) => {
|
|
234
|
+
return group.some((child) => child.type !== ComponentType.Separator)
|
|
235
|
+
})
|
|
236
|
+
}
|
|
62
237
|
|
|
63
238
|
/**
|
|
64
239
|
* Split markdown into text and table component segments.
|
|
@@ -670,15 +670,31 @@ export function registerInteractionHandler({
|
|
|
670
670
|
)
|
|
671
671
|
void notifyError(error, 'Interaction handler error')
|
|
672
672
|
try {
|
|
673
|
-
if (
|
|
674
|
-
interaction.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
673
|
+
if (interaction.isRepliable() && !interaction.replied) {
|
|
674
|
+
if (interaction.deferred) {
|
|
675
|
+
// Component interactions that used deferUpdate() set ephemeral
|
|
676
|
+
// to null. For those, followUp is correct (editReply would
|
|
677
|
+
// overwrite the original message). For deferReply() interactions
|
|
678
|
+
// (ephemeral is true/false), editReply resolves the pending reply.
|
|
679
|
+
const usedDeferUpdate =
|
|
680
|
+
interaction.isMessageComponent() &&
|
|
681
|
+
interaction.ephemeral === null
|
|
682
|
+
if (usedDeferUpdate) {
|
|
683
|
+
await interaction.followUp({
|
|
684
|
+
content: 'An error occurred processing this interaction.',
|
|
685
|
+
flags: MessageFlags.Ephemeral,
|
|
686
|
+
})
|
|
687
|
+
} else {
|
|
688
|
+
await interaction.editReply({
|
|
689
|
+
content: 'An error occurred processing this command.',
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
await interaction.reply({
|
|
694
|
+
content: 'An error occurred processing this command.',
|
|
695
|
+
flags: MessageFlags.Ephemeral,
|
|
696
|
+
})
|
|
697
|
+
}
|
|
682
698
|
}
|
|
683
699
|
} catch (replyError) {
|
|
684
700
|
interactionLogger.error(
|
package/src/voice-handler.ts
CHANGED
|
@@ -835,6 +835,16 @@ export function registerVoiceStateHandler({
|
|
|
835
835
|
return
|
|
836
836
|
}
|
|
837
837
|
|
|
838
|
+
// Check for Gemini API key before joining — voice requires it for live audio
|
|
839
|
+
const geminiApiKey =
|
|
840
|
+
(await getGeminiApiKey(appId)) || process.env.GEMINI_API_KEY
|
|
841
|
+
if (!geminiApiKey) {
|
|
842
|
+
voiceLogger.log(
|
|
843
|
+
`No Gemini API key available, skipping voice channel join. Use /audio-api-key in Discord to set one.`,
|
|
844
|
+
)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
|
|
838
848
|
try {
|
|
839
849
|
voiceLogger.log(
|
|
840
850
|
`Attempting to join voice channel: ${voiceChannel.name} (${voiceChannel.id})`,
|