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/src/discordBot.ts
CHANGED
|
@@ -46,6 +46,7 @@ import * as prism from 'prism-media'
|
|
|
46
46
|
import dedent from 'string-dedent'
|
|
47
47
|
import { transcribeAudio } from './voice.js'
|
|
48
48
|
import { extractTagsArrays, extractNonXmlContent } from './xml.js'
|
|
49
|
+
import { formatMarkdownTables } from './format-tables.js'
|
|
49
50
|
import prettyMilliseconds from 'pretty-ms'
|
|
50
51
|
import type { Session } from '@google/genai'
|
|
51
52
|
import { createLogger } from './logger.js'
|
|
@@ -61,7 +62,6 @@ type ParsedCommand = {
|
|
|
61
62
|
} | {
|
|
62
63
|
isCommand: false
|
|
63
64
|
}
|
|
64
|
-
|
|
65
65
|
function parseSlashCommand(text: string): ParsedCommand {
|
|
66
66
|
const trimmed = text.trim()
|
|
67
67
|
if (!trimmed.startsWith('/')) {
|
|
@@ -84,6 +84,32 @@ The user cannot see bash tool outputs. If there is important information in bash
|
|
|
84
84
|
|
|
85
85
|
Your current OpenCode session ID is: ${sessionId}
|
|
86
86
|
|
|
87
|
+
## permissions
|
|
88
|
+
|
|
89
|
+
Only users with these Discord permissions can send messages to the bot:
|
|
90
|
+
- Server Owner
|
|
91
|
+
- Administrator permission
|
|
92
|
+
- Manage Server permission
|
|
93
|
+
- "Kimaki" role (case-insensitive)
|
|
94
|
+
|
|
95
|
+
## changing the model
|
|
96
|
+
|
|
97
|
+
To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
|
|
98
|
+
|
|
99
|
+
\`\`\`json
|
|
100
|
+
{
|
|
101
|
+
"model": "anthropic/claude-sonnet-4-20250514"
|
|
102
|
+
}
|
|
103
|
+
\`\`\`
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
- \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
|
|
107
|
+
- \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
|
|
108
|
+
- \`"openai/gpt-4o"\` - GPT-4o
|
|
109
|
+
- \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
|
|
110
|
+
|
|
111
|
+
Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
|
|
112
|
+
|
|
87
113
|
## uploading files to discord
|
|
88
114
|
|
|
89
115
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
@@ -731,6 +757,7 @@ async function sendThreadMessage(
|
|
|
731
757
|
): Promise<Message> {
|
|
732
758
|
const MAX_LENGTH = 2000
|
|
733
759
|
|
|
760
|
+
content = formatMarkdownTables(content)
|
|
734
761
|
content = escapeBackticksInCodeBlocks(content)
|
|
735
762
|
|
|
736
763
|
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
|
|
@@ -805,7 +832,6 @@ async function processVoiceAttachment({
|
|
|
805
832
|
`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
|
|
806
833
|
)
|
|
807
834
|
|
|
808
|
-
await message.react('⏳')
|
|
809
835
|
await sendThreadMessage(thread, '🎤 Transcribing voice message...')
|
|
810
836
|
|
|
811
837
|
const audioResponse = await fetch(audioAttachment.url)
|
|
@@ -1068,9 +1094,9 @@ function escapeInlineCode(text: string): string {
|
|
|
1068
1094
|
.replace(/\|\|/g, '\\|\\|') // Double pipes (spoiler syntax)
|
|
1069
1095
|
}
|
|
1070
1096
|
|
|
1071
|
-
function resolveTextChannel(
|
|
1097
|
+
async function resolveTextChannel(
|
|
1072
1098
|
channel: TextChannel | ThreadChannel | null | undefined,
|
|
1073
|
-
): TextChannel | null {
|
|
1099
|
+
): Promise<TextChannel | null> {
|
|
1074
1100
|
if (!channel) {
|
|
1075
1101
|
return null
|
|
1076
1102
|
}
|
|
@@ -1084,9 +1110,12 @@ function resolveTextChannel(
|
|
|
1084
1110
|
channel.type === ChannelType.PrivateThread ||
|
|
1085
1111
|
channel.type === ChannelType.AnnouncementThread
|
|
1086
1112
|
) {
|
|
1087
|
-
const
|
|
1088
|
-
if (
|
|
1089
|
-
|
|
1113
|
+
const parentId = channel.parentId
|
|
1114
|
+
if (parentId) {
|
|
1115
|
+
const parent = await channel.guild.channels.fetch(parentId)
|
|
1116
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
1117
|
+
return parent as TextChannel
|
|
1118
|
+
}
|
|
1090
1119
|
}
|
|
1091
1120
|
}
|
|
1092
1121
|
|
|
@@ -1259,38 +1288,65 @@ function getToolSummaryText(part: Part): string {
|
|
|
1259
1288
|
if (part.type !== 'tool') return ''
|
|
1260
1289
|
|
|
1261
1290
|
if (part.tool === 'edit') {
|
|
1291
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
1262
1292
|
const newString = (part.state.input?.newString as string) || ''
|
|
1263
1293
|
const oldString = (part.state.input?.oldString as string) || ''
|
|
1264
1294
|
const added = newString.split('\n').length
|
|
1265
1295
|
const removed = oldString.split('\n').length
|
|
1266
|
-
|
|
1296
|
+
const fileName = filePath.split('/').pop() || ''
|
|
1297
|
+
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
|
|
1267
1298
|
}
|
|
1268
1299
|
|
|
1269
1300
|
if (part.tool === 'write') {
|
|
1301
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
1270
1302
|
const content = (part.state.input?.content as string) || ''
|
|
1271
1303
|
const lines = content.split('\n').length
|
|
1272
|
-
|
|
1304
|
+
const fileName = filePath.split('/').pop() || ''
|
|
1305
|
+
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
|
|
1273
1306
|
}
|
|
1274
1307
|
|
|
1275
1308
|
if (part.tool === 'webfetch') {
|
|
1276
1309
|
const url = (part.state.input?.url as string) || ''
|
|
1277
1310
|
const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
|
|
1278
|
-
return urlWithoutProtocol ?
|
|
1311
|
+
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
|
|
1279
1312
|
}
|
|
1280
1313
|
|
|
1281
|
-
if (
|
|
1282
|
-
part.
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
part.
|
|
1289
|
-
|
|
1290
|
-
|
|
1314
|
+
if (part.tool === 'read') {
|
|
1315
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
1316
|
+
const fileName = filePath.split('/').pop() || ''
|
|
1317
|
+
return fileName ? `*${fileName}*` : ''
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (part.tool === 'list') {
|
|
1321
|
+
const path = (part.state.input?.path as string) || ''
|
|
1322
|
+
const dirName = path.split('/').pop() || path
|
|
1323
|
+
return dirName ? `*${dirName}*` : ''
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (part.tool === 'glob') {
|
|
1327
|
+
const pattern = (part.state.input?.pattern as string) || ''
|
|
1328
|
+
return pattern ? `*${pattern}*` : ''
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (part.tool === 'grep') {
|
|
1332
|
+
const pattern = (part.state.input?.pattern as string) || ''
|
|
1333
|
+
return pattern ? `*${pattern}*` : ''
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
1291
1337
|
return ''
|
|
1292
1338
|
}
|
|
1293
1339
|
|
|
1340
|
+
if (part.tool === 'task') {
|
|
1341
|
+
const description = (part.state.input?.description as string) || ''
|
|
1342
|
+
return description ? `_${description}_` : ''
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (part.tool === 'skill') {
|
|
1346
|
+
const name = (part.state.input?.name as string) || ''
|
|
1347
|
+
return name ? `_${name}_` : ''
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1294
1350
|
if (!part.state.input) return ''
|
|
1295
1351
|
|
|
1296
1352
|
const inputFields = Object.entries(part.state.input)
|
|
@@ -1314,19 +1370,12 @@ function formatTodoList(part: Part): string {
|
|
|
1314
1370
|
content: string
|
|
1315
1371
|
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
|
1316
1372
|
}[]) || []
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
}
|
|
1324
|
-
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
1325
|
-
return `${num} ~~${todo.content}~~`
|
|
1326
|
-
}
|
|
1327
|
-
return `${num} ${todo.content}`
|
|
1328
|
-
})
|
|
1329
|
-
.join('\n')
|
|
1373
|
+
const activeIndex = todos.findIndex((todo) => {
|
|
1374
|
+
return todo.status === 'in_progress'
|
|
1375
|
+
})
|
|
1376
|
+
const activeTodo = todos[activeIndex]
|
|
1377
|
+
if (activeIndex === -1 || !activeTodo) return ''
|
|
1378
|
+
return `${activeIndex + 1}. **${activeTodo.content}**`
|
|
1330
1379
|
}
|
|
1331
1380
|
|
|
1332
1381
|
function formatPart(part: Part): string {
|
|
@@ -1372,17 +1421,16 @@ function formatPart(part: Part): string {
|
|
|
1372
1421
|
toolTitle = part.state.error || 'error'
|
|
1373
1422
|
} else if (part.tool === 'bash') {
|
|
1374
1423
|
const command = (part.state.input?.command as string) || ''
|
|
1424
|
+
const description = (part.state.input?.description as string) || ''
|
|
1375
1425
|
const isSingleLine = !command.includes('\n')
|
|
1376
|
-
const
|
|
1377
|
-
if (isSingleLine && command.length <=
|
|
1426
|
+
const hasUnderscores = command.includes('_')
|
|
1427
|
+
if (isSingleLine && !hasUnderscores && command.length <= 50) {
|
|
1378
1428
|
toolTitle = `_${command}_`
|
|
1379
|
-
} else {
|
|
1380
|
-
toolTitle =
|
|
1429
|
+
} else if (description) {
|
|
1430
|
+
toolTitle = `_${description}_`
|
|
1431
|
+
} else if (stateTitle) {
|
|
1432
|
+
toolTitle = `_${stateTitle}_`
|
|
1381
1433
|
}
|
|
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
1434
|
} else if (stateTitle) {
|
|
1387
1435
|
toolTitle = `_${stateTitle}_`
|
|
1388
1436
|
}
|
|
@@ -1541,6 +1589,51 @@ async function handleOpencodeSession({
|
|
|
1541
1589
|
let usedModel: string | undefined
|
|
1542
1590
|
let usedProviderID: string | undefined
|
|
1543
1591
|
let tokensUsedInSession = 0
|
|
1592
|
+
let lastDisplayedContextPercentage = 0
|
|
1593
|
+
let modelContextLimit: number | undefined
|
|
1594
|
+
|
|
1595
|
+
let typingInterval: NodeJS.Timeout | null = null
|
|
1596
|
+
|
|
1597
|
+
function startTyping(): () => void {
|
|
1598
|
+
if (abortController.signal.aborted) {
|
|
1599
|
+
discordLogger.log(`Not starting typing, already aborted`)
|
|
1600
|
+
return () => {}
|
|
1601
|
+
}
|
|
1602
|
+
if (typingInterval) {
|
|
1603
|
+
clearInterval(typingInterval)
|
|
1604
|
+
typingInterval = null
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
thread.sendTyping().catch((e) => {
|
|
1608
|
+
discordLogger.log(`Failed to send initial typing: ${e}`)
|
|
1609
|
+
})
|
|
1610
|
+
|
|
1611
|
+
typingInterval = setInterval(() => {
|
|
1612
|
+
thread.sendTyping().catch((e) => {
|
|
1613
|
+
discordLogger.log(`Failed to send periodic typing: ${e}`)
|
|
1614
|
+
})
|
|
1615
|
+
}, 8000)
|
|
1616
|
+
|
|
1617
|
+
if (!abortController.signal.aborted) {
|
|
1618
|
+
abortController.signal.addEventListener(
|
|
1619
|
+
'abort',
|
|
1620
|
+
() => {
|
|
1621
|
+
if (typingInterval) {
|
|
1622
|
+
clearInterval(typingInterval)
|
|
1623
|
+
typingInterval = null
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
{ once: true },
|
|
1627
|
+
)
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
return () => {
|
|
1631
|
+
if (typingInterval) {
|
|
1632
|
+
clearInterval(typingInterval)
|
|
1633
|
+
typingInterval = null
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1544
1637
|
|
|
1545
1638
|
const sendPartMessage = async (part: Part) => {
|
|
1546
1639
|
const content = formatPart(part) + '\n\n'
|
|
@@ -1570,58 +1663,6 @@ async function handleOpencodeSession({
|
|
|
1570
1663
|
}
|
|
1571
1664
|
|
|
1572
1665
|
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
1666
|
try {
|
|
1626
1667
|
let assistantMessageId: string | undefined
|
|
1627
1668
|
|
|
@@ -1645,6 +1686,30 @@ async function handleOpencodeSession({
|
|
|
1645
1686
|
assistantMessageId = msg.id
|
|
1646
1687
|
usedModel = msg.modelID
|
|
1647
1688
|
usedProviderID = msg.providerID
|
|
1689
|
+
|
|
1690
|
+
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
1691
|
+
if (!modelContextLimit) {
|
|
1692
|
+
try {
|
|
1693
|
+
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1694
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
1695
|
+
const model = provider?.models?.[usedModel]
|
|
1696
|
+
if (model?.limit?.context) {
|
|
1697
|
+
modelContextLimit = model.limit.context
|
|
1698
|
+
}
|
|
1699
|
+
} catch (e) {
|
|
1700
|
+
sessionLogger.error('Failed to fetch provider info for context limit:', e)
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
if (modelContextLimit) {
|
|
1705
|
+
const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
|
|
1706
|
+
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
1707
|
+
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
1708
|
+
lastDisplayedContextPercentage = thresholdCrossed
|
|
1709
|
+
await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`)
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1648
1713
|
}
|
|
1649
1714
|
} else if (event.type === 'message.part.updated') {
|
|
1650
1715
|
const part = event.properties.part
|
|
@@ -1672,7 +1737,7 @@ async function handleOpencodeSession({
|
|
|
1672
1737
|
|
|
1673
1738
|
// Start typing on step-start
|
|
1674
1739
|
if (part.type === 'step-start') {
|
|
1675
|
-
stopTyping = startTyping(
|
|
1740
|
+
stopTyping = startTyping()
|
|
1676
1741
|
}
|
|
1677
1742
|
|
|
1678
1743
|
// Send tool parts immediately when they start running
|
|
@@ -1698,7 +1763,7 @@ async function handleOpencodeSession({
|
|
|
1698
1763
|
// 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
1764
|
setTimeout(() => {
|
|
1700
1765
|
if (abortController.signal.aborted) return
|
|
1701
|
-
stopTyping = startTyping(
|
|
1766
|
+
stopTyping = startTyping()
|
|
1702
1767
|
}, 300)
|
|
1703
1768
|
}
|
|
1704
1769
|
} else if (event.type === 'session.error') {
|
|
@@ -1847,13 +1912,7 @@ async function handleOpencodeSession({
|
|
|
1847
1912
|
return
|
|
1848
1913
|
}
|
|
1849
1914
|
|
|
1850
|
-
|
|
1851
|
-
try {
|
|
1852
|
-
await originalMessage.react('⏳')
|
|
1853
|
-
} catch (e) {
|
|
1854
|
-
discordLogger.log(`Could not add processing reaction:`, e)
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1915
|
+
stopTyping = startTyping()
|
|
1857
1916
|
|
|
1858
1917
|
let response: { data?: unknown; error?: unknown; response: Response }
|
|
1859
1918
|
if (parsedCommand?.isCommand) {
|
|
@@ -2074,14 +2133,20 @@ export async function startDiscordBot({
|
|
|
2074
2133
|
}
|
|
2075
2134
|
}
|
|
2076
2135
|
|
|
2077
|
-
// Check if user is authoritative (server owner or has
|
|
2136
|
+
// Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
|
|
2078
2137
|
if (message.guild && message.member) {
|
|
2079
2138
|
const isOwner = message.member.id === message.guild.ownerId
|
|
2080
2139
|
const isAdmin = message.member.permissions.has(
|
|
2081
2140
|
PermissionsBitField.Flags.Administrator,
|
|
2082
2141
|
)
|
|
2142
|
+
const canManageServer = message.member.permissions.has(
|
|
2143
|
+
PermissionsBitField.Flags.ManageGuild,
|
|
2144
|
+
)
|
|
2145
|
+
const hasKimakiRole = message.member.roles.cache.some(
|
|
2146
|
+
(role) => role.name.toLowerCase() === 'kimaki',
|
|
2147
|
+
)
|
|
2083
2148
|
|
|
2084
|
-
if (!isOwner && !isAdmin) {
|
|
2149
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
2085
2150
|
return
|
|
2086
2151
|
}
|
|
2087
2152
|
}
|
|
@@ -2297,11 +2362,8 @@ export async function startDiscordBot({
|
|
|
2297
2362
|
|
|
2298
2363
|
// Get the channel's project directory from its topic
|
|
2299
2364
|
let projectDirectory: string | undefined
|
|
2300
|
-
if (
|
|
2301
|
-
|
|
2302
|
-
interaction.channel.type === ChannelType.GuildText
|
|
2303
|
-
) {
|
|
2304
|
-
const textChannel = resolveTextChannel(
|
|
2365
|
+
if (interaction.channel) {
|
|
2366
|
+
const textChannel = await resolveTextChannel(
|
|
2305
2367
|
interaction.channel as TextChannel | ThreadChannel | null,
|
|
2306
2368
|
)
|
|
2307
2369
|
if (textChannel) {
|
|
@@ -2383,11 +2445,8 @@ export async function startDiscordBot({
|
|
|
2383
2445
|
|
|
2384
2446
|
// Get the channel's project directory from its topic
|
|
2385
2447
|
let projectDirectory: string | undefined
|
|
2386
|
-
if (
|
|
2387
|
-
|
|
2388
|
-
interaction.channel.type === ChannelType.GuildText
|
|
2389
|
-
) {
|
|
2390
|
-
const textChannel = resolveTextChannel(
|
|
2448
|
+
if (interaction.channel) {
|
|
2449
|
+
const textChannel = await resolveTextChannel(
|
|
2391
2450
|
interaction.channel as TextChannel | ThreadChannel | null,
|
|
2392
2451
|
)
|
|
2393
2452
|
if (textChannel) {
|
|
@@ -2751,7 +2810,7 @@ export async function startDiscordBot({
|
|
|
2751
2810
|
if (partsToRender.length > 0) {
|
|
2752
2811
|
const combinedContent = partsToRender
|
|
2753
2812
|
.map((p) => p.content)
|
|
2754
|
-
.join('\n
|
|
2813
|
+
.join('\n')
|
|
2755
2814
|
|
|
2756
2815
|
const discordMessage = await sendThreadMessage(
|
|
2757
2816
|
thread,
|
|
@@ -2856,7 +2915,7 @@ export async function startDiscordBot({
|
|
|
2856
2915
|
`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2857
2916
|
)
|
|
2858
2917
|
}
|
|
2859
|
-
} else if (command.commandName === '
|
|
2918
|
+
} else if (command.commandName === 'create-new-project') {
|
|
2860
2919
|
await command.deferReply({ ephemeral: false })
|
|
2861
2920
|
|
|
2862
2921
|
const projectName = command.options.getString('name', true)
|
|
@@ -3091,7 +3150,7 @@ export async function startDiscordBot({
|
|
|
3091
3150
|
return
|
|
3092
3151
|
}
|
|
3093
3152
|
|
|
3094
|
-
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
3153
|
+
const textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
3095
3154
|
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
3096
3155
|
|
|
3097
3156
|
if (!directory) {
|
|
@@ -3162,7 +3221,7 @@ export async function startDiscordBot({
|
|
|
3162
3221
|
return
|
|
3163
3222
|
}
|
|
3164
3223
|
|
|
3165
|
-
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
3224
|
+
const textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
3166
3225
|
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
3167
3226
|
|
|
3168
3227
|
if (!directory) {
|
|
@@ -3270,15 +3329,20 @@ export async function startDiscordBot({
|
|
|
3270
3329
|
const member = newState.member || oldState.member
|
|
3271
3330
|
if (!member) return
|
|
3272
3331
|
|
|
3273
|
-
// Check if user is admin
|
|
3332
|
+
// Check if user is admin, server owner, can manage server, or has Kimaki role
|
|
3274
3333
|
const guild = newState.guild || oldState.guild
|
|
3275
3334
|
const isOwner = member.id === guild.ownerId
|
|
3276
3335
|
const isAdmin = member.permissions.has(
|
|
3277
3336
|
PermissionsBitField.Flags.Administrator,
|
|
3278
3337
|
)
|
|
3338
|
+
const canManageServer = member.permissions.has(
|
|
3339
|
+
PermissionsBitField.Flags.ManageGuild,
|
|
3340
|
+
)
|
|
3341
|
+
const hasKimakiRole = member.roles.cache.some(
|
|
3342
|
+
(role) => role.name.toLowerCase() === 'kimaki',
|
|
3343
|
+
)
|
|
3279
3344
|
|
|
3280
|
-
if (!isOwner && !isAdmin) {
|
|
3281
|
-
// Not an admin user, ignore
|
|
3345
|
+
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
3282
3346
|
return
|
|
3283
3347
|
}
|
|
3284
3348
|
|
|
@@ -3304,7 +3368,9 @@ export async function startDiscordBot({
|
|
|
3304
3368
|
if (m.id === member.id || m.user.bot) return false
|
|
3305
3369
|
return (
|
|
3306
3370
|
m.id === guild.ownerId ||
|
|
3307
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
3371
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
3372
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
3373
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
|
|
3308
3374
|
)
|
|
3309
3375
|
})
|
|
3310
3376
|
|
|
@@ -3349,7 +3415,9 @@ export async function startDiscordBot({
|
|
|
3349
3415
|
if (m.id === member.id || m.user.bot) return false
|
|
3350
3416
|
return (
|
|
3351
3417
|
m.id === guild.ownerId ||
|
|
3352
|
-
m.permissions.has(PermissionsBitField.Flags.Administrator)
|
|
3418
|
+
m.permissions.has(PermissionsBitField.Flags.Administrator) ||
|
|
3419
|
+
m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
|
|
3420
|
+
m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
|
|
3353
3421
|
)
|
|
3354
3422
|
})
|
|
3355
3423
|
|