kimaki 0.4.20 → 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.
@@ -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
- return `(+${added}-${removed})`;
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
- return `(${lines} line${lines === 1 ? '' : 's'})`;
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 ? `(${urlWithoutProtocol})` : '';
986
+ return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
987
+ }
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}*` : '';
958
997
  }
959
- if (part.tool === 'bash' ||
960
- part.tool === 'read' ||
961
- part.tool === 'list' ||
962
- part.tool === 'glob' ||
963
- part.tool === 'grep' ||
964
- part.tool === 'task' ||
965
- part.tool === 'todoread' ||
966
- part.tool === 'todowrite') {
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,19 +1076,18 @@ 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 <= 120 && !hasBackticks) {
1042
- toolTitle = `_${command}_`;
1082
+ if (isSingleLine && !hasBackticks && command.length <= 50) {
1083
+ toolTitle = `\`${command}\``;
1043
1084
  }
1044
- else {
1045
- toolTitle = stateTitle ? `_${stateTitle}_` : '';
1085
+ else if (description) {
1086
+ toolTitle = `_${description}_`;
1087
+ }
1088
+ else if (stateTitle) {
1089
+ toolTitle = `_${stateTitle}_`;
1046
1090
  }
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
1091
  }
1053
1092
  else if (stateTitle) {
1054
1093
  toolTitle = `_${stateTitle}_`;
@@ -1160,6 +1199,39 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1160
1199
  let usedModel;
1161
1200
  let usedProviderID;
1162
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
+ }
1163
1235
  const sendPartMessage = async (part) => {
1164
1236
  const content = formatPart(part) + '\n\n';
1165
1237
  if (!content.trim() || content.length === 0) {
@@ -1183,48 +1255,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1183
1255
  }
1184
1256
  };
1185
1257
  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
1258
  try {
1229
1259
  let assistantMessageId;
1230
1260
  for await (const event of events) {
@@ -1262,7 +1292,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1262
1292
  }
1263
1293
  // Start typing on step-start
1264
1294
  if (part.type === 'step-start') {
1265
- stopTyping = startTyping(thread);
1295
+ stopTyping = startTyping();
1266
1296
  }
1267
1297
  // Send tool parts immediately when they start running
1268
1298
  if (part.type === 'tool' && part.state.status === 'running') {
@@ -1285,7 +1315,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1285
1315
  setTimeout(() => {
1286
1316
  if (abortController.signal.aborted)
1287
1317
  return;
1288
- stopTyping = startTyping(thread);
1318
+ stopTyping = startTyping();
1289
1319
  }, 300);
1290
1320
  }
1291
1321
  }
@@ -1405,14 +1435,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1405
1435
  sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
1406
1436
  return;
1407
1437
  }
1408
- if (originalMessage) {
1409
- try {
1410
- await originalMessage.react('⏳');
1411
- }
1412
- catch (e) {
1413
- discordLogger.log(`Could not add processing reaction:`, e);
1414
- }
1415
- }
1438
+ stopTyping = startTyping();
1416
1439
  let response;
1417
1440
  if (parsedCommand?.isCommand) {
1418
1441
  sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
@@ -1579,11 +1602,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1579
1602
  return;
1580
1603
  }
1581
1604
  }
1582
- // Check if user is authoritative (server owner or has admin permissions)
1605
+ // Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
1583
1606
  if (message.guild && message.member) {
1584
1607
  const isOwner = message.member.id === message.guild.ownerId;
1585
1608
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
1586
- if (!isOwner && !isAdmin) {
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) {
1587
1612
  return;
1588
1613
  }
1589
1614
  }
@@ -2496,12 +2521,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2496
2521
  const member = newState.member || oldState.member;
2497
2522
  if (!member)
2498
2523
  return;
2499
- // Check if user is admin or server owner
2524
+ // Check if user is admin, server owner, can manage server, or has Kimaki role
2500
2525
  const guild = newState.guild || oldState.guild;
2501
2526
  const isOwner = member.id === guild.ownerId;
2502
2527
  const isAdmin = member.permissions.has(PermissionsBitField.Flags.Administrator);
