kimaki 0.4.19 → 0.4.21
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/discordBot.js +140 -102
- package/package.json +3 -3
- package/src/discordBot.ts +170 -119
package/dist/discordBot.js
CHANGED
|
@@ -43,6 +43,32 @@ The user cannot see bash tool outputs. If there is important information in bash
|
|
|
43
43
|
|
|
44
44
|
Your current OpenCode session ID is: ${sessionId}
|
|
45
45
|
|
|
46
|
+
## permissions
|
|
47
|
+
|
|
48
|
+
Only users with these Discord permissions can send messages to the bot:
|
|
49
|
+
- Server Owner
|
|
50
|
+
- Administrator permission
|
|
51
|
+
- Manage Server permission
|
|
52
|
+
- "Kimaki" role (case-insensitive)
|
|
53
|
+
|
|
54
|
+
## changing the model
|
|
55
|
+
|
|
56
|
+
To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
|
|
57
|
+
|
|
58
|
+
\`\`\`json
|
|
59
|
+
{
|
|
60
|
+
"model": "anthropic/claude-sonnet-4-20250514"
|
|
61
|
+
}
|
|
62
|
+
\`\`\`
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
- \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
|
|
66
|
+
- \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
|
|
67
|
+
- \`"openai/gpt-4o"\` - GPT-4o
|
|
68
|
+
- \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
|
|
69
|
+
|
|
70
|
+
Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
|
|
71
|
+
|
|
46
72
|
## uploading files to discord
|
|
47
73
|
|
|
48
74
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
@@ -585,7 +611,6 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
|
|
|
585
611
|
if (!audioAttachment)
|
|
586
612
|
return null;
|
|
587
613
|
voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`);
|
|
588
|
-
await message.react('⏳');
|
|
589
614
|
await sendThreadMessage(thread, '🎤 Transcribing voice message...');
|
|
590
615
|
const audioResponse = await fetch(audioAttachment.url);
|
|
591
616
|
const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
|
|
@@ -940,30 +965,45 @@ function getToolSummaryText(part) {
|
|
|
940
965
|
if (part.type !== 'tool')
|
|
941
966
|
return '';
|
|
942
967
|
if (part.tool === 'edit') {
|
|
968
|
+
const filePath = part.state.input?.filePath || '';
|
|
943
969
|
const newString = part.state.input?.newString || '';
|
|
944
970
|
const oldString = part.state.input?.oldString || '';
|
|
945
971
|
const added = newString.split('\n').length;
|
|
946
972
|
const removed = oldString.split('\n').length;
|
|
947
|
-
|
|
973
|
+
const fileName = filePath.split('/').pop() || '';
|
|
974
|
+
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`;
|
|
948
975
|
}
|
|
949
976
|
if (part.tool === 'write') {
|
|
977
|
+
const filePath = part.state.input?.filePath || '';
|
|
950
978
|
const content = part.state.input?.content || '';
|
|
951
979
|
const lines = content.split('\n').length;
|
|
952
|
-
|
|
980
|
+
const fileName = filePath.split('/').pop() || '';
|
|
981
|
+
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
|
|
953
982
|
}
|
|
954
983
|
if (part.tool === 'webfetch') {
|
|
955
984
|
const url = part.state.input?.url || '';
|
|
956
985
|
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
957
|
-
return urlWithoutProtocol ?
|
|
986
|
+
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
|
|
958
987
|
}
|
|
959
|
-
if (part.tool === '
|
|
960
|
-
part.
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
part.
|
|
966
|
-
|
|
988
|
+
if (part.tool === 'read') {
|
|
989
|
+
const filePath = part.state.input?.filePath || '';
|
|
990
|
+
const fileName = filePath.split('/').pop() || '';
|
|
991
|
+
return fileName ? `*${fileName}*` : '';
|
|
992
|
+
}
|
|
993
|
+
if (part.tool === 'list') {
|
|
994
|
+
const path = part.state.input?.path || '';
|
|
995
|
+
const dirName = path.split('/').pop() || path;
|
|
996
|
+
return dirName ? `*${dirName}*` : '';
|
|
997
|
+
}
|
|
998
|
+
if (part.tool === 'glob') {
|
|
999
|
+
const pattern = part.state.input?.pattern || '';
|
|
1000
|
+
return pattern ? `*${pattern}*` : '';
|
|
1001
|
+
}
|
|
1002
|
+
if (part.tool === 'grep') {
|
|
1003
|
+
const pattern = part.state.input?.pattern || '';
|
|
1004
|
+
return pattern ? `*${pattern}*` : '';
|
|
1005
|
+
}
|
|
1006
|
+
if (part.tool === 'bash' || part.tool === 'task' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
967
1007
|
return '';
|
|
968
1008
|
}
|
|
969
1009
|
if (!part.state.input)
|
|
@@ -1036,17 +1076,21 @@ function formatPart(part) {
|
|
|
1036
1076
|
}
|
|
1037
1077
|
else if (part.tool === 'bash') {
|
|
1038
1078
|
const command = part.state.input?.command || '';
|
|
1079
|
+
const description = part.state.input?.description || '';
|
|
1039
1080
|
const isSingleLine = !command.includes('\n');
|
|
1040
1081
|
const hasBackticks = command.includes('`');
|
|
1041
|
-
if (isSingleLine && command.length <=
|
|
1082
|
+
if (isSingleLine && !hasBackticks && command.length <= 50) {
|
|
1042
1083
|
toolTitle = `\`${command}\``;
|
|
1043
1084
|
}
|
|
1044
|
-
else {
|
|
1045
|
-
toolTitle =
|
|
1085
|
+
else if (description) {
|
|
1086
|
+
toolTitle = `_${description}_`;
|
|
1087
|
+
}
|
|
1088
|
+
else if (stateTitle) {
|
|
1089
|
+
toolTitle = `_${stateTitle}_`;
|
|
1046
1090
|
}
|
|
1047
1091
|
}
|
|
1048
1092
|
else if (stateTitle) {
|
|
1049
|
-
toolTitle =
|
|
1093
|
+
toolTitle = `_${stateTitle}_`;
|
|
1050
1094
|
}
|
|
1051
1095
|
const icon = part.state.status === 'error' ? '⨯' : '◼︎';
|
|
1052
1096
|
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
@@ -1074,16 +1118,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1074
1118
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
1075
1119
|
// Track session start time
|
|
1076
1120
|
const sessionStartTime = Date.now();
|
|
1077
|
-
// Add processing reaction to original message
|
|
1078
|
-
if (originalMessage) {
|
|
1079
|
-
try {
|
|
1080
|
-
await originalMessage.react('⏳');
|
|
1081
|
-
discordLogger.log(`Added processing reaction to message`);
|
|
1082
|
-
}
|
|
1083
|
-
catch (e) {
|
|
1084
|
-
discordLogger.log(`Could not add processing reaction:`, e);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
1121
|
// Use default directory if not specified
|
|
1088
1122
|
const directory = projectDirectory || process.cwd();
|
|
1089
1123
|
sessionLogger.log(`Using directory: ${directory}`);
|
|
@@ -1134,39 +1168,70 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1134
1168
|
voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
|
|
1135
1169
|
existingController.abort(new Error('New request started'));
|
|
1136
1170
|
}
|
|
1137
|
-
if (abortControllers.has(session.id)) {
|
|
1138
|
-
abortControllers.get(session.id)?.abort(new Error('new reply'));
|
|
1139
|
-
}
|
|
1140
1171
|
const abortController = new AbortController();
|
|
1141
|
-
// Store this controller for this session
|
|
1142
1172
|
abortControllers.set(session.id, abortController);
|
|
1173
|
+
if (existingController) {
|
|
1174
|
+
await new Promise((resolve) => { setTimeout(resolve, 200); });
|
|
1175
|
+
if (abortController.signal.aborted) {
|
|
1176
|
+
sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (abortController.signal.aborted) {
|
|
1181
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1143
1184
|
const eventsResult = await getClient().event.subscribe({
|
|
1144
1185
|
signal: abortController.signal,
|
|
1145
1186
|
});
|
|
1187
|
+
if (abortController.signal.aborted) {
|
|
1188
|
+
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1146
1191
|
const events = eventsResult.stream;
|
|
1147
1192
|
sessionLogger.log(`Subscribed to OpenCode events`);
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
.
|
|
1152
|
-
.all(thread.id);
|
|
1153
|
-
// Pre-populate map with existing messages
|
|
1154
|
-
for (const row of existingParts) {
|
|
1155
|
-
try {
|
|
1156
|
-
const message = await thread.messages.fetch(row.message_id);
|
|
1157
|
-
if (message) {
|
|
1158
|
-
partIdToMessage.set(row.part_id, message);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
catch (error) {
|
|
1162
|
-
voiceLogger.log(`Could not fetch message ${row.message_id} for part ${row.part_id}`);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1193
|
+
const sentPartIds = new Set(getDatabase()
|
|
1194
|
+
.prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
|
|
1195
|
+
.all(thread.id)
|
|
1196
|
+
.map((row) => row.part_id));
|
|
1165
1197
|
let currentParts = [];
|
|
1166
1198
|
let stopTyping = null;
|
|
1167
1199
|
let usedModel;
|
|
1168
1200
|
let usedProviderID;
|
|
1169
1201
|
let tokensUsedInSession = 0;
|
|
1202
|
+
let typingInterval = null;
|
|
1203
|
+
function startTyping() {
|
|
1204
|
+
if (abortController.signal.aborted) {
|
|
1205
|
+
discordLogger.log(`Not starting typing, already aborted`);
|
|
1206
|
+
return () => { };
|
|
1207
|
+
}
|
|
1208
|
+
if (typingInterval) {
|
|
1209
|
+
clearInterval(typingInterval);
|
|
1210
|
+
typingInterval = null;
|
|
1211
|
+
}
|
|
1212
|
+
thread.sendTyping().catch((e) => {
|
|
1213
|
+
discordLogger.log(`Failed to send initial typing: ${e}`);
|
|
1214
|
+
});
|
|
1215
|
+
typingInterval = setInterval(() => {
|
|
1216
|
+
thread.sendTyping().catch((e) => {
|
|
1217
|
+
discordLogger.log(`Failed to send periodic typing: ${e}`);
|
|
1218
|
+
});
|
|
1219
|
+
}, 8000);
|
|
1220
|
+
if (!abortController.signal.aborted) {
|
|
1221
|
+
abortController.signal.addEventListener('abort', () => {
|
|
1222
|
+
if (typingInterval) {
|
|
1223
|
+
clearInterval(typingInterval);
|
|
1224
|
+
typingInterval = null;
|
|
1225
|
+
}
|
|
1226
|
+
}, { once: true });
|
|
1227
|
+
}
|
|
1228
|
+
return () => {
|
|
1229
|
+
if (typingInterval) {
|
|
1230
|
+
clearInterval(typingInterval);
|
|
1231
|
+
typingInterval = null;
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1170
1235
|
const sendPartMessage = async (part) => {
|
|
1171
1236
|
const content = formatPart(part) + '\n\n';
|
|
1172
1237
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1174,12 +1239,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1174
1239
|
return;
|
|
1175
1240
|
}
|
|
1176
1241
|
// Skip if already sent
|
|
1177
|
-
if (
|
|
1242
|
+
if (sentPartIds.has(part.id)) {
|
|
1178
1243
|
return;
|
|
1179
1244
|
}
|
|
1180
1245
|
try {
|
|
1181
1246
|
const firstMessage = await sendThreadMessage(thread, content);
|
|
1182
|
-
|
|
1247
|
+
sentPartIds.add(part.id);
|
|
1183
1248
|
// Store part-message mapping in database
|
|
1184
1249
|
getDatabase()
|
|
1185
1250
|
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
@@ -1190,48 +1255,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1190
1255
|
}
|
|
1191
1256
|
};
|
|
1192
1257
|
const eventHandler = async () => {
|
|
1193
|
-
// Local typing function for this session
|
|
1194
|
-
// Outer-scoped interval for typing notifications. Only one at a time.
|
|
1195
|
-
let typingInterval = null;
|
|
1196
|
-
function startTyping(thread) {
|
|
1197
|
-
if (abortController.signal.aborted) {
|
|
1198
|
-
discordLogger.log(`Not starting typing, already aborted`);
|
|
1199
|
-
return () => { };
|
|
1200
|
-
}
|
|
1201
|
-
// Clear any previous typing interval
|
|
1202
|
-
if (typingInterval) {
|
|
1203
|
-
clearInterval(typingInterval);
|
|
1204
|
-
typingInterval = null;
|
|
1205
|
-
}
|
|
1206
|
-
// Send initial typing
|
|
1207
|
-
thread.sendTyping().catch((e) => {
|
|
1208
|
-
discordLogger.log(`Failed to send initial typing: ${e}`);
|
|
1209
|
-
});
|
|
1210
|
-
// Set up interval to send typing every 8 seconds
|
|
1211
|
-
typingInterval = setInterval(() => {
|
|
1212
|
-
thread.sendTyping().catch((e) => {
|
|
1213
|
-
discordLogger.log(`Failed to send periodic typing: ${e}`);
|
|
1214
|
-
});
|
|
1215
|
-
}, 8000);
|
|
1216
|
-
// Only add listener if not already aborted
|
|
1217
|
-
if (!abortController.signal.aborted) {
|
|
1218
|
-
abortController.signal.addEventListener('abort', () => {
|
|
1219
|
-
if (typingInterval) {
|
|
1220
|
-
clearInterval(typingInterval);
|
|
1221
|
-
typingInterval = null;
|
|
1222
|
-
}
|
|
1223
|
-
}, {
|
|
1224
|
-
once: true,
|
|
1225
|
-
});
|
|
1226
|
-
}
|
|
1227
|
-
// Return stop function
|
|
1228
|
-
return () => {
|
|
1229
|
-
if (typingInterval) {
|
|
1230
|
-
clearInterval(typingInterval);
|
|
1231
|
-
typingInterval = null;
|
|
1232
|
-
}
|
|
1233
|
-
};
|
|
1234
|
-
}
|
|
1235
1258
|
try {
|
|
1236
1259
|
let assistantMessageId;
|
|
1237
1260
|
for await (const event of events) {
|
|
@@ -1269,12 +1292,16 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1269
1292
|
}
|
|
1270
1293
|
// Start typing on step-start
|
|
1271
1294
|
if (part.type === 'step-start') {
|
|
1272
|
-
stopTyping = startTyping(
|
|
1295
|
+
stopTyping = startTyping();
|
|
1273
1296
|
}
|
|
1274
1297
|
// Send tool parts immediately when they start running
|
|
1275
1298
|
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1276
1299
|
await sendPartMessage(part);
|
|
1277
1300
|
}
|
|
1301
|
+
// Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
|
|
1302
|
+
if (part.type === 'reasoning') {
|
|
1303
|
+
await sendPartMessage(part);
|
|
1304
|
+
}
|
|
1278
1305
|
// Check if this is a step-finish part
|
|
1279
1306
|
if (part.type === 'step-finish') {
|
|
1280
1307
|
// Send all parts accumulated so far to Discord
|
|
@@ -1288,7 +1315,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1288
1315
|
setTimeout(() => {
|
|
1289
1316
|
if (abortController.signal.aborted)
|
|
1290
1317
|
return;
|
|
1291
|
-
stopTyping = startTyping(
|
|
1318
|
+
stopTyping = startTyping();
|
|
1292
1319
|
}, 300);
|
|
1293
1320
|
}
|
|
1294
1321
|
}
|
|
@@ -1361,7 +1388,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1361
1388
|
finally {
|
|
1362
1389
|
// Send any remaining parts that weren't sent
|
|
1363
1390
|
for (const part of currentParts) {
|
|
1364
|
-
if (!
|
|
1391
|
+
if (!sentPartIds.has(part.id)) {
|
|
1365
1392
|
try {
|
|
1366
1393
|
await sendPartMessage(part);
|
|
1367
1394
|
}
|
|
@@ -1403,8 +1430,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1403
1430
|
}
|
|
1404
1431
|
};
|
|
1405
1432
|
try {
|
|
1406
|
-
// Start the event handler
|
|
1407
1433
|
const eventHandlerPromise = eventHandler();
|
|
1434
|
+
if (abortController.signal.aborted) {
|
|
1435
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
stopTyping = startTyping();
|
|
1408
1439
|
let response;
|
|
1409
1440
|
if (parsedCommand?.isCommand) {
|
|
1410
1441
|
sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
|
|
@@ -1571,11 +1602,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1571
1602
|
return;
|
|
1572
1603
|
}
|
|
1573
1604
|
}
|
|
1574
|
-
// Check if user is authoritative (server owner or has
|
|
1605
|
+
// Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
|
|
1575
1606
|
if (message.guild && message.member) {
|
|
1576
1607
|
const isOwner = message.member.id === message.guild.ownerId;
|
|
1577
1608
|
const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
1578
|
-
|
|
1609
|
+
const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
1610
|
+
const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
|
|
1611
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
1579
1612
|
return;
|
|
1580
1613
|
}
|
|
1581
1614
|
}
|
|
@@ -2488,12 +2521,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2488
2521
|
const member = newState.member || oldState.member;
|
|
2489
2522
|
if (!member)
|
|
2490
2523
|
return;
|
|
2491
|
-
// Check if user is admin
|
|
2524
|
+
// Check if user is admin, server owner, can manage server, or has Kimaki role
|
|
2492
2525
|
const guild = newState.guild || oldState.guild;
|
|
2493
2526
|
const isOwner = member.id === guild.ownerId;
|
|
2494
2527
|
const isAdmin = member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
2495
|
-
|
|
2496
|
-
|
|
2528
|
+
const canManageServer = member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
2529
|
+
const hasKimakiRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
|
|
2530
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
2497
2531
|
return;
|
|
2498
2532
|
}
|
|
2499
2533
|
// Handle admin leaving voice channel
|
|
@@ -2512,7 +2546,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2512
2546
|
if (m.id === member.id || m.user.bot)
|
|
2513
2547
|
return false;
|
|
2514
2548
|
return (m.id === guild.ownerId ||
|
|
2515
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
2549
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
2550
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
2551
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
|
|
2516
2552
|
});
|
|
2517
2553
|
if (!hasOtherAdmins) {
|
|
2518
2554
|
voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
|
|
@@ -2542,7 +2578,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2542
2578
|
if (m.id === member.id || m.user.bot)
|
|
2543
2579
|
return false;
|
|
2544
2580
|
return (m.id === guild.ownerId ||
|
|
2545
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
2581
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
2582
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
2583
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
|
|
2546
2584
|
});
|
|
2547
2585
|
if (!hasOtherAdmins) {
|
|
2548
2586
|
voiceLogger.log(`Following admin to new channel: ${newState.channel?.name}`);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.21",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
8
|
"prepublishOnly": "pnpm tsc",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"bin.js"
|
|
22
22
|
],
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@opencode-ai/plugin": "^1.0.
|
|
24
|
+
"@opencode-ai/plugin": "^1.0.193",
|
|
25
25
|
"@types/better-sqlite3": "^7.6.13",
|
|
26
26
|
"@types/bun": "latest",
|
|
27
27
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@discordjs/opus": "^0.10.0",
|
|
36
36
|
"@discordjs/voice": "^0.19.0",
|
|
37
37
|
"@google/genai": "^1.34.0",
|
|
38
|
-
"@opencode-ai/sdk": "^1.0.
|
|
38
|
+
"@opencode-ai/sdk": "^1.0.193",
|
|
39
39
|
"@purinton/resampler": "^1.0.4",
|
|
40
40
|
"@snazzah/davey": "^0.1.6",
|
|
41
41
|
"ai": "^5.0.114",
|
package/src/discordBot.ts
CHANGED
|
@@ -61,7 +61,6 @@ type ParsedCommand = {
|
|
|
61
61
|
} | {
|
|
62
62
|
isCommand: false
|
|
63
63
|
}
|
|
64
|
-
|
|
65
64
|
function parseSlashCommand(text: string): ParsedCommand {
|
|
66
65
|
const trimmed = text.trim()
|
|
67
66
|
if (!trimmed.startsWith('/')) {
|
|
@@ -84,6 +83,32 @@ The user cannot see bash tool outputs. If there is important information in bash
|
|
|
84
83
|
|
|
85
84
|
Your current OpenCode session ID is: ${sessionId}
|
|
86
85
|
|
|
86
|
+
## permissions
|
|
87
|
+
|
|
88
|
+
Only users with these Discord permissions can send messages to the bot:
|
|
89
|
+
- Server Owner
|
|
90
|
+
- Administrator permission
|
|
91
|
+
- Manage Server permission
|
|
92
|
+
- "Kimaki" role (case-insensitive)
|
|
93
|
+
|
|
94
|
+
## changing the model
|
|
95
|
+
|
|
96
|
+
To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
|
|
97
|
+
|
|
98
|
+
\`\`\`json
|
|
99
|
+
{
|
|
100
|
+
"model": "anthropic/claude-sonnet-4-20250514"
|
|
101
|
+
}
|
|
102
|
+
\`\`\`
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
- \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
|
|
106
|
+
- \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
|
|
107
|
+
- \`"openai/gpt-4o"\` - GPT-4o
|
|
108
|
+
- \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
|
|
109
|
+
|
|
110
|
+
Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
|
|
111
|
+
|
|
87
112
|
## uploading files to discord
|
|
88
113
|
|
|
89
114
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
@@ -805,7 +830,6 @@ async function processVoiceAttachment({
|
|
|
805
830
|
`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
|
|
806
831
|
)
|
|
807
832
|
|
|
808
|
-
await message.react('⏳')
|
|
809
833
|
await sendThreadMessage(thread, '🎤 Transcribing voice message...')
|
|
810
834
|
|
|
811
835
|
const audioResponse = await fetch(audioAttachment.url)
|
|
@@ -1259,35 +1283,52 @@ function getToolSummaryText(part: Part): string {
|
|
|
1259
1283
|
if (part.type !== 'tool') return ''
|
|
1260
1284
|
|
|
1261
1285
|
if (part.tool === 'edit') {
|
|
1286
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
1262
1287
|
const newString = (part.state.input?.newString as string) || ''
|
|
1263
1288
|
const oldString = (part.state.input?.oldString as string) || ''
|
|
1264
1289
|
const added = newString.split('\n').length
|
|
1265
1290
|
const removed = oldString.split('\n').length
|
|
1266
|
-
|
|
1291
|
+
const fileName = filePath.split('/').pop() || ''
|
|
1292
|
+
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
|
|
1267
1293
|
}
|
|
1268
1294
|
|
|
1269
1295
|
if (part.tool === 'write') {
|
|
1296
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
1270
1297
|
const content = (part.state.input?.content as string) || ''
|
|
1271
1298
|
const lines = content.split('\n').length
|
|
1272
|
-
|
|
1299
|
+
const fileName = filePath.split('/').pop() || ''
|
|
1300
|
+
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
|
|
1273
1301
|
}
|
|
1274
1302
|
|
|
1275
1303
|
if (part.tool === 'webfetch') {
|
|
1276
1304
|
const url = (part.state.input?.url as string) || ''
|
|
1277
1305
|
const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
|
|
1278
|
-
return urlWithoutProtocol ?
|
|
1306
|
+
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
|
|
1279
1307
|
}
|
|
1280
1308
|
|
|
1281
|
-
if (
|
|
1282
|
-
part.
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
part.
|
|
1289
|
-
|
|
1290
|
-
|
|
1309
|
+
if (part.tool === 'read') {
|
|
1310
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
1311
|
+
const fileName = filePath.split('/').pop() || ''
|
|
1312
|
+
return fileName ? `*${fileName}*` : ''
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (part.tool === 'list') {
|
|
1316
|
+
const path = (part.state.input?.path as string) || ''
|
|
1317
|
+
const dirName = path.split('/').pop() || path
|
|
1318
|
+
return dirName ? `*${dirName}*` : ''
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (part.tool === 'glob') {
|
|
1322
|
+
const pattern = (part.state.input?.pattern as string) || ''
|
|
1323
|
+
return pattern ? `*${pattern}*` : ''
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (part.tool === 'grep') {
|
|
1327
|
+
const pattern = (part.state.input?.pattern as string) || ''
|
|
1328
|
+
return pattern ? `*${pattern}*` : ''
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (part.tool === 'bash' || part.tool === 'task' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
1291
1332
|
return ''
|
|
1292
1333
|
}
|
|
1293
1334
|
|
|
@@ -1372,15 +1413,18 @@ function formatPart(part: Part): string {
|
|
|
1372
1413
|
toolTitle = part.state.error || 'error'
|
|
1373
1414
|
} else if (part.tool === 'bash') {
|
|
1374
1415
|
const command = (part.state.input?.command as string) || ''
|
|
1416
|
+
const description = (part.state.input?.description as string) || ''
|
|
1375
1417
|
const isSingleLine = !command.includes('\n')
|
|
1376
1418
|
const hasBackticks = command.includes('`')
|
|
1377
|
-
if (isSingleLine && command.length <=
|
|
1419
|
+
if (isSingleLine && !hasBackticks && command.length <= 50) {
|
|
1378
1420
|
toolTitle = `\`${command}\``
|
|
1379
|
-
} else {
|
|
1380
|
-
toolTitle =
|
|
1421
|
+
} else if (description) {
|
|
1422
|
+
toolTitle = `_${description}_`
|
|
1423
|
+
} else if (stateTitle) {
|
|
1424
|
+
toolTitle = `_${stateTitle}_`
|
|
1381
1425
|
}
|
|
1382
1426
|
} else if (stateTitle) {
|
|
1383
|
-
toolTitle =
|
|
1427
|
+
toolTitle = `_${stateTitle}_`
|
|
1384
1428
|
}
|
|
1385
1429
|
|
|
1386
1430
|
const icon = part.state.status === 'error' ? '⨯' : '◼︎'
|
|
@@ -1430,16 +1474,6 @@ async function handleOpencodeSession({
|
|
|
1430
1474
|
// Track session start time
|
|
1431
1475
|
const sessionStartTime = Date.now()
|
|
1432
1476
|
|
|
1433
|
-
// Add processing reaction to original message
|
|
1434
|
-
if (originalMessage) {
|
|
1435
|
-
try {
|
|
1436
|
-
await originalMessage.react('⏳')
|
|
1437
|
-
discordLogger.log(`Added processing reaction to message`)
|
|
1438
|
-
} catch (e) {
|
|
1439
|
-
discordLogger.log(`Could not add processing reaction:`, e)
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
1477
|
// Use default directory if not specified
|
|
1444
1478
|
const directory = projectDirectory || process.cwd()
|
|
1445
1479
|
sessionLogger.log(`Using directory: ${directory}`)
|
|
@@ -1507,40 +1541,40 @@ async function handleOpencodeSession({
|
|
|
1507
1541
|
existingController.abort(new Error('New request started'))
|
|
1508
1542
|
}
|
|
1509
1543
|
|
|
1510
|
-
if (abortControllers.has(session.id)) {
|
|
1511
|
-
abortControllers.get(session.id)?.abort(new Error('new reply'))
|
|
1512
|
-
}
|
|
1513
1544
|
const abortController = new AbortController()
|
|
1514
|
-
// Store this controller for this session
|
|
1515
1545
|
abortControllers.set(session.id, abortController)
|
|
1516
1546
|
|
|
1547
|
+
if (existingController) {
|
|
1548
|
+
await new Promise((resolve) => { setTimeout(resolve, 200) })
|
|
1549
|
+
if (abortController.signal.aborted) {
|
|
1550
|
+
sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
|
|
1551
|
+
return
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (abortController.signal.aborted) {
|
|
1556
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
|
|
1557
|
+
return
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1517
1560
|
const eventsResult = await getClient().event.subscribe({
|
|
1518
1561
|
signal: abortController.signal,
|
|
1519
1562
|
})
|
|
1563
|
+
|
|
1564
|
+
if (abortController.signal.aborted) {
|
|
1565
|
+
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
|
|
1566
|
+
return
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1520
1569
|
const events = eventsResult.stream
|
|
1521
1570
|
sessionLogger.log(`Subscribed to OpenCode events`)
|
|
1522
1571
|
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
.all(thread.id) as { part_id: string; message_id: string }[]
|
|
1530
|
-
|
|
1531
|
-
// Pre-populate map with existing messages
|
|
1532
|
-
for (const row of existingParts) {
|
|
1533
|
-
try {
|
|
1534
|
-
const message = await thread.messages.fetch(row.message_id)
|
|
1535
|
-
if (message) {
|
|
1536
|
-
partIdToMessage.set(row.part_id, message)
|
|
1537
|
-
}
|
|
1538
|
-
} catch (error) {
|
|
1539
|
-
voiceLogger.log(
|
|
1540
|
-
`Could not fetch message ${row.message_id} for part ${row.part_id}`,
|
|
1541
|
-
)
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1572
|
+
const sentPartIds = new Set<string>(
|
|
1573
|
+
(getDatabase()
|
|
1574
|
+
.prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
|
|
1575
|
+
.all(thread.id) as { part_id: string }[])
|
|
1576
|
+
.map((row) => row.part_id)
|
|
1577
|
+
)
|
|
1544
1578
|
|
|
1545
1579
|
let currentParts: Part[] = []
|
|
1546
1580
|
let stopTyping: (() => void) | null = null
|
|
@@ -1548,6 +1582,49 @@ async function handleOpencodeSession({
|
|
|
1548
1582
|
let usedProviderID: string | undefined
|
|
1549
1583
|
let tokensUsedInSession = 0
|
|
1550
1584
|
|
|
1585
|
+
let typingInterval: NodeJS.Timeout | null = null
|
|
1586
|
+
|
|
1587
|
+
function startTyping(): () => void {
|
|
1588
|
+
if (abortController.signal.aborted) {
|
|
1589
|
+
discordLogger.log(`Not starting typing, already aborted`)
|
|
1590
|
+
return () => {}
|
|
1591
|
+
}
|
|
1592
|
+
if (typingInterval) {
|
|
1593
|
+
clearInterval(typingInterval)
|
|
1594
|
+
typingInterval = null
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
thread.sendTyping().catch((e) => {
|
|
1598
|
+
discordLogger.log(`Failed to send initial typing: ${e}`)
|
|
1599
|
+
})
|
|
1600
|
+
|
|
1601
|
+
typingInterval = setInterval(() => {
|
|
1602
|
+
thread.sendTyping().catch((e) => {
|
|
1603
|
+
discordLogger.log(`Failed to send periodic typing: ${e}`)
|
|
1604
|
+
})
|
|
1605
|
+
}, 8000)
|
|
1606
|
+
|
|
1607
|
+
if (!abortController.signal.aborted) {
|
|
1608
|
+
abortController.signal.addEventListener(
|
|
1609
|
+
'abort',
|
|
1610
|
+
() => {
|
|
1611
|
+
if (typingInterval) {
|
|
1612
|
+
clearInterval(typingInterval)
|
|
1613
|
+
typingInterval = null
|
|
1614
|
+
}
|
|
1615
|
+
},
|
|
1616
|
+
{ once: true },
|
|
1617
|
+
)
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
return () => {
|
|
1621
|
+
if (typingInterval) {
|
|
1622
|
+
clearInterval(typingInterval)
|
|
1623
|
+
typingInterval = null
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1551
1628
|
const sendPartMessage = async (part: Part) => {
|
|
1552
1629
|
const content = formatPart(part) + '\n\n'
|
|
1553
1630
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1556,13 +1633,13 @@ async function handleOpencodeSession({
|
|
|
1556
1633
|
}
|
|
1557
1634
|
|
|
1558
1635
|
// Skip if already sent
|
|
1559
|
-
if (
|
|
1636
|
+
if (sentPartIds.has(part.id)) {
|
|
1560
1637
|
return
|
|
1561
1638
|
}
|
|
1562
1639
|
|
|
1563
1640
|
try {
|
|
1564
1641
|
const firstMessage = await sendThreadMessage(thread, content)
|
|
1565
|
-
|
|
1642
|
+
sentPartIds.add(part.id)
|
|
1566
1643
|
|
|
1567
1644
|
// Store part-message mapping in database
|
|
1568
1645
|
getDatabase()
|
|
@@ -1576,58 +1653,6 @@ async function handleOpencodeSession({
|
|
|
1576
1653
|
}
|
|
1577
1654
|
|
|
1578
1655
|
const eventHandler = async () => {
|
|
1579
|
-
// Local typing function for this session
|
|
1580
|
-
// Outer-scoped interval for typing notifications. Only one at a time.
|
|
1581
|
-
let typingInterval: NodeJS.Timeout | null = null
|
|
1582
|
-
|
|
1583
|
-
function startTyping(thread: ThreadChannel): () => void {
|
|
1584
|
-
if (abortController.signal.aborted) {
|
|
1585
|
-
discordLogger.log(`Not starting typing, already aborted`)
|
|
1586
|
-
return () => {}
|
|
1587
|
-
}
|
|
1588
|
-
// Clear any previous typing interval
|
|
1589
|
-
if (typingInterval) {
|
|
1590
|
-
clearInterval(typingInterval)
|
|
1591
|
-
typingInterval = null
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
// Send initial typing
|
|
1595
|
-
thread.sendTyping().catch((e) => {
|
|
1596
|
-
discordLogger.log(`Failed to send initial typing: ${e}`)
|
|
1597
|
-
})
|
|
1598
|
-
|
|
1599
|
-
// Set up interval to send typing every 8 seconds
|
|
1600
|
-
typingInterval = setInterval(() => {
|
|
1601
|
-
thread.sendTyping().catch((e) => {
|
|
1602
|
-
discordLogger.log(`Failed to send periodic typing: ${e}`)
|
|
1603
|
-
})
|
|
1604
|
-
}, 8000)
|
|
1605
|
-
|
|
1606
|
-
// Only add listener if not already aborted
|
|
1607
|
-
if (!abortController.signal.aborted) {
|
|
1608
|
-
abortController.signal.addEventListener(
|
|
1609
|
-
'abort',
|
|
1610
|
-
() => {
|
|
1611
|
-
if (typingInterval) {
|
|
1612
|
-
clearInterval(typingInterval)
|
|
1613
|
-
typingInterval = null
|
|
1614
|
-
}
|
|
1615
|
-
},
|
|
1616
|
-
{
|
|
1617
|
-
once: true,
|
|
1618
|
-
},
|
|
1619
|
-
)
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
// Return stop function
|
|
1623
|
-
return () => {
|
|
1624
|
-
if (typingInterval) {
|
|
1625
|
-
clearInterval(typingInterval)
|
|
1626
|
-
typingInterval = null
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
1656
|
try {
|
|
1632
1657
|
let assistantMessageId: string | undefined
|
|
1633
1658
|
|
|
@@ -1678,7 +1703,7 @@ async function handleOpencodeSession({
|
|
|
1678
1703
|
|
|
1679
1704
|
// Start typing on step-start
|
|
1680
1705
|
if (part.type === 'step-start') {
|
|
1681
|
-
stopTyping = startTyping(
|
|
1706
|
+
stopTyping = startTyping()
|
|
1682
1707
|
}
|
|
1683
1708
|
|
|
1684
1709
|
// Send tool parts immediately when they start running
|
|
@@ -1686,6 +1711,11 @@ async function handleOpencodeSession({
|
|
|
1686
1711
|
await sendPartMessage(part)
|
|
1687
1712
|
}
|
|
1688
1713
|
|
|
1714
|
+
// Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
|
|
1715
|
+
if (part.type === 'reasoning') {
|
|
1716
|
+
await sendPartMessage(part)
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1689
1719
|
// Check if this is a step-finish part
|
|
1690
1720
|
if (part.type === 'step-finish') {
|
|
1691
1721
|
|
|
@@ -1699,7 +1729,7 @@ async function handleOpencodeSession({
|
|
|
1699
1729
|
// start typing in a moment, so that if the session finished, because step-finish is at the end of the message, we do not show typing status
|
|
1700
1730
|
setTimeout(() => {
|
|
1701
1731
|
if (abortController.signal.aborted) return
|
|
1702
|
-
stopTyping = startTyping(
|
|
1732
|
+
stopTyping = startTyping()
|
|
1703
1733
|
}, 300)
|
|
1704
1734
|
}
|
|
1705
1735
|
} else if (event.type === 'session.error') {
|
|
@@ -1790,7 +1820,7 @@ async function handleOpencodeSession({
|
|
|
1790
1820
|
} finally {
|
|
1791
1821
|
// Send any remaining parts that weren't sent
|
|
1792
1822
|
for (const part of currentParts) {
|
|
1793
|
-
if (!
|
|
1823
|
+
if (!sentPartIds.has(part.id)) {
|
|
1794
1824
|
try {
|
|
1795
1825
|
await sendPartMessage(part)
|
|
1796
1826
|
} catch (error) {
|
|
@@ -1841,9 +1871,15 @@ async function handleOpencodeSession({
|
|
|
1841
1871
|
}
|
|
1842
1872
|
|
|
1843
1873
|
try {
|
|
1844
|
-
// Start the event handler
|
|
1845
1874
|
const eventHandlerPromise = eventHandler()
|
|
1846
1875
|
|
|
1876
|
+
if (abortController.signal.aborted) {
|
|
1877
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
|
|
1878
|
+
return
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
stopTyping = startTyping()
|
|
1882
|
+
|
|
1847
1883
|
let response: { data?: unknown; error?: unknown; response: Response }
|
|
1848
1884
|
if (parsedCommand?.isCommand) {
|
|
1849
1885
|
sessionLogger.log(
|
|
@@ -2063,14 +2099,20 @@ export async function startDiscordBot({
|
|
|
2063
2099
|
}
|
|
2064
2100
|
}
|
|
2065
2101
|
|
|
2066
|
-
// Check if user is authoritative (server owner or has
|
|
2102
|
+
// Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
|
|
2067
2103
|
if (message.guild && message.member) {
|
|
2068
2104
|
const isOwner = message.member.id === message.guild.ownerId
|
|
2069
2105
|
const isAdmin = message.member.permissions.has(
|
|
2070
2106
|
PermissionsBitField.Flags.Administrator,
|
|
2071
2107
|
)
|
|
2108
|
+
const canManageServer = message.member.permissions.has(
|
|
2109
|
+
PermissionsBitField.Flags.ManageGuild,
|
|
2110
|
+
)
|
|
2111
|
+
const hasKimakiRole = message.member.roles.cache.some(
|
|
2112
|
+
(role) => role.name.toLowerCase() === 'kimaki',
|
|
2113
|
+
)
|
|
2072
2114
|
|
|
2073
|
-
if (!isOwner && !isAdmin) {
|
|
2115
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
2074
2116
|
return
|
|
2075
2117
|
}
|
|
2076
2118
|
}
|
|
@@ -3259,15 +3301,20 @@ export async function startDiscordBot({
|
|
|
3259
3301
|
const member = newState.member || oldState.member
|
|
3260
3302
|
if (!member) return
|
|
3261
3303
|
|
|
3262
|
-
// Check if user is admin
|
|
3304
|
+
// Check if user is admin, server owner, can manage server, or has Kimaki role
|
|
3263
3305
|
const guild = newState.guild || oldState.guild
|
|
3264
3306
|
const isOwner = member.id === guild.ownerId
|
|
3265
3307
|
const isAdmin = member.permissions.has(
|
|
3266
3308
|
PermissionsBitField.Flags.Administrator,
|
|
3267
3309
|
)
|
|
3310
|
+
const canManageServer = member.permissions.has(
|
|
3311
|
+
PermissionsBitField.Flags.ManageGuild,
|
|
3312
|
+
)
|
|
3313
|
+
const hasKimakiRole = member.roles.cache.some(
|
|
3314
|
+
(role) => role.name.toLowerCase() === 'kimaki',
|
|
3315
|
+
)
|
|
3268
3316
|
|
|
3269
|
-
if (!isOwner && !isAdmin) {
|
|
3270
|
-
// Not an admin user, ignore
|
|
3317
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
3271
3318
|
return
|
|
3272
3319
|
}
|
|
3273
3320
|
|
|
@@ -3293,7 +3340,9 @@ export async function startDiscordBot({
|
|
|
3293
3340
|
if (m.id === member.id || m.user.bot) return false
|
|
3294
3341
|
return (
|
|
3295
3342
|
m.id === guild.ownerId ||
|
|
3296
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
3343
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
3344
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
3345
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
|
|
3297
3346
|
)
|
|
3298
3347
|
})
|
|
3299
3348
|
|
|
@@ -3338,7 +3387,9 @@ export async function startDiscordBot({
|
|
|
3338
3387
|
if (m.id === member.id || m.user.bot) return false
|
|
3339
3388
|
return (
|
|
3340
3389
|
m.id === guild.ownerId ||
|
|
3341
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
3390
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
3391
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
3392
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
|
|
3342
3393
|
)
|
|
3343
3394
|
})
|
|
3344
3395
|
|