kimaki 0.4.20 → 0.4.22
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.js +1 -1
- package/dist/discordBot.js +166 -107
- package/dist/format-tables.js +93 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/markdown.js +3 -3
- package/dist/tools.js +2 -4
- package/dist/utils.js +31 -0
- package/package.json +1 -2
- package/src/cli.ts +1 -1
- package/src/discordBot.ts +191 -123
- package/src/format-tables.test.ts +440 -0
- package/src/format-tables.ts +106 -0
- package/src/markdown.ts +3 -3
- package/src/tools.ts +2 -4
- package/src/utils.ts +37 -0
package/dist/cli.js
CHANGED
|
@@ -87,7 +87,7 @@ async function registerCommands(token, appId) {
|
|
|
87
87
|
})
|
|
88
88
|
.toJSON(),
|
|
89
89
|
new SlashCommandBuilder()
|
|
90
|
-
.setName('
|
|
90
|
+
.setName('create-new-project')
|
|
91
91
|
.setDescription('Create a new project folder, initialize git, and start a session')
|
|
92
92
|
.addStringOption((option) => {
|
|
93
93
|
option
|
package/dist/discordBot.js
CHANGED
|
@@ -16,6 +16,7 @@ import * as prism from 'prism-media';
|
|
|
16
16
|
import dedent from 'string-dedent';
|
|
17
17
|
import { transcribeAudio } from './voice.js';
|
|
18
18
|
import { extractTagsArrays, extractNonXmlContent } from './xml.js';
|
|
19
|
+
import { formatMarkdownTables } from './format-tables.js';
|
|
19
20
|
import prettyMilliseconds from 'pretty-ms';
|
|
20
21
|
import { createLogger } from './logger.js';
|
|
21
22
|
import { isAbortError } from './utils.js';
|
|
@@ -43,6 +44,32 @@ The user cannot see bash tool outputs. If there is important information in bash
|
|
|
43
44
|
|
|
44
45
|
Your current OpenCode session ID is: ${sessionId}
|
|
45
46
|
|
|
47
|
+
## permissions
|
|
48
|
+
|
|
49
|
+
Only users with these Discord permissions can send messages to the bot:
|
|
50
|
+
- Server Owner
|
|
51
|
+
- Administrator permission
|
|
52
|
+
- Manage Server permission
|
|
53
|
+
- "Kimaki" role (case-insensitive)
|
|
54
|
+
|
|
55
|
+
## changing the model
|
|
56
|
+
|
|
57
|
+
To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
|
|
58
|
+
|
|
59
|
+
\`\`\`json
|
|
60
|
+
{
|
|
61
|
+
"model": "anthropic/claude-sonnet-4-20250514"
|
|
62
|
+
}
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
- \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
|
|
67
|
+
- \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
|
|
68
|
+
- \`"openai/gpt-4o"\` - GPT-4o
|
|
69
|
+
- \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
|
|
70
|
+
|
|
71
|
+
Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
|
|
72
|
+
|
|
46
73
|
## uploading files to discord
|
|
47
74
|
|
|
48
75
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
@@ -538,6 +565,7 @@ async function getOpenPort() {
|
|
|
538
565
|
*/
|
|
539
566
|
async function sendThreadMessage(thread, content) {
|
|
540
567
|
const MAX_LENGTH = 2000;
|
|
568
|
+
content = formatMarkdownTables(content);
|
|
541
569
|
content = escapeBackticksInCodeBlocks(content);
|
|
542
570
|
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
|
|
543
571
|
if (chunks.length > 1) {
|
|
@@ -585,7 +613,6 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
|
|
|
585
613
|
if (!audioAttachment)
|
|
586
614
|
return null;
|
|
587
615
|
voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`);
|
|
588
|
-
await message.react('⏳');
|
|
589
616
|
await sendThreadMessage(thread, '🎤 Transcribing voice message...');
|
|
590
617
|
const audioResponse = await fetch(audioAttachment.url);
|
|
591
618
|
const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
|
|
@@ -793,7 +820,7 @@ function escapeInlineCode(text) {
|
|
|
793
820
|
.replace(/(?<!\\)`(?!`)/g, '\\`') // Single backticks (not already escaped or part of double/triple)
|
|
794
821
|
.replace(/\|\|/g, '\\|\\|'); // Double pipes (spoiler syntax)
|
|
795
822
|
}
|
|
796
|
-
function resolveTextChannel(channel) {
|
|
823
|
+
async function resolveTextChannel(channel) {
|
|
797
824
|
if (!channel) {
|
|
798
825
|
return null;
|
|
799
826
|
}
|
|
@@ -803,9 +830,12 @@ function resolveTextChannel(channel) {
|
|
|
803
830
|
if (channel.type === ChannelType.PublicThread ||
|
|
804
831
|
channel.type === ChannelType.PrivateThread ||
|
|
805
832
|
channel.type === ChannelType.AnnouncementThread) {
|
|
806
|
-
const
|
|
807
|
-
if (
|
|
808
|
-
|
|
833
|
+
const parentId = channel.parentId;
|
|
834
|
+
if (parentId) {
|
|
835
|
+
const parent = await channel.guild.channels.fetch(parentId);
|
|
836
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
837
|
+
return parent;
|
|
838
|
+
}
|
|
809
839
|
}
|
|
810
840
|
}
|
|
811
841
|
return null;
|
|
@@ -940,32 +970,55 @@ function getToolSummaryText(part) {
|
|
|
940
970
|
if (part.type !== 'tool')
|
|
941
971
|
return '';
|
|
942
972
|
if (part.tool === 'edit') {
|
|
973
|
+
const filePath = part.state.input?.filePath || '';
|
|
943
974
|
const newString = part.state.input?.newString || '';
|
|
944
975
|
const oldString = part.state.input?.oldString || '';
|
|
945
976
|
const added = newString.split('\n').length;
|
|
946
977
|
const removed = oldString.split('\n').length;
|
|
947
|
-
|
|
978
|
+
const fileName = filePath.split('/').pop() || '';
|
|
979
|
+
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`;
|
|
948
980
|
}
|
|
949
981
|
if (part.tool === 'write') {
|
|
982
|
+
const filePath = part.state.input?.filePath || '';
|
|
950
983
|
const content = part.state.input?.content || '';
|
|
951
984
|
const lines = content.split('\n').length;
|
|
952
|
-
|
|
985
|
+
const fileName = filePath.split('/').pop() || '';
|
|
986
|
+
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
|
|
953
987
|
}
|
|
954
988
|
if (part.tool === 'webfetch') {
|
|
955
989
|
const url = part.state.input?.url || '';
|
|
956
990
|
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
957
|
-
return urlWithoutProtocol ?
|
|
991
|
+
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
|
|
992
|
+
}
|
|
993
|
+
if (part.tool === 'read') {
|
|
994
|
+
const filePath = part.state.input?.filePath || '';
|
|
995
|
+
const fileName = filePath.split('/').pop() || '';
|
|
996
|
+
return fileName ? `*${fileName}*` : '';
|
|
997
|
+
}
|
|
998
|
+
if (part.tool === 'list') {
|
|
999
|
+
const path = part.state.input?.path || '';
|
|
1000
|
+
const dirName = path.split('/').pop() || path;
|
|
1001
|
+
return dirName ? `*${dirName}*` : '';
|
|
1002
|
+
}
|
|
1003
|
+
if (part.tool === 'glob') {
|
|
1004
|
+
const pattern = part.state.input?.pattern || '';
|
|
1005
|
+
return pattern ? `*${pattern}*` : '';
|
|
958
1006
|
}
|
|
959
|
-
if (part.tool === '
|
|
960
|
-
part.
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
part.tool === 'task' ||
|
|
965
|
-
part.tool === 'todoread' ||
|
|
966
|
-
part.tool === 'todowrite') {
|
|
1007
|
+
if (part.tool === 'grep') {
|
|
1008
|
+
const pattern = part.state.input?.pattern || '';
|
|
1009
|
+
return pattern ? `*${pattern}*` : '';
|
|
1010
|
+
}
|
|
1011
|
+
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
967
1012
|
return '';
|
|
968
1013
|
}
|
|
1014
|
+
if (part.tool === 'task') {
|
|
1015
|
+
const description = part.state.input?.description || '';
|
|
1016
|
+
return description ? `_${description}_` : '';
|
|
1017
|
+
}
|
|
1018
|
+
if (part.tool === 'skill') {
|
|
1019
|
+
const name = part.state.input?.name || '';
|
|
1020
|
+
return name ? `_${name}_` : '';
|
|
1021
|
+
}
|
|
969
1022
|
if (!part.state.input)
|
|
970
1023
|
return '';
|
|
971
1024
|
const inputFields = Object.entries(part.state.input)
|
|
@@ -985,20 +1038,13 @@ function formatTodoList(part) {
|
|
|
985
1038
|
if (part.type !== 'tool' || part.tool !== 'todowrite')
|
|
986
1039
|
return '';
|
|
987
1040
|
const todos = part.state.input?.todos || [];
|
|
988
|
-
|
|
1041
|
+
const activeIndex = todos.findIndex((todo) => {
|
|
1042
|
+
return todo.status === 'in_progress';
|
|
1043
|
+
});
|
|
1044
|
+
const activeTodo = todos[activeIndex];
|
|
1045
|
+
if (activeIndex === -1 || !activeTodo)
|
|
989
1046
|
return '';
|
|
990
|
-
return
|
|
991
|
-
.map((todo, i) => {
|
|
992
|
-
const num = `${i + 1}.`;
|
|
993
|
-
if (todo.status === 'in_progress') {
|
|
994
|
-
return `${num} **${todo.content}**`;
|
|
995
|
-
}
|
|
996
|
-
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
997
|
-
return `${num} ~~${todo.content}~~`;
|
|
998
|
-
}
|
|
999
|
-
return `${num} ${todo.content}`;
|
|
1000
|
-
})
|
|
1001
|
-
.join('\n');
|
|
1047
|
+
return `${activeIndex + 1}. **${activeTodo.content}**`;
|
|
1002
1048
|
}
|
|
1003
1049
|
function formatPart(part) {
|
|
1004
1050
|
if (part.type === 'text') {
|
|
@@ -1036,19 +1082,18 @@ function formatPart(part) {
|
|
|
1036
1082
|
}
|
|
1037
1083
|
else if (part.tool === 'bash') {
|
|
1038
1084
|
const command = part.state.input?.command || '';
|
|
1085
|
+
const description = part.state.input?.description || '';
|
|
1039
1086
|
const isSingleLine = !command.includes('\n');
|
|
1040
|
-
const
|
|
1041
|
-
if (isSingleLine && command.length <=
|
|
1087
|
+
const hasUnderscores = command.includes('_');
|
|
1088
|
+
if (isSingleLine && !hasUnderscores && command.length <= 50) {
|
|
1042
1089
|
toolTitle = `_${command}_`;
|
|
1043
1090
|
}
|
|
1044
|
-
else {
|
|
1045
|
-
toolTitle =
|
|
1091
|
+
else if (description) {
|
|
1092
|
+
toolTitle = `_${description}_`;
|
|
1093
|
+
}
|
|
1094
|
+
else if (stateTitle) {
|
|
1095
|
+
toolTitle = `_${stateTitle}_`;
|
|
1046
1096
|
}
|
|
1047
|
-
}
|
|
1048
|
-
else if (part.tool === 'edit' || part.tool === 'write') {
|
|
1049
|
-
const filePath = part.state.input?.filePath || '';
|
|
1050
|
-
const fileName = filePath.split('/').pop() || filePath;
|
|
1051
|
-
toolTitle = fileName ? `_${fileName}_` : '';
|
|
1052
1097
|
}
|
|
1053
1098
|
else if (stateTitle) {
|
|
1054
1099
|
toolTitle = `_${stateTitle}_`;
|
|
@@ -1160,6 +1205,41 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1160
1205
|
let usedModel;
|
|
1161
1206
|
let usedProviderID;
|
|
1162
1207
|
let tokensUsedInSession = 0;
|
|
1208
|
+
let lastDisplayedContextPercentage = 0;
|
|
1209
|
+
let modelContextLimit;
|
|
1210
|
+
let typingInterval = null;
|
|
1211
|
+
function startTyping() {
|
|
1212
|
+
if (abortController.signal.aborted) {
|
|
1213
|
+
discordLogger.log(`Not starting typing, already aborted`);
|
|
1214
|
+
return () => { };
|
|
1215
|
+
}
|
|
1216
|
+
if (typingInterval) {
|
|
1217
|
+
clearInterval(typingInterval);
|
|
1218
|
+
typingInterval = null;
|
|
1219
|
+
}
|
|
1220
|
+
thread.sendTyping().catch((e) => {
|
|
1221
|
+
discordLogger.log(`Failed to send initial typing: ${e}`);
|
|
1222
|
+
});
|
|
1223
|
+
typingInterval = setInterval(() => {
|
|
1224
|
+
thread.sendTyping().catch((e) => {
|
|
1225
|
+
discordLogger.log(`Failed to send periodic typing: ${e}`);
|
|
1226
|
+
});
|
|
1227
|
+
}, 8000);
|
|
1228
|
+
if (!abortController.signal.aborted) {
|
|
1229
|
+
abortController.signal.addEventListener('abort', () => {
|
|
1230
|
+
if (typingInterval) {
|
|
1231
|
+
clearInterval(typingInterval);
|
|
1232
|
+
typingInterval = null;
|
|
1233
|
+
}
|
|
1234
|
+
}, { once: true });
|
|
1235
|
+
}
|
|
1236
|
+
return () => {
|
|
1237
|
+
if (typingInterval) {
|
|
1238
|
+
clearInterval(typingInterval);
|
|
1239
|
+
typingInterval = null;
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1163
1243
|
const sendPartMessage = async (part) => {
|
|
1164
1244
|
const content = formatPart(part) + '\n\n';
|
|
1165
1245
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1183,48 +1263,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1183
1263
|
}
|
|
1184
1264
|
};
|
|
1185
1265
|
const eventHandler = async () => {
|
|
1186
|
-
// Local typing function for this session
|
|
1187
|
-
// Outer-scoped interval for typing notifications. Only one at a time.
|
|
1188
|
-
let typingInterval = null;
|
|
1189
|
-
function startTyping(thread) {
|
|
1190
|
-
if (abortController.signal.aborted) {
|
|
1191
|
-
discordLogger.log(`Not starting typing, already aborted`);
|
|
1192
|
-
return () => { };
|
|
1193
|
-
}
|
|
1194
|
-
// Clear any previous typing interval
|
|
1195
|
-
if (typingInterval) {
|
|
1196
|
-
clearInterval(typingInterval);
|
|
1197
|
-
typingInterval = null;
|
|
1198
|
-
}
|
|
1199
|
-
// Send initial typing
|
|
1200
|
-
thread.sendTyping().catch((e) => {
|
|
1201
|
-
discordLogger.log(`Failed to send initial typing: ${e}`);
|
|
1202
|
-
});
|
|
1203
|
-
// Set up interval to send typing every 8 seconds
|
|
1204
|
-
typingInterval = setInterval(() => {
|
|
1205
|
-
thread.sendTyping().catch((e) => {
|
|
1206
|
-
discordLogger.log(`Failed to send periodic typing: ${e}`);
|
|
1207
|
-
});
|
|
1208
|
-
}, 8000);
|
|
1209
|
-
// Only add listener if not already aborted
|
|
1210
|
-
if (!abortController.signal.aborted) {
|
|
1211
|
-
abortController.signal.addEventListener('abort', () => {
|
|
1212
|
-
if (typingInterval) {
|
|
1213
|
-
clearInterval(typingInterval);
|
|
1214
|
-
typingInterval = null;
|
|
1215
|
-
}
|
|
1216
|
-
}, {
|
|
1217
|
-
once: true,
|
|
1218
|
-
});
|
|
1219
|
-
}
|
|
1220
|
-
// Return stop function
|
|
1221
|
-
return () => {
|
|
1222
|
-
if (typingInterval) {
|
|
1223
|
-
clearInterval(typingInterval);
|
|
1224
|
-
typingInterval = null;
|
|
1225
|
-
}
|
|
1226
|
-
};
|
|
1227
|
-
}
|
|
1228
1266
|
try {
|
|
1229
1267
|
let assistantMessageId;
|
|
1230
1268
|
for await (const event of events) {
|
|
@@ -1242,6 +1280,29 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1242
1280
|
assistantMessageId = msg.id;
|
|
1243
1281
|
usedModel = msg.modelID;
|
|
1244
1282
|
usedProviderID = msg.providerID;
|
|
1283
|
+
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
1284
|
+
if (!modelContextLimit) {
|
|
1285
|
+
try {
|
|
1286
|
+
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
1287
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
1288
|
+
const model = provider?.models?.[usedModel];
|
|
1289
|
+
if (model?.limit?.context) {
|
|
1290
|
+
modelContextLimit = model.limit.context;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
catch (e) {
|
|
1294
|
+
sessionLogger.error('Failed to fetch provider info for context limit:', e);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (modelContextLimit) {
|
|
1298
|
+
const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
|
|
1299
|
+
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
|
|
1300
|
+
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
1301
|
+
lastDisplayedContextPercentage = thresholdCrossed;
|
|
1302
|
+
await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1245
1306
|
}
|
|
1246
1307
|
}
|
|
1247
1308
|
else if (event.type === 'message.part.updated') {
|
|
@@ -1262,7 +1323,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1262
1323
|
}
|
|
1263
1324
|
// Start typing on step-start
|
|
1264
1325
|
if (part.type === 'step-start') {
|
|
1265
|
-
stopTyping = startTyping(
|
|
1326
|
+
stopTyping = startTyping();
|
|
1266
1327
|
}
|
|
1267
1328
|
// Send tool parts immediately when they start running
|
|
1268
1329
|
if (part.type === 'tool' && part.state.status === 'running') {
|
|
@@ -1285,7 +1346,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1285
1346
|
setTimeout(() => {
|
|
1286
1347
|
if (abortController.signal.aborted)
|
|
1287
1348
|
return;
|
|
1288
|
-
stopTyping = startTyping(
|
|
1349
|
+
stopTyping = startTyping();
|
|
1289
1350
|
}, 300);
|
|
1290
1351
|
}
|
|
1291
1352
|
}
|
|
@@ -1405,14 +1466,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1405
1466
|
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
|
|
1406
1467
|
return;
|
|
1407
1468
|
}
|
|
1408
|
-
|
|
1409
|
-
try {
|
|
1410
|
-
await originalMessage.react('⏳');
|
|
1411
|
-
}
|
|
1412
|
-
catch (e) {
|
|
1413
|
-
discordLogger.log(`Could not add processing reaction:`, e);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1469
|
+
stopTyping = startTyping();
|
|
1416
1470
|
let response;
|
|
1417
1471
|
if (parsedCommand?.isCommand) {
|
|
1418
1472
|
sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
|
|
@@ -1579,11 +1633,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1579
1633
|
return;
|
|
1580
1634
|
}
|
|
1581
1635
|
}
|
|
1582
|
-
// Check if user is authoritative (server owner or has
|
|
1636
|
+
// Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
|
|
1583
1637
|
if (message.guild && message.member) {
|
|
1584
1638
|
const isOwner = message.member.id === message.guild.ownerId;
|
|
1585
1639
|
const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
1586
|
-
|
|
1640
|
+
const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
1641
|
+
const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
|
|
1642
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
1587
1643
|
return;
|
|
1588
1644
|
}
|
|
1589
1645
|
}
|
|
@@ -1749,9 +1805,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1749
1805
|
const focusedValue = interaction.options.getFocused();
|
|
1750
1806
|
// Get the channel's project directory from its topic
|
|
1751
1807
|
let projectDirectory;
|
|
1752
|
-
if (interaction.channel
|
|
1753
|
-
interaction.channel
|
|
1754
|
-
const textChannel = resolveTextChannel(interaction.channel);
|
|
1808
|
+
if (interaction.channel) {
|
|
1809
|
+
const textChannel = await resolveTextChannel(interaction.channel);
|
|
1755
1810
|
if (textChannel) {
|
|
1756
1811
|
const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
|
|
1757
1812
|
if (channelAppId && channelAppId !== currentAppId) {
|
|
@@ -1814,9 +1869,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1814
1869
|
const currentQuery = (parts[parts.length - 1] || '').trim();
|
|
1815
1870
|
// Get the channel's project directory from its topic
|
|
1816
1871
|
let projectDirectory;
|
|
1817
|
-
if (interaction.channel
|
|
1818
|
-
interaction.channel
|
|
1819
|
-
const textChannel = resolveTextChannel(interaction.channel);
|
|
1872
|
+
if (interaction.channel) {
|
|
1873
|
+
const textChannel = await resolveTextChannel(interaction.channel);
|
|
1820
1874
|
if (textChannel) {
|
|
1821
1875
|
const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
|
|
1822
1876
|
if (channelAppId && channelAppId !== currentAppId) {
|
|
@@ -2078,7 +2132,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2078
2132
|
if (partsToRender.length > 0) {
|
|
2079
2133
|
const combinedContent = partsToRender
|
|
2080
2134
|
.map((p) => p.content)
|
|
2081
|
-
.join('\n
|
|
2135
|
+
.join('\n');
|
|
2082
2136
|
const discordMessage = await sendThreadMessage(thread, combinedContent);
|
|
2083
2137
|
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
2084
2138
|
const transaction = getDatabase().transaction((parts) => {
|
|
@@ -2143,7 +2197,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2143
2197
|
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
2144
2198
|
}
|
|
2145
2199
|
}
|
|
2146
|
-
else if (command.commandName === '
|
|
2200
|
+
else if (command.commandName === 'create-new-project') {
|
|
2147
2201
|
await command.deferReply({ ephemeral: false });
|
|
2148
2202
|
const projectName = command.options.getString('name', true);
|
|
2149
2203
|
const guild = command.guild;
|
|
@@ -2339,7 +2393,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2339
2393
|
});
|
|
2340
2394
|
return;
|
|
2341
2395
|
}
|
|
2342
|
-
const textChannel = resolveTextChannel(channel);
|
|
2396
|
+
const textChannel = await resolveTextChannel(channel);
|
|
2343
2397
|
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2344
2398
|
if (!directory) {
|
|
2345
2399
|
await command.reply({
|
|
@@ -2401,7 +2455,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2401
2455
|
});
|
|
2402
2456
|
return;
|
|
2403
2457
|
}
|
|
2404
|
-
const textChannel = resolveTextChannel(channel);
|
|
2458
|
+
const textChannel = await resolveTextChannel(channel);
|
|
2405
2459
|
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2406
2460
|
if (!directory) {
|
|
2407
2461
|
await command.reply({
|
|
@@ -2496,12 +2550,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2496
2550
|
const member = newState.member || oldState.member;
|
|
2497
2551
|
if (!member)
|
|
2498
2552
|
return;
|
|
2499
|
-
// Check if user is admin
|
|
2553
|
+
// Check if user is admin, server owner, can manage server, or has Kimaki role
|
|
2500
2554
|
const guild = newState.guild || oldState.guild;
|
|
2501
2555
|
const isOwner = member.id === guild.ownerId;
|
|
2502
2556
|
const isAdmin = member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
2503
|
-
|
|
2504
|
-
|
|
2557
|
+
const canManageServer = member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
2558
|
+
const hasKimakiRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
|
|
2559
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
2505
2560
|
return;
|
|
2506
2561
|
}
|
|
2507
2562
|
// Handle admin leaving voice channel
|
|
@@ -2520,7 +2575,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2520
2575
|
if (m.id === member.id || m.user.bot)
|
|
2521
2576
|
return false;
|
|
2522
2577
|
return (m.id === guild.ownerId ||
|
|
2523
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
2578
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
2579
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
2580
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
|
|
2524
2581
|
});
|
|
2525
2582
|
if (!hasOtherAdmins) {
|
|
2526
2583
|
voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
|
|
@@ -2550,7 +2607,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2550
2607
|
if (m.id === member.id || m.user.bot)
|
|
2551
2608
|
return false;
|
|
2552
2609
|
return (m.id === guild.ownerId ||
|
|
2553
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
2610
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
2611
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
2612
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
|
|
2554
2613
|
});
|
|
2555
2614
|
if (!hasOtherAdmins) {
|
|
2556
2615
|
voiceLogger.log(`Following admin to new channel: ${newState.channel?.name}`);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Lexer } from 'marked';
|
|
2
|
+
export function formatMarkdownTables(markdown) {
|
|
3
|
+
const lexer = new Lexer();
|
|
4
|
+
const tokens = lexer.lex(markdown);
|
|
5
|
+
let result = '';
|
|
6
|
+
for (const token of tokens) {
|
|
7
|
+
if (token.type === 'table') {
|
|
8
|
+
result += formatTableToken(token);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
result += token.raw;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
function formatTableToken(table) {
|
|
17
|
+
const headers = table.header.map((cell) => {
|
|
18
|
+
return extractCellText(cell.tokens);
|
|
19
|
+
});
|
|
20
|
+
const rows = table.rows.map((row) => {
|
|
21
|
+
return row.map((cell) => {
|
|
22
|
+
return extractCellText(cell.tokens);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const columnWidths = calculateColumnWidths(headers, rows);
|
|
26
|
+
const lines = [];
|
|
27
|
+
lines.push(formatRow(headers, columnWidths));
|
|
28
|
+
lines.push(formatSeparator(columnWidths));
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
lines.push(formatRow(row, columnWidths));
|
|
31
|
+
}
|
|
32
|
+
return '```\n' + lines.join('\n') + '\n```\n';
|
|
33
|
+
}
|
|
34
|
+
function extractCellText(tokens) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
for (const token of tokens) {
|
|
37
|
+
parts.push(extractTokenText(token));
|
|
38
|
+
}
|
|
39
|
+
return parts.join('').trim();
|
|
40
|
+
}
|
|
41
|
+
function extractTokenText(token) {
|
|
42
|
+
switch (token.type) {
|
|
43
|
+
case 'text':
|
|
44
|
+
case 'codespan':
|
|
45
|
+
case 'escape':
|
|
46
|
+
return token.text;
|
|
47
|
+
case 'link':
|
|
48
|
+
return token.href;
|
|
49
|
+
case 'image':
|
|
50
|
+
return token.href;
|
|
51
|
+
case 'strong':
|
|
52
|
+
case 'em':
|
|
53
|
+
case 'del':
|
|
54
|
+
return token.tokens ? extractCellText(token.tokens) : token.text;
|
|
55
|
+
case 'br':
|
|
56
|
+
return ' ';
|
|
57
|
+
default: {
|
|
58
|
+
const tokenAny = token;
|
|
59
|
+
if (tokenAny.tokens && Array.isArray(tokenAny.tokens)) {
|
|
60
|
+
return extractCellText(tokenAny.tokens);
|
|
61
|
+
}
|
|
62
|
+
if (typeof tokenAny.text === 'string') {
|
|
63
|
+
return tokenAny.text;
|
|
64
|
+
}
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function calculateColumnWidths(headers, rows) {
|
|
70
|
+
const widths = headers.map((h) => {
|
|
71
|
+
return h.length;
|
|
72
|
+
});
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
for (let i = 0; i < row.length; i++) {
|
|
75
|
+
const cell = row[i] ?? '';
|
|
76
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return widths;
|
|
80
|
+
}
|
|
81
|
+
function formatRow(cells, widths) {
|
|
82
|
+
const paddedCells = cells.map((cell, i) => {
|
|
83
|
+
return cell.padEnd(widths[i] ?? 0);
|
|
84
|
+
});
|
|
85
|
+
return paddedCells.join(' ');
|
|
86
|
+
}
|
|
87
|
+
function formatSeparator(widths) {
|
|
88
|
+
return widths
|
|
89
|
+
.map((w) => {
|
|
90
|
+
return '-'.repeat(w);
|
|
91
|
+
})
|
|
92
|
+
.join(' ');
|
|
93
|
+
}
|