2503
- if (!isOwner && !isAdmin) {
2504
- // Not an admin user, ignore
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) {
2505
2531
  return;
2506
2532
  }
2507
2533
  // Handle admin leaving voice channel
@@ -2520,7 +2546,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2520
2546
  if (m.id === member.id || m.user.bot)
2521
2547
  return false;
2522
2548
  return (m.id === guild.ownerId ||
2523
- 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'));
2524
2552
  });
2525
2553
  if (!hasOtherAdmins) {
2526
2554
  voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
@@ -2550,7 +2578,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2550
2578
  if (m.id === member.id || m.user.bot)
2551
2579
  return false;
2552
2580
  return (m.id === guild.ownerId ||
2553
- 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'));
2554
2584
  });
2555
2585
  if (!hasOtherAdmins) {
2556
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.20",
5
+ "version": "0.4.21",
6
6
  "scripts": {
7
7
  "dev": "tsx --env-file .env src/cli.ts",
8
8
  "prepublishOnly": "pnpm tsc",
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
- return `(+${added}-${removed})`
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
- return `(${lines} line${lines === 1 ? '' : 's'})`
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 ? `(${urlWithoutProtocol})` : ''
1306
+ return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
1279
1307
  }
1280
1308
 
1281
- if (
1282
- part.tool === 'bash' ||
1283
- part.tool === 'read' ||
1284
- part.tool === 'list' ||
1285
- part.tool === 'glob' ||
1286
- part.tool === 'grep' ||
1287
- part.tool === 'task' ||
1288
- part.tool === 'todoread' ||
1289
- part.tool === 'todowrite'
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,17 +1413,16 @@ 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 <= 120 && !hasBackticks) {
1378
- toolTitle = `_${command}_`
1379
- } else {
1380
- toolTitle = stateTitle ? `_${stateTitle}_` : ''
1419
+ if (isSingleLine && !hasBackticks && command.length <= 50) {
1420
+ toolTitle = `\`${command}\``
1421
+ } else if (description) {
1422
+ toolTitle = `_${description}_`
1423
+ } else if (stateTitle) {
1424
+ toolTitle = `_${stateTitle}_`
1381
1425
  }
1382
- } else if (part.tool === 'edit' || part.tool === 'write') {
1383
- const filePath = (part.state.input?.filePath as string) || ''
1384
- const fileName = filePath.split('/').pop() || filePath
1385
- toolTitle = fileName ? `_${fileName}_` : ''
1386
1426
  } else if (stateTitle) {
1387
1427
  toolTitle = `_${stateTitle}_`
1388
1428
  }
@@ -1542,6 +1582,49 @@ async function handleOpencodeSession({
1542
1582
  let usedProviderID: string | undefined
1543
1583
  let tokensUsedInSession = 0
1544
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
+
1545
1628
  const sendPartMessage = async (part: Part) => {
1546
1629
  const content = formatPart(part) + '\n\n'
1547
1630
  if (!content.trim() || content.length === 0) {
@@ -1570,58 +1653,6 @@ async function handleOpencodeSession({
1570
1653
  }
1571
1654
 
1572
1655
  const eventHandler = async () => {
1573
- // Local typing function for this session
1574
- // Outer-scoped interval for typing notifications. Only one at a time.
1575
- let typingInterval: NodeJS.Timeout | null = null
1576
-
1577
- function startTyping(thread: ThreadChannel): () => void {
1578
- if (abortController.signal.aborted) {
1579
- discordLogger.log(`Not starting typing, already aborted`)
1580
- return () => {}
1581
- }
1582
- // Clear any previous typing interval
1583
- if (typingInterval) {
1584
- clearInterval(typingInterval)
1585
- typingInterval = null
1586
- }
1587
-
1588
- // Send initial typing
1589
- thread.sendTyping().catch((e) => {
1590
- discordLogger.log(`Failed to send initial typing: ${e}`)
1591
- })
1592
-
1593
- // Set up interval to send typing every 8 seconds
1594
- typingInterval = setInterval(() => {
1595
- thread.sendTyping().catch((e) => {
1596
- discordLogger.log(`Failed to send periodic typing: ${e}`)
1597
- })
1598
- }, 8000)
1599
-
1600
- // Only add listener if not already aborted
1601
- if (!abortController.signal.aborted) {
1602
- abortController.signal.addEventListener(
1603
- 'abort',
1604
- () => {
1605
- if (typingInterval) {
1606
- clearInterval(typingInterval)
1607
- typingInterval = null
1608
- }
1609
- },
1610
- {
1611
- once: true,
1612
- },
1613
- )
1614
- }
1615
-
1616
- // Return stop function
1617
- return () => {
1618
- if (typingInterval) {
1619
- clearInterval(typingInterval)
1620
- typingInterval = null
1621
- }
1622
- }
1623
- }
1624
-
1625
1656
  try {
1626
1657
  let assistantMessageId: string | undefined
1627
1658
 
@@ -1672,7 +1703,7 @@ async function handleOpencodeSession({
1672
1703
 
1673
1704
  // Start typing on step-start
1674
1705
  if (part.type === 'step-start') {
1675
- stopTyping = startTyping(thread)
1706
+ stopTyping = startTyping()
1676
1707
  }
1677
1708
 
1678
1709
  // Send tool parts immediately when they start running
@@ -1698,7 +1729,7 @@ async function handleOpencodeSession({
1698
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
1699
1730
  setTimeout(() => {
1700
1731
  if (abortController.signal.aborted) return
1701
- stopTyping = startTyping(thread)
1732
+ stopTyping = startTyping()
1702
1733
  }, 300)
1703
1734
  }
1704
1735
  } else if (event.type === 'session.error') {
@@ -1847,13 +1878,7 @@ async function handleOpencodeSession({
1847
1878
  return
1848
1879
  }
1849
1880
 
1850
- if (originalMessage) {
1851
- try {
1852
- await originalMessage.react('⏳')
1853
- } catch (e) {
1854
- discordLogger.log(`Could not add processing reaction:`, e)
1855
- }
1856
- }
1881
+ stopTyping = startTyping()
1857
1882
 
1858
1883
  let response: { data?: unknown; error?: unknown; response: Response }
1859
1884
  if (parsedCommand?.isCommand) {
@@ -2074,14 +2099,20 @@ export async function startDiscordBot({
2074
2099
  }
2075
2100
  }
2076
2101
 
2077
- // Check if user is authoritative (server owner or has admin permissions)
2102
+ // Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
2078
2103
  if (message.guild && message.member) {
2079
2104
  const isOwner = message.member.id === message.guild.ownerId
2080
2105
  const isAdmin = message.member.permissions.has(
2081
2106
  PermissionsBitField.Flags.Administrator,
2082
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
+ )
2083
2114
 
2084
- if (!isOwner && !isAdmin) {
2115
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
2085
2116
  return
2086
2117
  }
2087
2118
  }
@@ -3270,15 +3301,20 @@ export async function startDiscordBot({
3270
3301
  const member = newState.member || oldState.member
3271
3302
  if (!member) return
3272
3303
 
3273
- // Check if user is admin or server owner
3304
+ // Check if user is admin, server owner, can manage server, or has Kimaki role
3274
3305
  const guild = newState.guild || oldState.guild
3275
3306
  const isOwner = member.id === guild.ownerId
3276
3307
  const isAdmin = member.permissions.has(
3277
3308
  PermissionsBitField.Flags.Administrator,
3278
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
+ )
3279
3316
 
3280
- if (!isOwner && !isAdmin) {
3281
- // Not an admin user, ignore
3317
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
3282
3318
  return
3283
3319
  }
3284
3320
 
@@ -3304,7 +3340,9 @@ export async function startDiscordBot({
3304
3340
  if (m.id === member.id || m.user.bot) return false
3305
3341
  return (
3306
3342
  m.id === guild.ownerId ||
3307
- 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')
3308
3346
  )
3309
3347
  })
3310
3348
 
@@ -3349,7 +3387,9 @@ export async function startDiscordBot({
3349
3387
  if (m.id === member.id || m.user.bot) return false
3350
3388
  return (
3351
3389
  m.id === guild.ownerId ||
3352
- 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')
3353
3393
  )
3354
3394
  })
3355
3395