@brianli/kimaki 0.4.72-brianli.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +2 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/bin.js +87 -0
- package/dist/bot-token.js +121 -0
- package/dist/bot-token.test.js +134 -0
- package/dist/channel-management.js +101 -0
- package/dist/cli-parsing.test.js +89 -0
- package/dist/cli.js +2529 -0
- package/dist/commands/abort.js +82 -0
- package/dist/commands/action-buttons.js +257 -0
- package/dist/commands/add-project.js +114 -0
- package/dist/commands/agent.js +291 -0
- package/dist/commands/ask-question.js +223 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +118 -0
- package/dist/commands/diff.js +128 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +217 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +490 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +124 -0
- package/dist/commands/model.js +694 -0
- package/dist/commands/permissions.js +163 -0
- package/dist/commands/queue.js +217 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +116 -0
- package/dist/commands/resume.js +159 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +192 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +159 -0
- package/dist/commands/unset-model.js +152 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +148 -0
- package/dist/commands/verbosity.js +60 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +299 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +110 -0
- package/dist/database.js +1050 -0
- package/dist/db.js +159 -0
- package/dist/db.test.js +49 -0
- package/dist/discord-api.js +28 -0
- package/dist/discord-auth.js +231 -0
- package/dist/discord-auth.test.js +80 -0
- package/dist/discord-bot.js +997 -0
- package/dist/discord-utils.js +560 -0
- package/dist/discord-utils.test.js +115 -0
- package/dist/errors.js +167 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +122 -0
- package/dist/format-tables.test.js +199 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +117 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-consumer.js +232 -0
- package/dist/gateway-consumer.test.js +18 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +35 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +30 -0
- package/dist/generated/internal/class.js +41 -0
- package/dist/generated/internal/prismaNamespace.js +239 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +95 -0
- package/dist/hrana-server.js +416 -0
- package/dist/hrana-server.test.js +368 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +327 -0
- package/dist/ipc-polling.js +251 -0
- package/dist/kimaki-digital-twin.e2e.test.js +165 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +160 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +253 -0
- package/dist/message-formatting.js +433 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +536 -0
- package/dist/opencode-plugin.test.js +98 -0
- package/dist/opencode.js +409 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/runtime-mode.js +51 -0
- package/dist/runtime-mode.test.js +115 -0
- package/dist/sentry.js +127 -0
- package/dist/session-handler/state.js +151 -0
- package/dist/session-handler.js +1874 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/startup-service.js +153 -0
- package/dist/system-message.js +499 -0
- package/dist/task-runner.js +282 -0
- package/dist/task-schedule.js +191 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +781 -0
- package/dist/tools.js +359 -0
- package/dist/unnest-code-blocks.js +136 -0
- package/dist/unnest-code-blocks.test.js +641 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +109 -0
- package/dist/voice-handler.js +606 -0
- package/dist/voice.js +304 -0
- package/dist/voice.test.js +187 -0
- package/dist/wait-session.js +94 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +727 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +82 -0
- package/schema.prisma +246 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +129 -0
- package/skills/errore/SKILL.md +589 -0
- package/skills/goke/.prettierrc +5 -0
- package/skills/goke/CHANGELOG.md +40 -0
- package/skills/goke/LICENSE +21 -0
- package/skills/goke/README.md +666 -0
- package/skills/goke/SKILL.md +458 -0
- package/skills/goke/package.json +43 -0
- package/skills/goke/src/__test__/coerce.test.ts +411 -0
- package/skills/goke/src/__test__/index.test.ts +1798 -0
- package/skills/goke/src/__test__/types.test-d.ts +111 -0
- package/skills/goke/src/coerce.ts +547 -0
- package/skills/goke/src/goke.ts +1362 -0
- package/skills/goke/src/index.ts +16 -0
- package/skills/goke/src/mri.ts +164 -0
- package/skills/goke/tsconfig.json +15 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/playwriter/SKILL.md +31 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/zustand-centralized-state/SKILL.md +582 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +41 -0
- package/src/__snapshots__/first-session-no-info.md +17 -0
- package/src/__snapshots__/first-session-with-info.md +23 -0
- package/src/__snapshots__/session-1.md +17 -0
- package/src/__snapshots__/session-2.md +5871 -0
- package/src/__snapshots__/session-3.md +17 -0
- package/src/__snapshots__/session-with-tools.md +5871 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +282 -0
- package/src/ai-tool.ts +39 -0
- package/src/bin.ts +108 -0
- package/src/bot-token.test.ts +171 -0
- package/src/bot-token.ts +159 -0
- package/src/channel-management.ts +172 -0
- package/src/cli-parsing.test.ts +132 -0
- package/src/cli.ts +3605 -0
- package/src/commands/abort.ts +112 -0
- package/src/commands/action-buttons.ts +376 -0
- package/src/commands/add-project.ts +152 -0
- package/src/commands/agent.ts +404 -0
- package/src/commands/ask-question.ts +330 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +179 -0
- package/src/commands/diff.ts +165 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +320 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +634 -0
- package/src/commands/mention-mode.ts +77 -0
- package/src/commands/merge-worktree.ts +177 -0
- package/src/commands/model.ts +961 -0
- package/src/commands/permissions.ts +261 -0
- package/src/commands/queue.ts +296 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +242 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +250 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +221 -0
- package/src/commands/unset-model.ts +189 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +193 -0
- package/src/commands/verbosity.ts +88 -0
- package/src/commands/worktree-settings.ts +79 -0
- package/src/commands/worktree.ts +431 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +148 -0
- package/src/database.ts +1530 -0
- package/src/db.test.ts +60 -0
- package/src/db.ts +190 -0
- package/src/discord-api.ts +35 -0
- package/src/discord-bot.ts +1316 -0
- package/src/discord-utils.test.ts +132 -0
- package/src/discord-utils.ts +767 -0
- package/src/errors.ts +213 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/format-tables.test.ts +223 -0
- package/src/format-tables.ts +145 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +176 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +109 -0
- package/src/generated/client.ts +131 -0
- package/src/generated/commonInputTypes.ts +512 -0
- package/src/generated/enums.ts +46 -0
- package/src/generated/internal/class.ts +362 -0
- package/src/generated/internal/prismaNamespace.ts +2251 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1577 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +2104 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1224 -0
- package/src/generated/models/channel_worktrees.ts +1308 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1599 -0
- package/src/generated/models/thread_worktrees.ts +1352 -0
- package/src/generated/models.ts +29 -0
- package/src/heap-monitor.ts +121 -0
- package/src/hrana-server.test.ts +428 -0
- package/src/hrana-server.ts +547 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +461 -0
- package/src/ipc-polling.ts +325 -0
- package/src/kimaki-digital-twin.e2e.test.ts +201 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +203 -0
- package/src/markdown.test.ts +360 -0
- package/src/markdown.ts +410 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +549 -0
- package/src/openai-realtime.ts +362 -0
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +108 -0
- package/src/opencode-plugin.ts +652 -0
- package/src/opencode.ts +554 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/schema.sql +158 -0
- package/src/sentry.ts +137 -0
- package/src/session-handler/state.ts +232 -0
- package/src/session-handler.ts +2668 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/startup-service.ts +200 -0
- package/src/system-message.ts +568 -0
- package/src/task-runner.ts +425 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +287 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +997 -0
- package/src/tools.ts +432 -0
- package/src/unnest-code-blocks.test.ts +679 -0
- package/src/unnest-code-blocks.ts +168 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +145 -0
- package/src/voice-handler.ts +852 -0
- package/src/voice.test.ts +219 -0
- package/src/voice.ts +444 -0
- package/src/wait-session.ts +147 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-utils.ts +988 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
package/src/bin.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Respawn wrapper for the kimaki bot process.
|
|
2
|
+
// When running the default command (no subcommand) with --auto-restart,
|
|
3
|
+
// spawns cli.js as a child process and restarts it on non-zero exit codes
|
|
4
|
+
// (crash, OOM kill, etc). Intentional exits (code 0 or EXIT_NO_RESTART=64)
|
|
5
|
+
// are not restarted.
|
|
6
|
+
//
|
|
7
|
+
// Subcommands (send, tunnel, project, etc.) run directly without the wrapper
|
|
8
|
+
// since they are short-lived and don't need crash recovery.
|
|
9
|
+
//
|
|
10
|
+
// When __KIMAKI_CHILD is set, we're the child process -- just run cli.js directly.
|
|
11
|
+
//
|
|
12
|
+
// V8 heap snapshot flags:
|
|
13
|
+
// Injects --heapsnapshot-near-heap-limit=3 and --diagnostic-dir so V8 writes
|
|
14
|
+
// heap snapshots internally as it approaches the heap limit. This catches OOM
|
|
15
|
+
// situations where SIGKILL (exit 137) would kill the process before our
|
|
16
|
+
// heap-monitor.ts polling can react. The polling monitor is kept as an early
|
|
17
|
+
// warning system at 85% usage; the V8 flag is the last-resort safety net.
|
|
18
|
+
|
|
19
|
+
import { spawn } from 'node:child_process'
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
|
|
24
|
+
const HEAP_SNAPSHOT_DIR = path.join(os.homedir(), '.kimaki', 'heap-snapshots')
|
|
25
|
+
|
|
26
|
+
// First arg after node + script is either a subcommand or a flag.
|
|
27
|
+
// If it doesn't start with '-', it's a subcommand (e.g. "send", "tunnel", "project").
|
|
28
|
+
const firstArg = process.argv[2]
|
|
29
|
+
const isSubcommand = firstArg && !firstArg.startsWith('-')
|
|
30
|
+
const hasAutoRestart = process.argv.includes('--auto-restart')
|
|
31
|
+
|
|
32
|
+
if (process.env.__KIMAKI_CHILD || isSubcommand || !hasAutoRestart) {
|
|
33
|
+
await import('./cli.js')
|
|
34
|
+
} else {
|
|
35
|
+
const EXIT_NO_RESTART = 64
|
|
36
|
+
const MAX_RAPID_RESTARTS = 5
|
|
37
|
+
const RAPID_RESTART_WINDOW_MS = 60_000
|
|
38
|
+
const RESTART_DELAY_MS = 2_000
|
|
39
|
+
|
|
40
|
+
const restartTimestamps: number[] = []
|
|
41
|
+
let child: ReturnType<typeof spawn> | null = null
|
|
42
|
+
// Track when we forwarded a termination signal so we don't restart after graceful shutdown
|
|
43
|
+
let shutdownRequested = false
|
|
44
|
+
|
|
45
|
+
function start() {
|
|
46
|
+
if (!fs.existsSync(HEAP_SNAPSHOT_DIR)) {
|
|
47
|
+
fs.mkdirSync(HEAP_SNAPSHOT_DIR, { recursive: true })
|
|
48
|
+
}
|
|
49
|
+
const heapArgs = [
|
|
50
|
+
`--heapsnapshot-near-heap-limit=3`,
|
|
51
|
+
`--diagnostic-dir=${HEAP_SNAPSHOT_DIR}`,
|
|
52
|
+
]
|
|
53
|
+
child = spawn(
|
|
54
|
+
process.argv[0]!,
|
|
55
|
+
[...heapArgs, ...process.execArgv, ...process.argv.slice(1)],
|
|
56
|
+
{
|
|
57
|
+
stdio: 'inherit',
|
|
58
|
+
env: { ...process.env, __KIMAKI_CHILD: '1' },
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
child.on('exit', (code, signal) => {
|
|
63
|
+
if (code === 0 || code === EXIT_NO_RESTART || shutdownRequested) {
|
|
64
|
+
process.exit(code ?? 0)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const now = Date.now()
|
|
69
|
+
restartTimestamps.push(now)
|
|
70
|
+
while (
|
|
71
|
+
restartTimestamps.length > 0 &&
|
|
72
|
+
restartTimestamps[0]! < now - RAPID_RESTART_WINDOW_MS
|
|
73
|
+
) {
|
|
74
|
+
restartTimestamps.shift()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (restartTimestamps.length > MAX_RAPID_RESTARTS) {
|
|
78
|
+
console.error(
|
|
79
|
+
`[kimaki] Crash loop detected (${MAX_RAPID_RESTARTS} crashes in ${RAPID_RESTART_WINDOW_MS / 1000}s), exiting`,
|
|
80
|
+
)
|
|
81
|
+
process.exit(1)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reason = signal ? `signal ${signal}` : `code ${code}`
|
|
86
|
+
console.error(
|
|
87
|
+
`[kimaki] Process exited with ${reason}, restarting in ${RESTART_DELAY_MS / 1000}s...`,
|
|
88
|
+
)
|
|
89
|
+
setTimeout(start, RESTART_DELAY_MS)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Forward signals to child so graceful shutdown and heap snapshots work.
|
|
94
|
+
// SIGTERM/SIGINT mark shutdownRequested so we don't restart after graceful exit.
|
|
95
|
+
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
|
96
|
+
process.on(sig, () => {
|
|
97
|
+
shutdownRequested = true
|
|
98
|
+
child?.kill(sig)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
for (const sig of ['SIGUSR1', 'SIGUSR2'] as const) {
|
|
102
|
+
process.on(sig, () => {
|
|
103
|
+
child?.kill(sig)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
start()
|
|
108
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { afterAll, beforeEach, describe, expect, test } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
appIdFromToken,
|
|
5
|
+
getBotToken,
|
|
6
|
+
hydrateBotTokenCache,
|
|
7
|
+
isAuthModeEnabled,
|
|
8
|
+
} from './bot-token.js'
|
|
9
|
+
|
|
10
|
+
const ORIGINAL_BOT_TOKEN = process.env.KIMAKI_BOT_TOKEN
|
|
11
|
+
const ORIGINAL_GUILD_ID = process.env.KIMAKI_GUILD_ID
|
|
12
|
+
const ORIGINAL_PRIVATE_KEY = process.env.KIMAKI_PRIVATE_KEY
|
|
13
|
+
const ORIGINAL_APP_ID = process.env.KIMAKI_APP_ID
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
delete process.env.KIMAKI_BOT_TOKEN
|
|
17
|
+
delete process.env.KIMAKI_GUILD_ID
|
|
18
|
+
delete process.env.KIMAKI_PRIVATE_KEY
|
|
19
|
+
delete process.env.KIMAKI_APP_ID
|
|
20
|
+
hydrateBotTokenCache(null)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
process.env.KIMAKI_BOT_TOKEN = ORIGINAL_BOT_TOKEN
|
|
25
|
+
process.env.KIMAKI_GUILD_ID = ORIGINAL_GUILD_ID
|
|
26
|
+
process.env.KIMAKI_PRIVATE_KEY = ORIGINAL_PRIVATE_KEY
|
|
27
|
+
process.env.KIMAKI_APP_ID = ORIGINAL_APP_ID
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('appIdFromToken', () => {
|
|
31
|
+
test('derives app id from valid token format', () => {
|
|
32
|
+
const token = 'MTQ3Njc0NTc2MzAwOTU5MzM2NQ.anything.anything'
|
|
33
|
+
expect(appIdFromToken(token)).toBe('1476745763009593365')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('returns undefined for malformed tokens', () => {
|
|
37
|
+
expect(appIdFromToken('not-a-token')).toBeUndefined()
|
|
38
|
+
expect(appIdFromToken('')).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('getBotToken', () => {
|
|
43
|
+
test('prefers env token over db by default', () => {
|
|
44
|
+
process.env.KIMAKI_BOT_TOKEN =
|
|
45
|
+
'MTQ3Njc0NTc2MzAwOTU5MzM2NQ.env.payload'
|
|
46
|
+
hydrateBotTokenCache({ app_id: 'db-app', token: 'db-token' })
|
|
47
|
+
|
|
48
|
+
const resolved = getBotToken()
|
|
49
|
+
|
|
50
|
+
expect(resolved).toEqual({
|
|
51
|
+
token: 'MTQ3Njc0NTc2MzAwOTU5MzM2NQ.env.payload',
|
|
52
|
+
appId: '1476745763009593365',
|
|
53
|
+
source: 'env',
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('uses db token when env is absent', () => {
|
|
58
|
+
delete process.env.KIMAKI_BOT_TOKEN
|
|
59
|
+
hydrateBotTokenCache({
|
|
60
|
+
app_id: '1476745763009593365',
|
|
61
|
+
token: 'db-token',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const resolved = getBotToken()
|
|
65
|
+
|
|
66
|
+
expect(resolved).toEqual({
|
|
67
|
+
token: 'db-token',
|
|
68
|
+
appId: '1476745763009593365',
|
|
69
|
+
source: 'db',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('supports db-only lookup when preferEnv is false', () => {
|
|
74
|
+
process.env.KIMAKI_BOT_TOKEN = 'env-token'
|
|
75
|
+
hydrateBotTokenCache({
|
|
76
|
+
app_id: '1476745763009593365',
|
|
77
|
+
token: 'db-token',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const resolved = getBotToken({ preferEnv: false })
|
|
81
|
+
|
|
82
|
+
expect(resolved).toEqual({
|
|
83
|
+
token: 'db-token',
|
|
84
|
+
appId: '1476745763009593365',
|
|
85
|
+
source: 'db',
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('can skip db lookup entirely', () => {
|
|
90
|
+
delete process.env.KIMAKI_BOT_TOKEN
|
|
91
|
+
hydrateBotTokenCache({ app_id: 'x', token: 'y' })
|
|
92
|
+
|
|
93
|
+
const resolved = getBotToken({ allowDatabase: false })
|
|
94
|
+
|
|
95
|
+
expect(resolved).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('applies appId override consistently', () => {
|
|
99
|
+
process.env.KIMAKI_BOT_TOKEN = 'env-token'
|
|
100
|
+
const fromEnv = getBotToken({
|
|
101
|
+
appIdOverride: 'override-app-id',
|
|
102
|
+
allowDatabase: false,
|
|
103
|
+
})
|
|
104
|
+
expect(fromEnv?.appId).toBe('override-app-id')
|
|
105
|
+
|
|
106
|
+
delete process.env.KIMAKI_BOT_TOKEN
|
|
107
|
+
hydrateBotTokenCache({
|
|
108
|
+
app_id: 'db-app',
|
|
109
|
+
token: 'db-token',
|
|
110
|
+
})
|
|
111
|
+
const fromDb = getBotToken({
|
|
112
|
+
appIdOverride: 'override-app-id',
|
|
113
|
+
})
|
|
114
|
+
expect(fromDb?.appId).toBe('override-app-id')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('auth mode takes precedence over env and db token', () => {
|
|
118
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
119
|
+
process.env.KIMAKI_GUILD_ID = '1477130736841658398'
|
|
120
|
+
process.env.KIMAKI_APP_ID = '1476745763009593365'
|
|
121
|
+
process.env.KIMAKI_PRIVATE_KEY = privateKey
|
|
122
|
+
.export({ format: 'pem', type: 'pkcs8' })
|
|
123
|
+
.toString()
|
|
124
|
+
process.env.KIMAKI_BOT_TOKEN = 'env-token'
|
|
125
|
+
hydrateBotTokenCache({
|
|
126
|
+
app_id: 'db-app',
|
|
127
|
+
token: 'db-token',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const resolved = getBotToken()
|
|
131
|
+
|
|
132
|
+
expect(isAuthModeEnabled()).toBe(true)
|
|
133
|
+
expect(resolved?.source).toBe('auth')
|
|
134
|
+
expect(resolved?.appId).toBe('1476745763009593365')
|
|
135
|
+
expect(resolved?.token.split('.')).toHaveLength(3)
|
|
136
|
+
|
|
137
|
+
const tokenParts = resolved!.token.split('.')
|
|
138
|
+
expect(tokenParts).toHaveLength(3)
|
|
139
|
+
const guildPart = tokenParts[0]!
|
|
140
|
+
const timestamp = tokenParts[1]!
|
|
141
|
+
const signaturePart = tokenParts[2]!
|
|
142
|
+
const decodedGuildId = Buffer.from(guildPart, 'base64').toString('utf8')
|
|
143
|
+
expect(decodedGuildId).toBe('1477130736841658398')
|
|
144
|
+
expect(Number.isNaN(Number.parseInt(timestamp, 10))).toBe(false)
|
|
145
|
+
|
|
146
|
+
const signature = Buffer.from(signaturePart, 'base64url')
|
|
147
|
+
const verified = crypto.verify(
|
|
148
|
+
null,
|
|
149
|
+
Buffer.from(`${decodedGuildId}\n${timestamp}`, 'utf8'),
|
|
150
|
+
publicKey,
|
|
151
|
+
signature,
|
|
152
|
+
)
|
|
153
|
+
expect(verified).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('auth mode requires all env vars', () => {
|
|
157
|
+
process.env.KIMAKI_GUILD_ID = '1477130736841658398'
|
|
158
|
+
delete process.env.KIMAKI_PRIVATE_KEY
|
|
159
|
+
process.env.KIMAKI_APP_ID = '1476745763009593365'
|
|
160
|
+
process.env.KIMAKI_BOT_TOKEN = 'env-token'
|
|
161
|
+
|
|
162
|
+
const resolved = getBotToken()
|
|
163
|
+
|
|
164
|
+
expect(isAuthModeEnabled()).toBe(false)
|
|
165
|
+
expect(resolved).toEqual({
|
|
166
|
+
token: 'env-token',
|
|
167
|
+
appId: undefined,
|
|
168
|
+
source: 'env',
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
})
|
package/src/bot-token.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export type ResolvedBotToken = {
|
|
4
|
+
token: string
|
|
5
|
+
appId: string | undefined
|
|
6
|
+
source: 'auth' | 'env' | 'db'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type StoredBotToken = { app_id: string; token: string }
|
|
10
|
+
|
|
11
|
+
type GetBotTokenOptions = {
|
|
12
|
+
appIdOverride?: string
|
|
13
|
+
preferEnv?: boolean
|
|
14
|
+
allowDatabase?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let dbBotToken: StoredBotToken | null = null
|
|
18
|
+
|
|
19
|
+
type AuthModeConfig = {
|
|
20
|
+
guildId: string
|
|
21
|
+
privateKey: string
|
|
22
|
+
appId: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toBase64(value: string): string {
|
|
26
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
27
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4)
|
|
28
|
+
return `${normalized}${padding}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveAuthModeConfig(): AuthModeConfig | null {
|
|
32
|
+
const guildId = process.env.KIMAKI_GUILD_ID?.trim()
|
|
33
|
+
const privateKey = process.env.KIMAKI_PRIVATE_KEY?.trim()
|
|
34
|
+
const appId = process.env.KIMAKI_APP_ID?.trim()
|
|
35
|
+
if (!guildId || !privateKey || !appId) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
guildId,
|
|
40
|
+
privateKey,
|
|
41
|
+
appId,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parsePrivateKey(privateKeyValue: string): crypto.KeyObject {
|
|
46
|
+
if (privateKeyValue.includes('BEGIN PRIVATE KEY')) {
|
|
47
|
+
return crypto.createPrivateKey(privateKeyValue)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const candidates: Array<{ key: Buffer; format: 'der'; type: 'pkcs8' }> = []
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
candidates.push({
|
|
54
|
+
key: Buffer.from(toBase64(privateKeyValue), 'base64'),
|
|
55
|
+
format: 'der',
|
|
56
|
+
type: 'pkcs8',
|
|
57
|
+
})
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore and continue to hex fallback.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
candidates.push({
|
|
64
|
+
key: Buffer.from(privateKeyValue, 'hex'),
|
|
65
|
+
format: 'der',
|
|
66
|
+
type: 'pkcs8',
|
|
67
|
+
})
|
|
68
|
+
} catch {
|
|
69
|
+
// Ignore and continue.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
try {
|
|
74
|
+
return crypto.createPrivateKey(candidate)
|
|
75
|
+
} catch {
|
|
76
|
+
// Try next candidate.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error('Invalid KIMAKI_PRIVATE_KEY for auth mode')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createAuthModeToken(config: AuthModeConfig): string {
|
|
84
|
+
const timestamp = Date.now()
|
|
85
|
+
const key = parsePrivateKey(config.privateKey)
|
|
86
|
+
const message = `${config.guildId}\n${timestamp}`
|
|
87
|
+
const signature = crypto
|
|
88
|
+
.sign(null, Buffer.from(message, 'utf8'), key)
|
|
89
|
+
.toString('base64url')
|
|
90
|
+
const guildPart = Buffer.from(config.guildId, 'utf8').toString('base64')
|
|
91
|
+
return `${guildPart}.${timestamp}.${signature}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Derive the Discord Application ID from a bot token.
|
|
95
|
+
// Discord bot tokens have the format: base64(userId).timestamp.hmac
|
|
96
|
+
// The first segment is the bot's user ID (= Application ID) base64-encoded.
|
|
97
|
+
export function appIdFromToken(token: string): string | undefined {
|
|
98
|
+
const segment = token.split('.')[0]
|
|
99
|
+
if (!segment) {
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const decoded = Buffer.from(segment, 'base64').toString('utf8')
|
|
104
|
+
if (/^\d{17,20}$/.test(decoded)) {
|
|
105
|
+
return decoded
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function hydrateBotTokenCache(botToken: StoredBotToken | null): void {
|
|
114
|
+
dbBotToken = botToken
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isAuthModeEnabled(): boolean {
|
|
118
|
+
return resolveAuthModeConfig() !== null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getBotToken(
|
|
122
|
+
options: GetBotTokenOptions = {},
|
|
123
|
+
): ResolvedBotToken | undefined {
|
|
124
|
+
const { appIdOverride, preferEnv = true, allowDatabase = true } = options
|
|
125
|
+
const authMode = resolveAuthModeConfig()
|
|
126
|
+
|
|
127
|
+
if (authMode) {
|
|
128
|
+
return {
|
|
129
|
+
token: createAuthModeToken(authMode),
|
|
130
|
+
appId: appIdOverride || authMode.appId,
|
|
131
|
+
source: 'auth',
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN
|
|
136
|
+
|
|
137
|
+
if (preferEnv && envToken) {
|
|
138
|
+
return {
|
|
139
|
+
token: envToken,
|
|
140
|
+
appId: appIdOverride || appIdFromToken(envToken),
|
|
141
|
+
source: 'env',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!allowDatabase) {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const botRow = dbBotToken
|
|
150
|
+
if (!botRow) {
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
token: botRow.token,
|
|
156
|
+
appId: appIdOverride || botRow.app_id,
|
|
157
|
+
source: 'db',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Discord channel and category management.
|
|
2
|
+
// Creates and manages Kimaki project channels (text + voice pairs),
|
|
3
|
+
// extracts channel metadata from topic tags, and ensures category structure.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ChannelType,
|
|
7
|
+
type CategoryChannel,
|
|
8
|
+
type Guild,
|
|
9
|
+
type TextChannel,
|
|
10
|
+
} from 'discord.js'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { getChannelDirectory, setChannelDirectory } from './database.js'
|
|
13
|
+
|
|
14
|
+
export async function ensureKimakiCategory(
|
|
15
|
+
guild: Guild,
|
|
16
|
+
botName?: string,
|
|
17
|
+
): Promise<CategoryChannel> {
|
|
18
|
+
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
|
|
19
|
+
const isKimakiBot = botName?.toLowerCase() === 'kimaki'
|
|
20
|
+
const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki'
|
|
21
|
+
|
|
22
|
+
const existingCategory = guild.channels.cache.find(
|
|
23
|
+
(channel): channel is CategoryChannel => {
|
|
24
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (existingCategory) {
|
|
33
|
+
return existingCategory
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return guild.channels.create({
|
|
37
|
+
name: categoryName,
|
|
38
|
+
type: ChannelType.GuildCategory,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function ensureKimakiAudioCategory(
|
|
43
|
+
guild: Guild,
|
|
44
|
+
botName?: string,
|
|
45
|
+
): Promise<CategoryChannel> {
|
|
46
|
+
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
|
|
47
|
+
const isKimakiBot = botName?.toLowerCase() === 'kimaki'
|
|
48
|
+
const categoryName =
|
|
49
|
+
botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
|
|
50
|
+
|
|
51
|
+
const existingCategory = guild.channels.cache.find(
|
|
52
|
+
(channel): channel is CategoryChannel => {
|
|
53
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (existingCategory) {
|
|
62
|
+
return existingCategory
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return guild.channels.create({
|
|
66
|
+
name: categoryName,
|
|
67
|
+
type: ChannelType.GuildCategory,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function createProjectChannels({
|
|
72
|
+
guild,
|
|
73
|
+
projectDirectory,
|
|
74
|
+
appId,
|
|
75
|
+
botName,
|
|
76
|
+
enableVoiceChannels = false,
|
|
77
|
+
}: {
|
|
78
|
+
guild: Guild
|
|
79
|
+
projectDirectory: string
|
|
80
|
+
appId: string
|
|
81
|
+
botName?: string
|
|
82
|
+
enableVoiceChannels?: boolean
|
|
83
|
+
}): Promise<{
|
|
84
|
+
textChannelId: string
|
|
85
|
+
voiceChannelId: string | null
|
|
86
|
+
channelName: string
|
|
87
|
+
}> {
|
|
88
|
+
const baseName = path.basename(projectDirectory)
|
|
89
|
+
const channelName = `${baseName}`
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
92
|
+
.slice(0, 100)
|
|
93
|
+
|
|
94
|
+
const kimakiCategory = await ensureKimakiCategory(guild, botName)
|
|
95
|
+
|
|
96
|
+
const textChannel = await guild.channels.create({
|
|
97
|
+
name: channelName,
|
|
98
|
+
type: ChannelType.GuildText,
|
|
99
|
+
parent: kimakiCategory,
|
|
100
|
+
// Channel configuration is stored in SQLite, not in the topic
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await setChannelDirectory({
|
|
104
|
+
channelId: textChannel.id,
|
|
105
|
+
directory: projectDirectory,
|
|
106
|
+
channelType: 'text',
|
|
107
|
+
appId,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
let voiceChannelId: string | null = null
|
|
111
|
+
|
|
112
|
+
if (enableVoiceChannels) {
|
|
113
|
+
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
|
|
114
|
+
|
|
115
|
+
const voiceChannel = await guild.channels.create({
|
|
116
|
+
name: channelName,
|
|
117
|
+
type: ChannelType.GuildVoice,
|
|
118
|
+
parent: kimakiAudioCategory,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await setChannelDirectory({
|
|
122
|
+
channelId: voiceChannel.id,
|
|
123
|
+
directory: projectDirectory,
|
|
124
|
+
channelType: 'voice',
|
|
125
|
+
appId,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
voiceChannelId = voiceChannel.id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
textChannelId: textChannel.id,
|
|
133
|
+
voiceChannelId,
|
|
134
|
+
channelName,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type ChannelWithTags = {
|
|
139
|
+
id: string
|
|
140
|
+
name: string
|
|
141
|
+
description: string | null
|
|
142
|
+
kimakiDirectory?: string
|
|
143
|
+
kimakiApp?: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function getChannelsWithDescriptions(
|
|
147
|
+
guild: Guild,
|
|
148
|
+
): Promise<ChannelWithTags[]> {
|
|
149
|
+
const channels: ChannelWithTags[] = []
|
|
150
|
+
|
|
151
|
+
const textChannels = guild.channels.cache.filter((channel) =>
|
|
152
|
+
channel.isTextBased(),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
for (const channel of textChannels.values()) {
|
|
156
|
+
const textChannel = channel as TextChannel
|
|
157
|
+
const description = textChannel.topic || null
|
|
158
|
+
|
|
159
|
+
// Get channel config from database instead of parsing XML from topic
|
|
160
|
+
const channelConfig = await getChannelDirectory(textChannel.id)
|
|
161
|
+
|
|
162
|
+
channels.push({
|
|
163
|
+
id: textChannel.id,
|
|
164
|
+
name: textChannel.name,
|
|
165
|
+
description,
|
|
166
|
+
kimakiDirectory: channelConfig?.directory,
|
|
167
|
+
kimakiApp: channelConfig?.appId || undefined,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return channels
|
|
172
|
+
}
|