From 2335c6dfb393a282354c3a007844b815887925ad Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Fri, 6 Mar 2026 14:23:18 +0100 Subject: [PATCH 01/10] Add /helper thread moderation commands with webhook tracking - add new /helper root command with guild-only subcommands: warn-new-thread and close-thread - implement warn-new-thread optional user mention support (matching prior /say new-thread mention behavior) - implement close-thread thread-only guard, then post close message and archive/lock thread - remove /say new-thread subcommand and route this workflow to /helper - add shared command webhook utility posting to HELPER_COMMAND_WEBHOOK_URL - include webhook payload fields: threadId, messageCount, time, command, invokedBy - wire helper command registration in src/index.ts and extend env typings - document HELPER_COMMAND_WEBHOOK_URL and helper command usage in README/.env.example - fix TS typing issues uncovered during validation: interaction.user nullability, thread type guard, github option literal types Validation: npx tsc --noEmit passes --- .env.example | 1 + README.md | 5 +- package-lock.json | 1393 +++++++++++++++++++++++++++++++++++ src/commands/github.ts | 6 +- src/commands/helper.ts | 104 +++ src/commands/say.ts | 8 - src/index.ts | 7 +- src/utils/commandWebhook.ts | 57 ++ 8 files changed, 1567 insertions(+), 14 deletions(-) create mode 100644 package-lock.json create mode 100644 src/commands/helper.ts create mode 100644 src/utils/commandWebhook.ts diff --git a/.env.example b/.env.example index 1bcaa3c..ce5f810 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ DISCORD_BOT_TOKEN= +HELPER_COMMAND_WEBHOOK_URL= diff --git a/README.md b/README.md index 9c151a7..d33b593 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ DEPLOY_SECRET="your-deploy-secret" DISCORD_CLIENT_ID="your-client-id" DISCORD_PUBLIC_KEY="discord-public-key" DISCORD_BOT_TOKEN="your-bot-token" +HELPER_COMMAND_WEBHOOK_URL="https://your-worker.example.workers.dev" ``` 2. Install dependencies: @@ -28,6 +29,8 @@ bun run dev ## Commands - `/github` - Look up an issue or PR (defaults to openclaw/hermit) +- `/helper warn-new-thread` - Post a helper-channel warning for long threads +- `/helper close-thread` - Post a close notice and archive/lock the current thread ## Gateway Events @@ -40,4 +43,4 @@ Edit `src/config/automod-messages.json` to map keywords to messages. Use `{user} ## License -MIT \ No newline at end of file +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ac681a1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1393 @@ +{ + "name": "hermit", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hermit", + "version": "1.0.0", + "dependencies": { + "@buape/carbon": "^0.0.0-beta-20260216184201", + "drizzle-orm": "^0.45.1" + }, + "devDependencies": { + "@types/bun": "1.3.6", + "drizzle-kit": "^0.31.8", + "typescript": "5.9.3" + } + }, + "node_modules/@buape/carbon": { + "version": "0.0.0-beta-20260303183026", + "resolved": "https://registry.npmjs.org/@buape/carbon/-/carbon-0.0.0-beta-20260303183026.tgz", + "integrity": "sha512-rYL8rkIvw6JFQns3ykR2v87cPhMgu3XS79n/+QDv0A3a7yPlwvy6YJ/Ip7duMjNTXhxmbDVbJNpq/QvsiBrZmg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.0.9", + "discord-api-types": "0.38.37" + }, + "optionalDependencies": { + "@cloudflare/workers-types": "4.20260120.0", + "@discordjs/voice": "0.19.0", + "@hono/node-server": "1.19.9", + "@types/bun": "1.3.9", + "@types/ws": "8.18.1", + "ws": "8.19.0" + } + }, + "node_modules/@buape/carbon/node_modules/@types/bun": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", + "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", + "license": "MIT", + "optional": true, + "dependencies": { + "bun-types": "1.3.9" + } + }, + "node_modules/@buape/carbon/node_modules/bun-types": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", + "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260120.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260120.0.tgz", + "integrity": "sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==", + "license": "MIT OR Apache-2.0", + "optional": true + }, + "node_modules/@discordjs/voice": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.0.tgz", + "integrity": "sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@types/ws": "^8.18.1", + "discord-api-types": "^0.38.16", + "prism-media": "^1.3.5", + "tslib": "^2.8.1", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@types/bun": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.6.tgz", + "integrity": "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.6" + } + }, + "node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bun-types": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.6.tgz", + "integrity": "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/discord-api-types": { + "version": "0.38.37", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", + "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/drizzle-kit": { + "version": "0.31.9", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", + "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/hono": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/prism-media": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", + "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", + "license": "Apache-2.0", + "optional": true, + "peerDependencies": { + "@discordjs/opus": ">=0.8.0 <1.0.0", + "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", + "node-opus": "^0.3.3", + "opusscript": "^0.0.8" + }, + "peerDependenciesMeta": { + "@discordjs/opus": { + "optional": true + }, + "ffmpeg-static": { + "optional": true + }, + "node-opus": { + "optional": true + }, + "opusscript": { + "optional": true + } + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/src/commands/github.ts b/src/commands/github.ts index 95b6d61..2e852a7 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -85,18 +85,18 @@ export default class GithubCommand extends BaseCommand { { name: "number", description: "Issue or pull request number", - type: ApplicationCommandOptionType.Integer, + type: ApplicationCommandOptionType.Integer as const, required: true }, { name: "user", description: "Repository owner (default: openclaw)", - type: ApplicationCommandOptionType.String + type: ApplicationCommandOptionType.String as const }, { name: "repo", description: "Repository name (default: hermit)", - type: ApplicationCommandOptionType.String + type: ApplicationCommandOptionType.String as const } ] diff --git a/src/commands/helper.ts b/src/commands/helper.ts new file mode 100644 index 0000000..7df9e7e --- /dev/null +++ b/src/commands/helper.ts @@ -0,0 +1,104 @@ +import { + ApplicationCommandOptionType, + ApplicationIntegrationType, + CommandWithSubcommands, + Container, + type CommandInteraction, + type GuildThreadChannel, + InteractionContextType, + TextDisplay +} from "@buape/carbon" +import BaseCommand from "./base.js" +import { sendCommandWebhook } from "../utils/commandWebhook.js" + +const warnNewThreadMessage = + "This thread is getting very long and answers may not be accurate due to the large context. Please start a new thread for any different problems/topics. @Krill 🦐 please sum up the answer to the initial message and the conversation briefly. This thread will be closed soon." +const closeThreadMessage = + "This thread has gotten very long and spanned multiple topics which will make future reading difficult. This thread is now closed. Please create a new thread for any new topics." + +const isThreadLikeChannel = ( + channel: CommandInteraction["channel"] +): channel is GuildThreadChannel => + Boolean( + channel && + typeof (channel as GuildThreadChannel).archive === "function" && + typeof (channel as GuildThreadChannel).lock === "function" + ) + +class HelperWarnNewThreadCommand extends BaseCommand { + name = "warn-new-thread" + description = "Warn that the thread should be split into a new thread" + integrationTypes = [ApplicationIntegrationType.GuildInstall] + contexts = [InteractionContextType.Guild] + options = [ + { + name: "user", + description: "User to mention", + type: ApplicationCommandOptionType.User as const + } + ] + + async run(interaction: CommandInteraction) { + await sendCommandWebhook(interaction, "/helper warn-new-thread") + const user = interaction.options.getUser("user") + const message = user + ? `${this.formatMention(user.id)}${this.lowercaseFirstLetter(warnNewThreadMessage)}` + : warnNewThreadMessage + + await interaction.reply({ + components: [new Container([new TextDisplay(message)])] + }) + } + + private formatMention(userId: string) { + return `<@${userId}>, ` + } + + private lowercaseFirstLetter(message: string) { + const match = message.match(/[A-Za-z]/) + if (match?.index === undefined) { + return message + } + + const index = match.index + return `${message.slice(0, index)}${message.charAt(index).toLowerCase()}${message.slice(index + 1)}` + } +} + +class HelperCloseThreadCommand extends BaseCommand { + name = "close-thread" + description = "Post a close notice and archive/lock the current thread" + integrationTypes = [ApplicationIntegrationType.GuildInstall] + contexts = [InteractionContextType.Guild] + + async run(interaction: CommandInteraction) { + const channel = interaction.channel + await sendCommandWebhook(interaction, "/helper close-thread") + + if (!isThreadLikeChannel(channel)) { + await interaction.reply({ + components: [ + new Container([ + new TextDisplay("This command can only be used inside a thread.") + ]) + ] + }) + return + } + + await interaction.reply({ + components: [new Container([new TextDisplay(closeThreadMessage)])] + }) + + await channel.archive() + await channel.lock() + } +} + +export default class HelperRootCommand extends CommandWithSubcommands { + name = "helper" + description = "Helper-channel moderation utilities" + integrationTypes = [ApplicationIntegrationType.GuildInstall] + contexts = [InteractionContextType.Guild] + subcommands = [new HelperWarnNewThreadCommand(), new HelperCloseThreadCommand()] +} diff --git a/src/commands/say.ts b/src/commands/say.ts index e56c0ae..a369d07 100644 --- a/src/commands/say.ts +++ b/src/commands/say.ts @@ -40,13 +40,6 @@ class SayModelCommand extends SayCommand { protected message = "Any discussion about various AI models should be taken to <#1478196963563409520>." } -class SayNewThreadCommand extends SayCommand { - name = "new-thread" - description = "Ask for a new thread when topics change" - protected message = "This thread is getting very long and answers may not be accurate due to the large context. Please start a new thread for any different problems/topics. <@1457407575476801641> please sum up the answer to the initial message and the conversation briefly." - protected useRawContent = true -} - class SayStuckCommand extends SayCommand { name = "stuck" description = "Share the fastest way to get unstuck" @@ -111,7 +104,6 @@ export default class SayRootCommand extends CommandWithSubcommands { contexts = [InteractionContextType.Guild, InteractionContextType.BotDM] subcommands = [ new SayModelCommand(), - new SayNewThreadCommand(), new SayHelpCommand(), new SayUserHelpCommand(), new SayServerFaqCommand(), diff --git a/src/index.ts b/src/index.ts index a162346..8e6064f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway" import GithubCommand from "./commands/github.js" import SayRootCommand from "./commands/say.js" import RoleCommand from "./commands/role.js" +import HelperRootCommand from "./commands/helper.js" import AutoModerationActionExecution from "./events/autoModerationActionExecution.js" import AutoPublishMessageCreate from "./events/autoPublishMessageCreate.js" import Ready from "./events/ready.js" @@ -33,7 +34,8 @@ const client = new Client( commands: [ new GithubCommand(), new SayRootCommand(), - new RoleCommand() + new RoleCommand(), + new HelperRootCommand() ], listeners: [ new AutoModerationActionExecution(), @@ -52,6 +54,7 @@ declare global { DISCORD_CLIENT_ID: string; DISCORD_PUBLIC_KEY: string; DISCORD_BOT_TOKEN: string; + HELPER_COMMAND_WEBHOOK_URL?: string; } } -} \ No newline at end of file +} diff --git a/src/utils/commandWebhook.ts b/src/utils/commandWebhook.ts new file mode 100644 index 0000000..9af8e0c --- /dev/null +++ b/src/utils/commandWebhook.ts @@ -0,0 +1,57 @@ +import type { CommandInteraction } from "@buape/carbon" + +type ThreadStatsChannel = { + id?: string + messageCount?: number + totalMessageSent?: number +} + +type CommandWebhookPayload = { + threadId: string | null + messageCount: number | null + time: string + command: string + invokedBy: { + id: string | null + username: string | null + globalName: string | null + } +} + +export const sendCommandWebhook = async ( + interaction: CommandInteraction, + command: string +) => { + const workerUrl = process.env.HELPER_COMMAND_WEBHOOK_URL + if (!workerUrl) { + return + } + + const channel = interaction.channel as ThreadStatsChannel | null + const user = interaction.user + const messageCount = + channel?.totalMessageSent ?? channel?.messageCount ?? null + const payload: CommandWebhookPayload = { + threadId: channel?.id ?? null, + messageCount, + time: new Date().toISOString(), + command, + invokedBy: { + id: user?.id ?? null, + username: user?.username ?? null, + globalName: user?.globalName ?? null + } + } + + try { + await fetch(workerUrl, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify(payload) + }) + } catch { + // Ignore webhook delivery failures so command execution can continue. + } +} From 41c7011793fe6f08df3d705b280875469b4ea69a Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Fri, 6 Mar 2026 14:35:46 +0100 Subject: [PATCH 02/10] Add shared secret header for helper webhook delivery - read HELPER_COMMAND_WEBHOOK_SECRET from env - include x-helper-webhook-secret header on helper webhook POST when configured - document new env var in README and .env.example - extend ProcessEnv typing with HELPER_COMMAND_WEBHOOK_SECRET --- .env.example | 1 + README.md | 3 +++ src/index.ts | 1 + src/utils/commandWebhook.ts | 6 +++++- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index ce5f810..a73618f 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DISCORD_BOT_TOKEN= HELPER_COMMAND_WEBHOOK_URL= +HELPER_COMMAND_WEBHOOK_SECRET= diff --git a/README.md b/README.md index d33b593..7e0e9ca 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ DISCORD_CLIENT_ID="your-client-id" DISCORD_PUBLIC_KEY="discord-public-key" DISCORD_BOT_TOKEN="your-bot-token" HELPER_COMMAND_WEBHOOK_URL="https://your-worker.example.workers.dev" +HELPER_COMMAND_WEBHOOK_SECRET="your-shared-secret" ``` 2. Install dependencies: @@ -32,6 +33,8 @@ bun run dev - `/helper warn-new-thread` - Post a helper-channel warning for long threads - `/helper close-thread` - Post a close notice and archive/lock the current thread +If `HELPER_COMMAND_WEBHOOK_SECRET` is set, Hermit sends it as the `x-helper-webhook-secret` header on helper webhook requests. + ## Gateway Events The bot listens for the following Gateway events: diff --git a/src/index.ts b/src/index.ts index 8e6064f..065d16f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,7 @@ declare global { DISCORD_PUBLIC_KEY: string; DISCORD_BOT_TOKEN: string; HELPER_COMMAND_WEBHOOK_URL?: string; + HELPER_COMMAND_WEBHOOK_SECRET?: string; } } } diff --git a/src/utils/commandWebhook.ts b/src/utils/commandWebhook.ts index 9af8e0c..6cdf905 100644 --- a/src/utils/commandWebhook.ts +++ b/src/utils/commandWebhook.ts @@ -23,6 +23,7 @@ export const sendCommandWebhook = async ( command: string ) => { const workerUrl = process.env.HELPER_COMMAND_WEBHOOK_URL + const webhookSecret = process.env.HELPER_COMMAND_WEBHOOK_SECRET if (!workerUrl) { return } @@ -47,7 +48,10 @@ export const sendCommandWebhook = async ( await fetch(workerUrl, { method: "POST", headers: { - "content-type": "application/json" + "content-type": "application/json", + ...(webhookSecret + ? { "x-helper-webhook-secret": webhookSecret } + : {}) }, body: JSON.stringify(payload) }) From c297aa5233c829ab9fe0f3af59c5c986e077e648 Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Fri, 6 Mar 2026 15:13:03 +0100 Subject: [PATCH 03/10] Add helper-thread welcome message on thread creation - add ThreadCreate listener to post onboarding guidance for new helper threads - target one configured helper parent channel via HELPER_THREAD_WELCOME_PARENT_ID - set default in-file helper welcome template (overrideable via HELPER_THREAD_WELCOME_TEMPLATE) - register listener in src/index.ts and update env/docs typing and examples --- .env.example | 2 ++ README.md | 4 +++ src/events/threadCreateWelcome.ts | 55 +++++++++++++++++++++++++++++++ src/index.ts | 4 +++ 4 files changed, 65 insertions(+) create mode 100644 src/events/threadCreateWelcome.ts diff --git a/.env.example b/.env.example index a73618f..ecfc9d3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ DISCORD_BOT_TOKEN= HELPER_COMMAND_WEBHOOK_URL= HELPER_COMMAND_WEBHOOK_SECRET= +HELPER_THREAD_WELCOME_PARENT_ID= +HELPER_THREAD_WELCOME_TEMPLATE= diff --git a/README.md b/README.md index 7e0e9ca..b29c068 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ DISCORD_PUBLIC_KEY="discord-public-key" DISCORD_BOT_TOKEN="your-bot-token" HELPER_COMMAND_WEBHOOK_URL="https://your-worker.example.workers.dev" HELPER_COMMAND_WEBHOOK_SECRET="your-shared-secret" +HELPER_THREAD_WELCOME_PARENT_ID="123456789012345678" +HELPER_THREAD_WELCOME_TEMPLATE="Welcome to helpers. Please include expected vs actual behavior, what you already tried, and relevant logs/code." ``` 2. Install dependencies: @@ -35,6 +37,8 @@ bun run dev If `HELPER_COMMAND_WEBHOOK_SECRET` is set, Hermit sends it as the `x-helper-webhook-secret` header on helper webhook requests. +Hermit sends a welcome message for every newly created thread under the configured helper parent channel (`HELPER_THREAD_WELCOME_PARENT_ID`). + ## Gateway Events The bot listens for the following Gateway events: diff --git a/src/events/threadCreateWelcome.ts b/src/events/threadCreateWelcome.ts new file mode 100644 index 0000000..e6144cd --- /dev/null +++ b/src/events/threadCreateWelcome.ts @@ -0,0 +1,55 @@ +import { + Container, + type Client, + type ListenerEventData, + TextDisplay, + ThreadCreateListener +} from "@buape/carbon" + +const defaultWelcomeTemplate = + `Welcome to the help channel! + +Krill cannot see your system — it only knows what you tell it. The more details you include, the easier it is to help. + +If you haven’t included it yet, please consider sending: + +What you’re trying to do (goal / expected behaviour) + +What happened instead (exact error message) + +What you ran or clicked (commands, config snippet, etc.) + +Your environment (OS, install method, versions) + +Relevant logs (the smallest useful snippet) + +Posts like “it doesn’t work” without details are very hard to debug. + +If new issues arise, please open a new thread/topic instead of continuing in this one. Keeping one issue per thread helps ensure answers stay accurate and makes it easier for others to find solutions later.` + +export default class ThreadCreateWelcome extends ThreadCreateListener { + async handle(data: ListenerEventData[this["type"]], _client: Client) { + const welcomeParentId = process.env.HELPER_THREAD_WELCOME_PARENT_ID?.trim() + if (!welcomeParentId) { + return + } + + const thread = data.thread + const parentId = thread.parentId + + if (thread.archived || !parentId || parentId !== welcomeParentId) { + return + } + + const template = + process.env.HELPER_THREAD_WELCOME_TEMPLATE ?? defaultWelcomeTemplate + + try { + await thread.send({ + components: [new Container([new TextDisplay(template)])] + }) + } catch (error) { + console.error("Failed to send thread welcome message:", error) + } + } +} diff --git a/src/index.ts b/src/index.ts index 065d16f..d76d81b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import HelperRootCommand from "./commands/helper.js" import AutoModerationActionExecution from "./events/autoModerationActionExecution.js" import AutoPublishMessageCreate from "./events/autoPublishMessageCreate.js" import Ready from "./events/ready.js" +import ThreadCreateWelcome from "./events/threadCreateWelcome.js" const gateway = new GatewayPlugin({ intents: @@ -40,6 +41,7 @@ const client = new Client( listeners: [ new AutoModerationActionExecution(), new AutoPublishMessageCreate(), + new ThreadCreateWelcome(), new Ready() ], }, @@ -56,6 +58,8 @@ declare global { DISCORD_BOT_TOKEN: string; HELPER_COMMAND_WEBHOOK_URL?: string; HELPER_COMMAND_WEBHOOK_SECRET?: string; + HELPER_THREAD_WELCOME_PARENT_ID?: string; + HELPER_THREAD_WELCOME_TEMPLATE?: string; } } } From 1c29a2a28242fb7662b2e1f7620f46324ffbdf4f Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Fri, 6 Mar 2026 16:18:47 +0100 Subject: [PATCH 04/10] Add /helper close command for thread lock+archive - add /helper close subcommand that posts close message then archives and locks thread - keep /helper close-thread as compatibility alias using shared close flow - update README command list --- README.md | 1 + src/commands/helper.ts | 28 +++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b29c068..3aec2d2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ bun run dev - `/github` - Look up an issue or PR (defaults to openclaw/hermit) - `/helper warn-new-thread` - Post a helper-channel warning for long threads +- `/helper close` - Post a close notice and archive/lock the current thread - `/helper close-thread` - Post a close notice and archive/lock the current thread If `HELPER_COMMAND_WEBHOOK_SECRET` is set, Hermit sends it as the `x-helper-webhook-secret` header on helper webhook requests. diff --git a/src/commands/helper.ts b/src/commands/helper.ts index 7df9e7e..3d58d9b 100644 --- a/src/commands/helper.ts +++ b/src/commands/helper.ts @@ -72,8 +72,27 @@ class HelperCloseThreadCommand extends BaseCommand { contexts = [InteractionContextType.Guild] async run(interaction: CommandInteraction) { + await closeHelperThread(interaction, "/helper close-thread") + } +} + +class HelperCloseCommand extends BaseCommand { + name = "close" + description = "Close and lock the current thread" + integrationTypes = [ApplicationIntegrationType.GuildInstall] + contexts = [InteractionContextType.Guild] + + async run(interaction: CommandInteraction) { + await closeHelperThread(interaction, "/helper close") + } +} + +const closeHelperThread = async ( + interaction: CommandInteraction, + commandName: string +) => { const channel = interaction.channel - await sendCommandWebhook(interaction, "/helper close-thread") + await sendCommandWebhook(interaction, commandName) if (!isThreadLikeChannel(channel)) { await interaction.reply({ @@ -92,7 +111,6 @@ class HelperCloseThreadCommand extends BaseCommand { await channel.archive() await channel.lock() - } } export default class HelperRootCommand extends CommandWithSubcommands { @@ -100,5 +118,9 @@ export default class HelperRootCommand extends CommandWithSubcommands { description = "Helper-channel moderation utilities" integrationTypes = [ApplicationIntegrationType.GuildInstall] contexts = [InteractionContextType.Guild] - subcommands = [new HelperWarnNewThreadCommand(), new HelperCloseThreadCommand()] + subcommands = [ + new HelperWarnNewThreadCommand(), + new HelperCloseCommand(), + new HelperCloseThreadCommand() + ] } From 187c4f9684b74ada791fd4ff5645a01c518e7080 Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Mon, 9 Mar 2026 15:12:23 +0100 Subject: [PATCH 05/10] Updated to mention Krill by ID and compact welcome message --- src/commands/helper.ts | 2 +- src/events/threadCreateWelcome.ts | 21 +++++++-------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/commands/helper.ts b/src/commands/helper.ts index 3d58d9b..97614cf 100644 --- a/src/commands/helper.ts +++ b/src/commands/helper.ts @@ -12,7 +12,7 @@ import BaseCommand from "./base.js" import { sendCommandWebhook } from "../utils/commandWebhook.js" const warnNewThreadMessage = - "This thread is getting very long and answers may not be accurate due to the large context. Please start a new thread for any different problems/topics. @Krill 🦐 please sum up the answer to the initial message and the conversation briefly. This thread will be closed soon." + "This thread is getting very long and answers may not be accurate due to the large context. Please start a new thread for any different problems/topics. <@1457407575476801641> please sum up the answer to the initial message and the conversation briefly. This thread will be closed soon." const closeThreadMessage = "This thread has gotten very long and spanned multiple topics which will make future reading difficult. This thread is now closed. Please create a new thread for any new topics." diff --git a/src/events/threadCreateWelcome.ts b/src/events/threadCreateWelcome.ts index e6144cd..7e9020c 100644 --- a/src/events/threadCreateWelcome.ts +++ b/src/events/threadCreateWelcome.ts @@ -9,23 +9,16 @@ import { const defaultWelcomeTemplate = `Welcome to the help channel! -Krill cannot see your system — it only knows what you tell it. The more details you include, the easier it is to help. - -If you haven’t included it yet, please consider sending: - -What you’re trying to do (goal / expected behaviour) - -What happened instead (exact error message) - -What you ran or clicked (commands, config snippet, etc.) - -Your environment (OS, install method, versions) - -Relevant logs (the smallest useful snippet) +<@1457407575476801641> cannot see your system — it only knows what you tell it. The more details you include, the easier it is to help. If you haven’t included it yet, please consider sending: +- What you’re trying to do (goal / expected behaviour) +- What happened instead (exact error message) +- What you ran or clicked (commands, config snippet, etc.) +- Your environment (OS, install method, versions) +- Relevant logs (the smallest useful snippet) Posts like “it doesn’t work” without details are very hard to debug. -If new issues arise, please open a new thread/topic instead of continuing in this one. Keeping one issue per thread helps ensure answers stay accurate and makes it easier for others to find solutions later.` +If new issues arise, please open a new thread instead of continuing here — one issue per thread helps keep answers accurate and searchable.` export default class ThreadCreateWelcome extends ThreadCreateListener { async handle(data: ListenerEventData[this["type"]], _client: Client) { From 158bd3e3d197bee6ba1673c4fd94e325f581c83a Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Mon, 9 Mar 2026 15:15:08 +0100 Subject: [PATCH 06/10] Add moderated Answer Overflow solution marking --- .env.example | 6 +- README.md | 9 +- src/commands/helper.ts | 10 +- src/commands/solvedMod.ts | 240 ++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +- src/utils/commandWebhook.ts | 61 --------- src/utils/workerEvent.ts | 74 +++++++++++ 7 files changed, 337 insertions(+), 71 deletions(-) create mode 100644 src/commands/solvedMod.ts delete mode 100644 src/utils/commandWebhook.ts create mode 100644 src/utils/workerEvent.ts diff --git a/.env.example b/.env.example index ecfc9d3..1aeea2d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ DISCORD_BOT_TOKEN= -HELPER_COMMAND_WEBHOOK_URL= -HELPER_COMMAND_WEBHOOK_SECRET= +ANSWER_OVERFLOW_API_KEY= +ANSWER_OVERFLOW_API_BASE_URL= +WORKER_EVENT_URL= +WORKER_EVENT_SECRET= HELPER_THREAD_WELCOME_PARENT_ID= HELPER_THREAD_WELCOME_TEMPLATE= diff --git a/README.md b/README.md index 3aec2d2..4782691 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ DEPLOY_SECRET="your-deploy-secret" DISCORD_CLIENT_ID="your-client-id" DISCORD_PUBLIC_KEY="discord-public-key" DISCORD_BOT_TOKEN="your-bot-token" -HELPER_COMMAND_WEBHOOK_URL="https://your-worker.example.workers.dev" -HELPER_COMMAND_WEBHOOK_SECRET="your-shared-secret" +ANSWER_OVERFLOW_API_KEY="your-answer-overflow-api-key" +ANSWER_OVERFLOW_API_BASE_URL="https://www.answeroverflow.com" +WORKER_EVENT_URL="https://your-worker.example.workers.dev" +WORKER_EVENT_SECRET="your-shared-secret" HELPER_THREAD_WELCOME_PARENT_ID="123456789012345678" HELPER_THREAD_WELCOME_TEMPLATE="Welcome to helpers. Please include expected vs actual behavior, what you already tried, and relevant logs/code." ``` @@ -32,11 +34,12 @@ bun run dev ## Commands - `/github` - Look up an issue or PR (defaults to openclaw/hermit) +- `Solved (Mod)` - Moderator-only message context menu item that marks the current thread as solved in Answer Overflow - `/helper warn-new-thread` - Post a helper-channel warning for long threads - `/helper close` - Post a close notice and archive/lock the current thread - `/helper close-thread` - Post a close notice and archive/lock the current thread -If `HELPER_COMMAND_WEBHOOK_SECRET` is set, Hermit sends it as the `x-helper-webhook-secret` header on helper webhook requests. +If `WORKER_EVENT_SECRET` is set, Hermit sends it as the `x-worker-event-secret` header on worker event requests. Hermit sends a welcome message for every newly created thread under the configured helper parent channel (`HELPER_THREAD_WELCOME_PARENT_ID`). diff --git a/src/commands/helper.ts b/src/commands/helper.ts index 97614cf..48cd755 100644 --- a/src/commands/helper.ts +++ b/src/commands/helper.ts @@ -9,7 +9,7 @@ import { TextDisplay } from "@buape/carbon" import BaseCommand from "./base.js" -import { sendCommandWebhook } from "../utils/commandWebhook.js" +import { sendWorkerEvent } from "../utils/workerEvent.js" const warnNewThreadMessage = "This thread is getting very long and answers may not be accurate due to the large context. Please start a new thread for any different problems/topics. <@1457407575476801641> please sum up the answer to the initial message and the conversation briefly. This thread will be closed soon." @@ -39,7 +39,9 @@ class HelperWarnNewThreadCommand extends BaseCommand { ] async run(interaction: CommandInteraction) { - await sendCommandWebhook(interaction, "/helper warn-new-thread") + await sendWorkerEvent(interaction, "helper_command", { + command: "/helper warn-new-thread" + }) const user = interaction.options.getUser("user") const message = user ? `${this.formatMention(user.id)}${this.lowercaseFirstLetter(warnNewThreadMessage)}` @@ -92,7 +94,9 @@ const closeHelperThread = async ( commandName: string ) => { const channel = interaction.channel - await sendCommandWebhook(interaction, commandName) + await sendWorkerEvent(interaction, "helper_command", { + command: commandName + }) if (!isThreadLikeChannel(channel)) { await interaction.reply({ diff --git a/src/commands/solvedMod.ts b/src/commands/solvedMod.ts new file mode 100644 index 0000000..d3a0216 --- /dev/null +++ b/src/commands/solvedMod.ts @@ -0,0 +1,240 @@ +import { + ApplicationCommandType, + ApplicationIntegrationType, + Container, + type CommandInteraction, + GuildThreadChannel, + InteractionContextType, + Permission, + Routes, + TextDisplay +} from "@buape/carbon" +import BaseCommand from "./base.js" +import { sendWorkerEvent } from "../utils/workerEvent.js" + +const answerOverflowBaseUrl = ( + process.env.ANSWER_OVERFLOW_API_BASE_URL ?? "https://www.answeroverflow.com" +).replace(/\/+$/, "") + +type MarkSolutionResponse = { + success?: boolean +} + +type MarkSolutionWorkerEvent = { + command: string + threadId: string | null + questionMessageId: string + solutionMessageId: string + solutionAuthorId: string | null + aoRequest: { + url: string + success: boolean + status: number | null + } + discordActions: { + reactionAdded: boolean + threadArchived: boolean + threadLocked: boolean + } + error: string | null +} + +const isThreadLikeChannel = ( + channel: CommandInteraction["channel"] +): channel is GuildThreadChannel => + Boolean( + channel && + typeof (channel as GuildThreadChannel).archive === "function" && + typeof (channel as GuildThreadChannel).lock === "function" + ) + +const addCheckmarkReaction = async (interaction: CommandInteraction) => { + const message = interaction.targetMessage + if (!message) { + return + } + + await interaction.client.rest.put( + Routes.channelMessageOwnReaction( + message.channelId, + message.id, + encodeURIComponent("✅") + ) + ) +} + +export default class SolvedModCommand extends BaseCommand { + name = "Solved (Mod)" + type = ApplicationCommandType.Message + integrationTypes = [ApplicationIntegrationType.GuildInstall] + contexts = [InteractionContextType.Guild] + permission = [Permission.ManageMessages, Permission.ManageThreads] + + async run(interaction: CommandInteraction) { + const targetMessage = interaction.targetMessage + const channel = interaction.channel + const apiKey = process.env.ANSWER_OVERFLOW_API_KEY + + if (!targetMessage) { + await interaction.reply({ + components: [ + new Container([ + new TextDisplay("This action requires a target message.") + ], { accentColor: "#f85149" }) + ] + }) + return + } + + if (!isThreadLikeChannel(channel)) { + await interaction.reply({ + components: [ + new Container([ + new TextDisplay("This action can only be used inside a thread.") + ], { accentColor: "#f85149" }) + ] + }) + return + } + + if (!apiKey) { + await interaction.reply({ + components: [ + new Container([ + new TextDisplay("ANSWER_OVERFLOW_API_KEY is not configured.") + ], { accentColor: "#f85149" }) + ] + }) + return + } + + const questionMessageId = channel.id + const solutionMessageId = targetMessage.id + const aoUrl = `${answerOverflowBaseUrl}/api/v1/messages/${questionMessageId}` + + let aoSuccess = false + let aoStatus: number | null = null + let errorMessage: string | null = null + let reactionAdded = false + let threadArchived = false + let threadLocked = false + + try { + const response = await fetch(aoUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey + }, + body: JSON.stringify({ + solutionId: solutionMessageId + }) + }) + + aoStatus = response.status + if (response.ok) { + const result = (await response.json()) as MarkSolutionResponse + aoSuccess = result.success === true + } + + if (!aoSuccess) { + errorMessage = response.ok + ? "Answer Overflow did not confirm the solution update." + : `Answer Overflow returned ${response.status}.` + } + } catch (error) { + errorMessage = + error instanceof Error + ? error.message + : "Could not reach Answer Overflow." + } + + if (!aoSuccess) { + await sendWorkerEvent( + interaction, + "mark_solution", + { + command: "Solved (Mod)", + threadId: channel.id, + questionMessageId, + solutionMessageId, + solutionAuthorId: targetMessage.author?.id ?? null, + aoRequest: { + url: aoUrl, + success: false, + status: aoStatus + }, + discordActions: { + reactionAdded, + threadArchived, + threadLocked + }, + error: errorMessage + } + ) + + await interaction.reply({ + components: [ + new Container([ + new TextDisplay( + errorMessage ?? "Failed to mark this thread as solved." + ) + ], { accentColor: "#f85149" }) + ] + }) + return + } + + try { + await addCheckmarkReaction(interaction) + reactionAdded = true + } catch (error) { + errorMessage = + error instanceof Error ? error.message : "Failed to add the checkmark reaction." + } + + try { + await channel.archive() + threadArchived = true + } catch (error) { + errorMessage = + error instanceof Error ? error.message : "Failed to archive the thread." + } + + try { + await channel.lock() + threadLocked = true + } catch (error) { + errorMessage = + error instanceof Error ? error.message : "Failed to lock the thread." + } + + await sendWorkerEvent(interaction, "mark_solution", { + command: "Solved (Mod)", + threadId: channel.id, + questionMessageId, + solutionMessageId, + solutionAuthorId: targetMessage.author?.id ?? null, + aoRequest: { + url: aoUrl, + success: true, + status: aoStatus + }, + discordActions: { + reactionAdded, + threadArchived, + threadLocked + }, + error: errorMessage + }) + + const resultMessage = + errorMessage === null + ? "Marked the thread as solved, added a checkmark, and closed the thread." + : "Marked the thread as solved in Answer Overflow, but some Discord cleanup steps failed." + + await interaction.reply({ + components: [new Container([new TextDisplay(resultMessage)])] + }) + } +} diff --git a/src/index.ts b/src/index.ts index d76d81b..045824d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { Client } from "@buape/carbon" import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway" import GithubCommand from "./commands/github.js" +import SolvedModCommand from "./commands/solvedMod.js" import SayRootCommand from "./commands/say.js" import RoleCommand from "./commands/role.js" import HelperRootCommand from "./commands/helper.js" @@ -34,6 +35,7 @@ const client = new Client( { commands: [ new GithubCommand(), + new SolvedModCommand(), new SayRootCommand(), new RoleCommand(), new HelperRootCommand() @@ -56,8 +58,10 @@ declare global { DISCORD_CLIENT_ID: string; DISCORD_PUBLIC_KEY: string; DISCORD_BOT_TOKEN: string; - HELPER_COMMAND_WEBHOOK_URL?: string; - HELPER_COMMAND_WEBHOOK_SECRET?: string; + ANSWER_OVERFLOW_API_KEY?: string; + ANSWER_OVERFLOW_API_BASE_URL?: string; + WORKER_EVENT_URL?: string; + WORKER_EVENT_SECRET?: string; HELPER_THREAD_WELCOME_PARENT_ID?: string; HELPER_THREAD_WELCOME_TEMPLATE?: string; } diff --git a/src/utils/commandWebhook.ts b/src/utils/commandWebhook.ts deleted file mode 100644 index 6cdf905..0000000 --- a/src/utils/commandWebhook.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { CommandInteraction } from "@buape/carbon" - -type ThreadStatsChannel = { - id?: string - messageCount?: number - totalMessageSent?: number -} - -type CommandWebhookPayload = { - threadId: string | null - messageCount: number | null - time: string - command: string - invokedBy: { - id: string | null - username: string | null - globalName: string | null - } -} - -export const sendCommandWebhook = async ( - interaction: CommandInteraction, - command: string -) => { - const workerUrl = process.env.HELPER_COMMAND_WEBHOOK_URL - const webhookSecret = process.env.HELPER_COMMAND_WEBHOOK_SECRET - if (!workerUrl) { - return - } - - const channel = interaction.channel as ThreadStatsChannel | null - const user = interaction.user - const messageCount = - channel?.totalMessageSent ?? channel?.messageCount ?? null - const payload: CommandWebhookPayload = { - threadId: channel?.id ?? null, - messageCount, - time: new Date().toISOString(), - command, - invokedBy: { - id: user?.id ?? null, - username: user?.username ?? null, - globalName: user?.globalName ?? null - } - } - - try { - await fetch(workerUrl, { - method: "POST", - headers: { - "content-type": "application/json", - ...(webhookSecret - ? { "x-helper-webhook-secret": webhookSecret } - : {}) - }, - body: JSON.stringify(payload) - }) - } catch { - // Ignore webhook delivery failures so command execution can continue. - } -} diff --git a/src/utils/workerEvent.ts b/src/utils/workerEvent.ts new file mode 100644 index 0000000..2dc4b6a --- /dev/null +++ b/src/utils/workerEvent.ts @@ -0,0 +1,74 @@ +import type { CommandInteraction } from "@buape/carbon" + +type ThreadStatsChannel = { + id?: string + messageCount?: number + totalMessageSent?: number +} + +type WorkerEventActor = { + id: string | null + username: string | null + globalName: string | null +} + +type WorkerEventContext = { + guildId: string | null + channelId: string | null + threadId: string | null + messageCount: number | null +} + +type WorkerEventPayload = { + type: string + time: string + invokedBy: WorkerEventActor + context: WorkerEventContext + data: TData +} + +export const sendWorkerEvent = async ( + interaction: CommandInteraction, + type: string, + data: TData +) => { + const workerUrl = process.env.WORKER_EVENT_URL + const workerSecret = process.env.WORKER_EVENT_SECRET + if (!workerUrl) { + return + } + + const channel = interaction.channel as ThreadStatsChannel | null + const user = interaction.user + const messageCount = + channel?.totalMessageSent ?? channel?.messageCount ?? null + const payload: WorkerEventPayload = { + type, + time: new Date().toISOString(), + invokedBy: { + id: user?.id ?? null, + username: user?.username ?? null, + globalName: user?.globalName ?? null + }, + context: { + guildId: interaction.guild?.id ?? null, + channelId: interaction.channel?.id ?? null, + threadId: channel?.id ?? null, + messageCount + }, + data + } + + try { + await fetch(workerUrl, { + method: "POST", + headers: { + "content-type": "application/json", + ...(workerSecret ? { "x-worker-event-secret": workerSecret } : {}) + }, + body: JSON.stringify(payload) + }) + } catch { + // Ignore worker event failures so command execution can continue. + } +} From 4150d4d5fccf2680ade1d2ee2828e4346f8fc550 Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Mon, 9 Mar 2026 15:48:56 +0100 Subject: [PATCH 07/10] Send worker event for welcome threads --- src/events/threadCreateWelcome.ts | 18 +++++++++ src/utils/workerEvent.ts | 62 +++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/events/threadCreateWelcome.ts b/src/events/threadCreateWelcome.ts index 7e9020c..ce339b9 100644 --- a/src/events/threadCreateWelcome.ts +++ b/src/events/threadCreateWelcome.ts @@ -5,6 +5,7 @@ import { TextDisplay, ThreadCreateListener } from "@buape/carbon" +import { postWorkerEvent } from "../utils/workerEvent.js" const defaultWelcomeTemplate = `Welcome to the help channel! @@ -37,6 +38,23 @@ export default class ThreadCreateWelcome extends ThreadCreateListener { const template = process.env.HELPER_THREAD_WELCOME_TEMPLATE ?? defaultWelcomeTemplate + await postWorkerEvent({ + type: "thread_welcome_created", + invokedBy: { + id: null, + username: null, + globalName: null + }, + context: { + guildId: thread.guildId ?? null, + channelId: parentId, + threadId: thread.id, + messageCount: null, + parentId + }, + data: {} + }) + try { await thread.send({ components: [new Container([new TextDisplay(template)])] diff --git a/src/utils/workerEvent.ts b/src/utils/workerEvent.ts index 2dc4b6a..adb1444 100644 --- a/src/utils/workerEvent.ts +++ b/src/utils/workerEvent.ts @@ -17,6 +17,7 @@ type WorkerEventContext = { channelId: string | null threadId: string | null messageCount: number | null + parentId?: string | null } type WorkerEventPayload = { @@ -27,35 +28,30 @@ type WorkerEventPayload = { data: TData } -export const sendWorkerEvent = async ( - interaction: CommandInteraction, - type: string, +type SendWorkerEventInput = { + type: string + invokedBy: WorkerEventActor + context: WorkerEventContext data: TData -) => { +} + +export const postWorkerEvent = async ({ + type, + invokedBy, + context, + data +}: SendWorkerEventInput) => { const workerUrl = process.env.WORKER_EVENT_URL const workerSecret = process.env.WORKER_EVENT_SECRET if (!workerUrl) { return } - const channel = interaction.channel as ThreadStatsChannel | null - const user = interaction.user - const messageCount = - channel?.totalMessageSent ?? channel?.messageCount ?? null const payload: WorkerEventPayload = { type, time: new Date().toISOString(), - invokedBy: { - id: user?.id ?? null, - username: user?.username ?? null, - globalName: user?.globalName ?? null - }, - context: { - guildId: interaction.guild?.id ?? null, - channelId: interaction.channel?.id ?? null, - threadId: channel?.id ?? null, - messageCount - }, + invokedBy, + context, data } @@ -69,6 +65,32 @@ export const sendWorkerEvent = async ( body: JSON.stringify(payload) }) } catch { - // Ignore worker event failures so command execution can continue. + // Ignore worker event failures so primary flows can continue. } } + +export const sendWorkerEvent = async ( + interaction: CommandInteraction, + type: string, + data: TData +) => { + const channel = interaction.channel as ThreadStatsChannel | null + const user = interaction.user + const messageCount = + channel?.totalMessageSent ?? channel?.messageCount ?? null + await postWorkerEvent({ + type, + invokedBy: { + id: user?.id ?? null, + username: user?.username ?? null, + globalName: user?.globalName ?? null + }, + context: { + guildId: interaction.guild?.id ?? null, + channelId: interaction.channel?.id ?? null, + threadId: channel?.id ?? null, + messageCount + }, + data + }) +} From 2561e5ff4c980955a773e92be2b77dabc07b5a6e Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Mon, 9 Mar 2026 16:14:34 +0100 Subject: [PATCH 08/10] Add Hermit-managed thread length monitoring --- README.md | 41 +++++ src/config/threadLengthMessages.ts | 8 + src/events/ready.ts | 4 +- src/events/threadCreateWelcome.ts | 52 ++++-- src/index.ts | 1 + src/services/threadLengthMonitor.ts | 251 ++++++++++++++++++++++++++++ src/utils/trackedThreads.ts | 99 +++++++++++ 7 files changed, 440 insertions(+), 16 deletions(-) create mode 100644 src/config/threadLengthMessages.ts create mode 100644 src/services/threadLengthMonitor.ts create mode 100644 src/utils/trackedThreads.ts diff --git a/README.md b/README.md index 4782691..0f4258b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ WORKER_EVENT_URL="https://your-worker.example.workers.dev" WORKER_EVENT_SECRET="your-shared-secret" HELPER_THREAD_WELCOME_PARENT_ID="123456789012345678" HELPER_THREAD_WELCOME_TEMPLATE="Welcome to helpers. Please include expected vs actual behavior, what you already tried, and relevant logs/code." +THREAD_LENGTH_CHECK_INTERVAL_HOURS="2" ``` 2. Install dependencies: @@ -43,6 +44,46 @@ If `WORKER_EVENT_SECRET` is set, Hermit sends it as the `x-worker-event-secret` Hermit sends a welcome message for every newly created thread under the configured helper parent channel (`HELPER_THREAD_WELCOME_PARENT_ID`). +Hermit also registers those welcome threads in the worker's tracked-thread API before posting the welcome message. Registration failures are logged but do not block the Discord welcome message. + +## Thread Length Monitoring + +Hermit owns the thread-length policy. The worker only stores tracked-thread state. + +When `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is set to a positive number, Hermit starts a background polling loop on startup that: + +- requests active tracked threads from the worker +- fetches each Discord thread directly +- checks whether the thread is already archived or locked +- sends warning messages or auto-closes the thread based on live Discord message counts +- writes the latest thread state back to the worker + +Thresholds: + +- more than `100` messages: first warning +- more than `150` messages: second warning asking users to close solved threads and move new issues to a new thread +- more than `200` messages: automatic close notice, then archive + lock + +Messages are stored in git-tracked files: + +- `src/config/threadLengthMessages.ts` + +Hermit tracks the following worker fields for each thread: + +- `threadId` +- `createdAt` +- `lastChecked` +- `solved` +- `warningLevel` +- `closed` +- `lastMessageCount` + +Notes: + +- If `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is unset, the poller stays disabled. +- Threads that are already archived or locked are marked as closed in worker state and skipped on future passes. +- The worker URL is derived from `WORKER_EVENT_URL`, so the same base worker configuration is used for event logging and tracked-thread reads/writes. + ## Gateway Events The bot listens for the following Gateway events: diff --git a/src/config/threadLengthMessages.ts b/src/config/threadLengthMessages.ts new file mode 100644 index 0000000..e994739 --- /dev/null +++ b/src/config/threadLengthMessages.ts @@ -0,0 +1,8 @@ +export const threadLengthWarning100Message = + "This thread is getting very long and answers may not be accurate due to the large context. Please start a new thread for any different problems/topics. If the original issue is solved, please mark the solution. <@1457407575476801641> please sum up the answer to the initial message and the conversation briefly." + +export const threadLengthWarning150Message = + "This thread is getting very long and answers may not be accurate due to the large context. If the original issue is solved, please mark the solution and close the thread. Please start a new thread for any different problems/topics. <@1457407575476801641> please sum up the answer to the initial message and the conversation briefly." + +export const threadLengthClose200Message = + "This thread has gotten very long and spanned multiple topics which will make future reading difficult. This thread is now closed automatically. Please create a new thread for any new topics." diff --git a/src/events/ready.ts b/src/events/ready.ts index ff664ba..aff61ed 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -3,9 +3,11 @@ import { ReadyListener, type ListenerEventData } from "@buape/carbon" +import { startThreadLengthMonitor } from "../services/threadLengthMonitor.js" export default class Ready extends ReadyListener { async handle(data: ListenerEventData[this["type"]], client: Client) { console.log(`Logged in as ${data.user.username}`) + startThreadLengthMonitor(client) } -} \ No newline at end of file +} diff --git a/src/events/threadCreateWelcome.ts b/src/events/threadCreateWelcome.ts index ce339b9..f3feb11 100644 --- a/src/events/threadCreateWelcome.ts +++ b/src/events/threadCreateWelcome.ts @@ -5,6 +5,7 @@ import { TextDisplay, ThreadCreateListener } from "@buape/carbon" +import { upsertTrackedThread } from "../utils/trackedThreads.js" import { postWorkerEvent } from "../utils/workerEvent.js" const defaultWelcomeTemplate = @@ -38,22 +39,43 @@ export default class ThreadCreateWelcome extends ThreadCreateListener { const template = process.env.HELPER_THREAD_WELCOME_TEMPLATE ?? defaultWelcomeTemplate - await postWorkerEvent({ - type: "thread_welcome_created", - invokedBy: { - id: null, - username: null, - globalName: null - }, - context: { - guildId: thread.guildId ?? null, - channelId: parentId, + const createdAt = thread.createTimestamp ?? new Date().toISOString() + const initialMessageCount = + thread.totalMessageSent ?? thread.messageCount ?? null + + const workerEventResult = await Promise.allSettled([ + postWorkerEvent({ + type: "thread_welcome_created", + invokedBy: { + id: null, + username: null, + globalName: null + }, + context: { + guildId: thread.guildId ?? null, + channelId: parentId, + threadId: thread.id, + messageCount: initialMessageCount, + parentId + }, + data: {} + }), + upsertTrackedThread({ threadId: thread.id, - messageCount: null, - parentId - }, - data: {} - }) + createdAt, + lastChecked: null, + solved: false, + warningLevel: 0, + closed: false, + lastMessageCount: initialMessageCount + }) + ]) + + for (const result of workerEventResult) { + if (result.status === "rejected") { + console.error("Failed to register tracked helper thread:", result.reason) + } + } try { await thread.send({ diff --git a/src/index.ts b/src/index.ts index 045824d..b56db91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ declare global { WORKER_EVENT_SECRET?: string; HELPER_THREAD_WELCOME_PARENT_ID?: string; HELPER_THREAD_WELCOME_TEMPLATE?: string; + THREAD_LENGTH_CHECK_INTERVAL_HOURS?: string; } } } diff --git a/src/services/threadLengthMonitor.ts b/src/services/threadLengthMonitor.ts new file mode 100644 index 0000000..2bed5ca --- /dev/null +++ b/src/services/threadLengthMonitor.ts @@ -0,0 +1,251 @@ +import { + type Client, + Container, + GuildThreadChannel, + TextDisplay +} from "@buape/carbon" +import { + threadLengthClose200Message, + threadLengthWarning100Message, + threadLengthWarning150Message +} from "../config/threadLengthMessages.js" +import { + listTrackedThreads, + type TrackedThreadRecord, + upsertTrackedThread +} from "../utils/trackedThreads.js" + +const FIRST_WARNING_THRESHOLD = 100 +const SECOND_WARNING_THRESHOLD = 150 +const AUTO_CLOSE_THRESHOLD = 200 +const DEFAULT_FETCH_LIMIT = 500 + +let monitorStarted = false +let monitorInterval: ReturnType | null = null +let monitorRunInFlight = false + +const parseIntervalMs = () => { + const rawValue = process.env.THREAD_LENGTH_CHECK_INTERVAL_HOURS?.trim() + if (!rawValue) { + return null + } + + const intervalHours = Number.parseFloat(rawValue) + if (!Number.isFinite(intervalHours) || intervalHours <= 0) { + console.warn( + `THREAD_LENGTH_CHECK_INTERVAL_HOURS must be a positive number. Got "${rawValue}".` + ) + return null + } + + return Math.round(intervalHours * 60 * 60 * 1000) +} + +const isThreadLikeChannel = ( + channel: unknown +): channel is GuildThreadChannel => + Boolean( + channel && + typeof channel === "object" && + "archive" in channel && + typeof channel.archive === "function" && + "lock" in channel && + typeof channel.lock === "function" + ) + +const getMessageCount = (thread: GuildThreadChannel) => + thread.totalMessageSent ?? thread.messageCount ?? 0 + +const sendThreadMessage = async ( + thread: GuildThreadChannel, + message: string +) => { + await thread.send({ + components: [new Container([new TextDisplay(message)])] + }) +} + +const syncClosedThread = async ( + trackedThread: TrackedThreadRecord, + lastMessageCount: number | null +) => { + await upsertTrackedThread({ + threadId: trackedThread.thread_id, + createdAt: trackedThread.created_at, + lastChecked: new Date().toISOString(), + solved: trackedThread.solved === 1, + warningLevel: trackedThread.warning_level, + closed: true, + lastMessageCount + }) +} + +const checkTrackedThread = async ( + client: Client, + trackedThread: TrackedThreadRecord +) => { + let channel: Awaited> + + try { + channel = await client.fetchChannel(trackedThread.thread_id) + } catch (error) { + console.error( + `Failed to fetch tracked thread ${trackedThread.thread_id} from Discord:`, + error + ) + await syncClosedThread(trackedThread, trackedThread.last_message_count) + return + } + + if (!isThreadLikeChannel(channel)) { + console.warn( + `Tracked thread ${trackedThread.thread_id} is missing or is no longer a Discord thread channel.` + ) + await syncClosedThread(trackedThread, trackedThread.last_message_count) + return + } + + const messageCount = getMessageCount(channel) + const threadIsClosed = Boolean(channel.archived || channel.locked) + + if (threadIsClosed) { + await syncClosedThread(trackedThread, messageCount) + return + } + + const checkedAt = new Date().toISOString() + let nextWarningLevel = trackedThread.warning_level + let nextClosed = trackedThread.closed === 1 + + if (messageCount > AUTO_CLOSE_THRESHOLD) { + try { + await sendThreadMessage(channel, threadLengthClose200Message) + } catch (error) { + console.error( + `Failed to send auto-close warning for thread ${trackedThread.thread_id}:`, + error + ) + } + + let archived = false + let locked = false + + try { + await channel.archive() + archived = true + } catch (error) { + console.error( + `Failed to archive thread ${trackedThread.thread_id} during auto-close:`, + error + ) + } + + try { + await channel.lock() + locked = true + } catch (error) { + console.error( + `Failed to lock thread ${trackedThread.thread_id} during auto-close:`, + error + ) + } + + nextClosed = archived || locked + nextWarningLevel = Math.max(nextWarningLevel, 2) + } else if ( + messageCount > SECOND_WARNING_THRESHOLD && + trackedThread.warning_level < 2 + ) { + try { + await sendThreadMessage(channel, threadLengthWarning150Message) + nextWarningLevel = 2 + } catch (error) { + console.error( + `Failed to send 150-message warning for thread ${trackedThread.thread_id}:`, + error + ) + } + } else if ( + messageCount > FIRST_WARNING_THRESHOLD && + trackedThread.warning_level < 1 + ) { + try { + await sendThreadMessage(channel, threadLengthWarning100Message) + nextWarningLevel = 1 + } catch (error) { + console.error( + `Failed to send 100-message warning for thread ${trackedThread.thread_id}:`, + error + ) + } + } + + await upsertTrackedThread({ + threadId: trackedThread.thread_id, + createdAt: trackedThread.created_at, + lastChecked: checkedAt, + solved: trackedThread.solved === 1, + warningLevel: nextWarningLevel, + closed: nextClosed, + lastMessageCount: messageCount + }) +} + +const runMonitorPass = async (client: Client) => { + const trackedThreads = await listTrackedThreads({ + solved: false, + closed: false, + limit: DEFAULT_FETCH_LIMIT + }) + + for (const trackedThread of trackedThreads) { + await checkTrackedThread(client, trackedThread) + } +} + +export const startThreadLengthMonitor = (client: Client) => { + if (monitorStarted) { + return + } + + monitorStarted = true + + const intervalMs = parseIntervalMs() + if (!intervalMs) { + console.log("Thread length monitor disabled.") + return + } + + if (!process.env.WORKER_EVENT_URL) { + console.log( + "Thread length monitor disabled because WORKER_EVENT_URL is not configured." + ) + return + } + + const run = async () => { + if (monitorRunInFlight) { + console.log("Skipping thread length monitor pass because the previous pass is still running.") + return + } + + monitorRunInFlight = true + try { + await runMonitorPass(client) + } catch (error) { + console.error("Thread length monitor pass failed:", error) + } finally { + monitorRunInFlight = false + } + } + + console.log(`Thread length monitor enabled with interval ${intervalMs}ms.`) + void run() + monitorInterval = setInterval(() => { + void run() + }, intervalMs) + + if (typeof monitorInterval.unref === "function") { + monitorInterval.unref() + } +} diff --git a/src/utils/trackedThreads.ts b/src/utils/trackedThreads.ts new file mode 100644 index 0000000..7bb2fbf --- /dev/null +++ b/src/utils/trackedThreads.ts @@ -0,0 +1,99 @@ +type TrackedThreadRecord = { + id: number + thread_id: string + created_at: string + last_checked: string | null + solved: number + warning_level: number + closed: number + last_message_count: number | null + received_at: string +} + +type TrackedThreadListResponse = { + count: number + threads?: TrackedThreadRecord[] +} + +type TrackedThreadUpsertPayload = { + threadId: string + createdAt?: string | null + lastChecked?: string | null + solved?: boolean + warningLevel?: number + closed?: boolean + lastMessageCount?: number | null +} + +const getWorkerApiUrl = (pathname: string) => { + const workerEventUrl = process.env.WORKER_EVENT_URL?.trim() + if (!workerEventUrl) { + return null + } + + return new URL(pathname, workerEventUrl) +} + +const getWorkerHeaders = () => ({ + "content-type": "application/json", + ...(process.env.WORKER_EVENT_SECRET + ? { "x-worker-event-secret": process.env.WORKER_EVENT_SECRET } + : {}) +}) + +export const listTrackedThreads = async ( + filters: { + solved?: boolean + closed?: boolean + limit?: number + } = {} +) => { + const url = getWorkerApiUrl("/api/threads") + if (!url) { + return [] + } + + if (filters.solved !== undefined) { + url.searchParams.set("solved", filters.solved ? "1" : "0") + } + + if (filters.closed !== undefined) { + url.searchParams.set("closed", filters.closed ? "1" : "0") + } + + if (filters.limit !== undefined) { + url.searchParams.set("limit", String(filters.limit)) + } + + const response = await fetch(url, { + headers: getWorkerHeaders() + }) + + if (!response.ok) { + throw new Error(`Worker tracked thread request failed with ${response.status}.`) + } + + const payload = (await response.json()) as TrackedThreadListResponse + return payload.threads ?? [] +} + +export const upsertTrackedThread = async ( + payload: TrackedThreadUpsertPayload +) => { + const url = getWorkerApiUrl("/api/threads") + if (!url) { + return + } + + const response = await fetch(url, { + method: "POST", + headers: getWorkerHeaders(), + body: JSON.stringify(payload) + }) + + if (!response.ok) { + throw new Error(`Worker tracked thread upsert failed with ${response.status}.`) + } +} + +export type { TrackedThreadRecord, TrackedThreadUpsertPayload } From c5d23c36cfdf9782f73fdfec42b971a9348dffe4 Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Wed, 11 Mar 2026 21:51:15 +0100 Subject: [PATCH 09/10] Migrate helper logs into Hermit --- README.md | 39 +- drizzle/0000_key_value.sql | 6 + drizzle/0001_init.sql | 17 + drizzle/0002_generic_worker_events.sql | 23 + drizzle/0003_tracked_thread_state.sql | 11 + drizzle/meta/_journal.json | 34 + package-lock.json | 1393 ------------------------ package.json | 9 +- src/data/helperLogs.ts | 305 ++++++ src/db/schema.ts | 59 +- src/index.ts | 9 +- src/scripts/migrate.ts | 8 + src/server/helperLogsServer.ts | 430 ++++++++ src/services/threadLengthMonitor.ts | 7 - src/utils/trackedThreads.ts | 83 +- src/utils/workerEvent.ts | 23 +- 16 files changed, 952 insertions(+), 1504 deletions(-) create mode 100644 drizzle/0000_key_value.sql create mode 100644 drizzle/0001_init.sql create mode 100644 drizzle/0002_generic_worker_events.sql create mode 100644 drizzle/0003_tracked_thread_state.sql create mode 100644 drizzle/meta/_journal.json delete mode 100644 package-lock.json create mode 100644 src/data/helperLogs.ts create mode 100644 src/scripts/migrate.ts create mode 100644 src/server/helperLogsServer.ts diff --git a/README.md b/README.md index 0f4258b..49ba72f 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ DISCORD_PUBLIC_KEY="discord-public-key" DISCORD_BOT_TOKEN="your-bot-token" ANSWER_OVERFLOW_API_KEY="your-answer-overflow-api-key" ANSWER_OVERFLOW_API_BASE_URL="https://www.answeroverflow.com" -WORKER_EVENT_URL="https://your-worker.example.workers.dev" -WORKER_EVENT_SECRET="your-shared-secret" HELPER_THREAD_WELCOME_PARENT_ID="123456789012345678" HELPER_THREAD_WELCOME_TEMPLATE="Welcome to helpers. Please include expected vs actual behavior, what you already tried, and relevant logs/code." THREAD_LENGTH_CHECK_INTERVAL_HOURS="2" +DB_PATH="data/hermit.sqlite" +HELPER_LOGS_HOST="127.0.0.1" +HELPER_LOGS_PORT="8787" ``` 2. Install dependencies: @@ -27,7 +28,12 @@ THREAD_LENGTH_CHECK_INTERVAL_HOURS="2" bun install ``` -3. Start the development server: +3. Apply database migrations: +```bash +bun run db:migrate +``` + +4. Start the development server: ```bash bun run dev ``` @@ -40,23 +46,21 @@ bun run dev - `/helper close` - Post a close notice and archive/lock the current thread - `/helper close-thread` - Post a close notice and archive/lock the current thread -If `WORKER_EVENT_SECRET` is set, Hermit sends it as the `x-worker-event-secret` header on worker event requests. - Hermit sends a welcome message for every newly created thread under the configured helper parent channel (`HELPER_THREAD_WELCOME_PARENT_ID`). -Hermit also registers those welcome threads in the worker's tracked-thread API before posting the welcome message. Registration failures are logged but do not block the Discord welcome message. +Hermit also registers those welcome threads directly in SQLite before posting the welcome message. Registration failures are logged but do not block the Discord welcome message. ## Thread Length Monitoring -Hermit owns the thread-length policy. The worker only stores tracked-thread state. +Hermit now owns both the thread-length policy and the SQLite persistence layer. When `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is set to a positive number, Hermit starts a background polling loop on startup that: -- requests active tracked threads from the worker +- requests active tracked threads from SQLite - fetches each Discord thread directly - checks whether the thread is already archived or locked - sends warning messages or auto-closes the thread based on live Discord message counts -- writes the latest thread state back to the worker +- writes the latest thread state back to SQLite Thresholds: @@ -68,7 +72,7 @@ Messages are stored in git-tracked files: - `src/config/threadLengthMessages.ts` -Hermit tracks the following worker fields for each thread: +Hermit tracks the following persisted fields for each thread: - `threadId` - `createdAt` @@ -81,8 +85,19 @@ Hermit tracks the following worker fields for each thread: Notes: - If `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is unset, the poller stays disabled. -- Threads that are already archived or locked are marked as closed in worker state and skipped on future passes. -- The worker URL is derived from `WORKER_EVENT_URL`, so the same base worker configuration is used for event logging and tracked-thread reads/writes. +- Threads that are already archived or locked are marked as closed in SQLite state and skipped on future passes. + +## Helper Logs API + +Hermit starts a local Bun HTTP server for the former `helper-logs` functionality. By default it listens on `http://127.0.0.1:8787`. + +Available routes: + +- `GET /` dashboard UI +- `GET /api/events` browse normalized event rows +- `GET /api/threads` browse tracked-thread rows + +Set `HELPER_LOGS_PORT=0` to disable the local helper logs server entirely. ## Gateway Events diff --git a/drizzle/0000_key_value.sql b/drizzle/0000_key_value.sql new file mode 100644 index 0000000..fcc303f --- /dev/null +++ b/drizzle/0000_key_value.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS keyValue ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL +); diff --git a/drizzle/0001_init.sql b/drizzle/0001_init.sql new file mode 100644 index 0000000..9cd725d --- /dev/null +++ b/drizzle/0001_init.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS helper_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT, + message_count INTEGER, + event_time TEXT NOT NULL, + command TEXT NOT NULL, + invoked_by_id TEXT, + invoked_by_username TEXT, + invoked_by_global_name TEXT, + received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + raw_payload TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_helper_events_event_time ON helper_events(event_time DESC); +CREATE INDEX IF NOT EXISTS idx_helper_events_command ON helper_events(command); +CREATE INDEX IF NOT EXISTS idx_helper_events_thread_id ON helper_events(thread_id); +CREATE INDEX IF NOT EXISTS idx_helper_events_invoked_by_id ON helper_events(invoked_by_id); diff --git a/drizzle/0002_generic_worker_events.sql b/drizzle/0002_generic_worker_events.sql new file mode 100644 index 0000000..644f604 --- /dev/null +++ b/drizzle/0002_generic_worker_events.sql @@ -0,0 +1,23 @@ +ALTER TABLE helper_events +ADD COLUMN event_type TEXT NOT NULL DEFAULT 'helper_command'; + +UPDATE helper_events +SET event_type = 'helper_command' +WHERE event_type IS NULL OR TRIM(event_type) = ''; + +CREATE INDEX IF NOT EXISTS idx_helper_events_event_type ON helper_events(event_type); +CREATE INDEX IF NOT EXISTS idx_helper_events_thread_time ON helper_events(thread_id, event_time DESC); + +CREATE TABLE IF NOT EXISTS tracked_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_checked TEXT, + solved INTEGER NOT NULL DEFAULT 0, + received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + raw_payload TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tracked_threads_solved ON tracked_threads(solved); +CREATE INDEX IF NOT EXISTS idx_tracked_threads_last_checked ON tracked_threads(last_checked DESC); +CREATE INDEX IF NOT EXISTS idx_tracked_threads_received_at ON tracked_threads(received_at DESC); diff --git a/drizzle/0003_tracked_thread_state.sql b/drizzle/0003_tracked_thread_state.sql new file mode 100644 index 0000000..8ec8885 --- /dev/null +++ b/drizzle/0003_tracked_thread_state.sql @@ -0,0 +1,11 @@ +ALTER TABLE tracked_threads +ADD COLUMN warning_level INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE tracked_threads +ADD COLUMN closed INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE tracked_threads +ADD COLUMN last_message_count INTEGER; + +CREATE INDEX IF NOT EXISTS idx_tracked_threads_closed ON tracked_threads(closed); +CREATE INDEX IF NOT EXISTS idx_tracked_threads_warning_level ON tracked_threads(warning_level); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..a661087 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,34 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1762300800000, + "tag": "0000_key_value", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1762300800001, + "tag": "0001_init", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1762300800002, + "tag": "0002_generic_worker_events", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1762300800003, + "tag": "0003_tracked_thread_state", + "breakpoints": true + } + ] +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index ac681a1..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1393 +0,0 @@ -{ - "name": "hermit", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "hermit", - "version": "1.0.0", - "dependencies": { - "@buape/carbon": "^0.0.0-beta-20260216184201", - "drizzle-orm": "^0.45.1" - }, - "devDependencies": { - "@types/bun": "1.3.6", - "drizzle-kit": "^0.31.8", - "typescript": "5.9.3" - } - }, - "node_modules/@buape/carbon": { - "version": "0.0.0-beta-20260303183026", - "resolved": "https://registry.npmjs.org/@buape/carbon/-/carbon-0.0.0-beta-20260303183026.tgz", - "integrity": "sha512-rYL8rkIvw6JFQns3ykR2v87cPhMgu3XS79n/+QDv0A3a7yPlwvy6YJ/Ip7duMjNTXhxmbDVbJNpq/QvsiBrZmg==", - "license": "MIT", - "dependencies": { - "@types/node": "^25.0.9", - "discord-api-types": "0.38.37" - }, - "optionalDependencies": { - "@cloudflare/workers-types": "4.20260120.0", - "@discordjs/voice": "0.19.0", - "@hono/node-server": "1.19.9", - "@types/bun": "1.3.9", - "@types/ws": "8.18.1", - "ws": "8.19.0" - } - }, - "node_modules/@buape/carbon/node_modules/@types/bun": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", - "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", - "license": "MIT", - "optional": true, - "dependencies": { - "bun-types": "1.3.9" - } - }, - "node_modules/@buape/carbon/node_modules/bun-types": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", - "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260120.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260120.0.tgz", - "integrity": "sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==", - "license": "MIT OR Apache-2.0", - "optional": true - }, - "node_modules/@discordjs/voice": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.0.tgz", - "integrity": "sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@types/ws": "^8.18.1", - "discord-api-types": "^0.38.16", - "prism-media": "^1.3.5", - "tslib": "^2.8.1", - "ws": "^8.18.3" - }, - "engines": { - "node": ">=22.12.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@types/bun": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.6.tgz", - "integrity": "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bun-types": "1.3.6" - } - }, - "node_modules/@types/node": { - "version": "25.3.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", - "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bun-types": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.6.tgz", - "integrity": "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/discord-api-types": { - "version": "0.38.37", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", - "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/drizzle-kit": { - "version": "0.31.9", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", - "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.4", - "esbuild-register": "^3.5.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, - "node_modules/drizzle-orm": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", - "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/hono": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", - "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/prism-media": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", - "integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==", - "license": "Apache-2.0", - "optional": true, - "peerDependencies": { - "@discordjs/opus": ">=0.8.0 <1.0.0", - "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", - "node-opus": "^0.3.3", - "opusscript": "^0.0.8" - }, - "peerDependenciesMeta": { - "@discordjs/opus": { - "optional": true - }, - "ffmpeg-static": { - "optional": true - }, - "node-opus": { - "optional": true - }, - "opusscript": { - "optional": true - } - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json index 6c8f090..3b8ac3a 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,11 @@ "type": "module", "main": "./src/index.ts", "scripts": { - "dev": "bun run . --watch", - "start": "bun run ." + "dev": "bun --watch run src/index.ts", + "start": "bun run src/index.ts", + "typecheck": "tsc --noEmit", + "db:migrate": "bun run src/scripts/migrate.ts", + "db:generate": "drizzle-kit generate" }, "dependencies": { "@buape/carbon": "^0.0.0-beta-20260216184201", @@ -17,4 +20,4 @@ "drizzle-kit": "^0.31.8", "typescript": "5.9.3" } -} \ No newline at end of file +} diff --git a/src/data/helperLogs.ts b/src/data/helperLogs.ts new file mode 100644 index 0000000..ea4b779 --- /dev/null +++ b/src/data/helperLogs.ts @@ -0,0 +1,305 @@ +import { and, desc, eq, gte, lte, sql } from "drizzle-orm" +import { db } from "../db.js" +import { helperEvents, trackedThreads } from "../db/schema.js" + +type GenericWorkerEventPayload = { + type?: string | null + time?: string | null + invokedBy?: { + id?: string | null + username?: string | null + globalName?: string | null + } | null + context?: { + guildId?: string | null + channelId?: string | null + threadId?: string | null + messageCount?: number | null + parentId?: string | null + } | null + data?: { + command?: string | null + } | null +} + +type NormalizedEvent = { + eventType: string + threadId: string | null + messageCount: number | null + eventTime: string + command: string + invokedById: string | null + invokedByUsername: string | null + invokedByGlobalName: string | null + rawPayload: string +} + +type ThreadUpsertPayload = { + threadId?: string | null + createdAt?: string | null + lastChecked?: string | null + solved?: boolean | number | string | null + warningLevel?: number | null + closed?: boolean | number | string | null + lastMessageCount?: number | null +} + +type EventFilters = { + eventType?: string | null + command?: string | null + threadId?: string | null + invokedBy?: string | null + from?: string | null + to?: string | null + limit?: number +} + +type ThreadFilters = { + threadId?: string | null + solved?: boolean + closed?: boolean + limit?: number +} + +const asStringOrNull = (value: unknown): string | null => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null + +const asNumberOrNull = (value: unknown): number | null => { + if (typeof value !== "number" || Number.isNaN(value)) { + return null + } + + return Math.trunc(value) +} + +const toIsoOrNow = (value: unknown): string => { + if (typeof value !== "string") { + return new Date().toISOString() + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return new Date().toISOString() + } + + return date.toISOString() +} + +const parseIsoOrNull = (value: unknown): string | null => { + if (typeof value !== "string") { + return null + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return null + } + + return date.toISOString() +} + +const parseBooleanLike = (value: unknown): number => { + if (typeof value === "boolean") { + return value ? 1 : 0 + } + + if (typeof value === "number") { + return value === 1 ? 1 : 0 + } + + if (typeof value === "string") { + const normalized = value.trim().toLowerCase() + if (normalized === "true" || normalized === "1") { + return 1 + } + } + + return 0 +} + +const parseNonNegativeInt = (value: unknown, fallback: number): number => { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallback + } + + return Math.max(0, Math.trunc(value)) +} + +export const normalizeEventPayload = ( + payload: unknown +): NormalizedEvent | null => { + if (!payload || typeof payload !== "object") { + return null + } + + const rawPayload = JSON.stringify(payload) + const record = payload as GenericWorkerEventPayload & Record + const invokedBy = record.invokedBy as Record | null | undefined + + const invokedById = asStringOrNull(invokedBy?.id) + const invokedByUsername = asStringOrNull(invokedBy?.username) + const invokedByGlobalName = asStringOrNull(invokedBy?.globalName) + const eventType = asStringOrNull(record.type) + if (!eventType) { + return null + } + + const context = record.context as Record | null | undefined + const data = record.data as Record | null | undefined + + return { + eventType, + threadId: asStringOrNull(context?.threadId), + messageCount: asNumberOrNull(context?.messageCount), + eventTime: toIsoOrNow(record.time), + command: asStringOrNull(data?.command) ?? eventType, + invokedById, + invokedByUsername, + invokedByGlobalName, + rawPayload + } +} + +export const insertEvent = async (normalizedEvent: NormalizedEvent) => { + await db.insert(helperEvents).values(normalizedEvent) +} + +export const listEvents = async ({ + eventType, + command, + threadId, + invokedBy, + from, + to, + limit = 100 +}: EventFilters = {}) => { + const filters = [] + + if (eventType) { + filters.push(eq(helperEvents.eventType, eventType)) + } + + if (command) { + filters.push(eq(helperEvents.command, command)) + } + + if (threadId) { + filters.push(eq(helperEvents.threadId, threadId)) + } + + if (invokedBy) { + filters.push(eq(helperEvents.invokedById, invokedBy)) + } + + const fromIso = parseIsoOrNull(from) + if (fromIso) { + filters.push(gte(helperEvents.eventTime, fromIso)) + } + + const toIso = parseIsoOrNull(to) + if (toIso) { + filters.push(lte(helperEvents.eventTime, toIso)) + } + + return db + .select({ + id: helperEvents.id, + event_type: helperEvents.eventType, + thread_id: helperEvents.threadId, + message_count: helperEvents.messageCount, + event_time: helperEvents.eventTime, + command: helperEvents.command, + invoked_by_id: helperEvents.invokedById, + invoked_by_username: helperEvents.invokedByUsername, + invoked_by_global_name: helperEvents.invokedByGlobalName, + received_at: helperEvents.receivedAt + }) + .from(helperEvents) + .where(filters.length > 0 ? and(...filters) : undefined) + .orderBy(desc(helperEvents.eventTime)) + .limit(Math.min(limit, 500)) +} + +export const upsertTrackedThread = async (payload: ThreadUpsertPayload) => { + const threadId = asStringOrNull(payload.threadId) + if (!threadId) { + return { error: "threadId is required", status: 400 as const } + } + + const createdAt = toIsoOrNow(payload.createdAt) + const lastChecked = parseIsoOrNull(payload.lastChecked) + const solved = parseBooleanLike(payload.solved) + const warningLevel = parseNonNegativeInt(payload.warningLevel, 0) + const closed = parseBooleanLike(payload.closed) + const lastMessageCount = asNumberOrNull(payload.lastMessageCount) + const rawPayload = JSON.stringify(payload) + + await db + .insert(trackedThreads) + .values({ + threadId, + createdAt, + lastChecked, + solved, + warningLevel, + closed, + lastMessageCount, + receivedAt: sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, + rawPayload + }) + .onConflictDoUpdate({ + target: trackedThreads.threadId, + set: { + createdAt, + lastChecked, + solved, + warningLevel, + closed, + lastMessageCount, + receivedAt: sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, + rawPayload + } + }) + + return { ok: true as const } +} + +export const listTrackedThreads = async ({ + threadId, + solved, + closed, + limit = 100 +}: ThreadFilters = {}) => { + const filters = [] + + if (threadId) { + filters.push(eq(trackedThreads.threadId, threadId)) + } + + if (solved !== undefined) { + filters.push(eq(trackedThreads.solved, solved ? 1 : 0)) + } + + if (closed !== undefined) { + filters.push(eq(trackedThreads.closed, closed ? 1 : 0)) + } + + return db + .select({ + id: trackedThreads.id, + thread_id: trackedThreads.threadId, + created_at: trackedThreads.createdAt, + last_checked: trackedThreads.lastChecked, + solved: trackedThreads.solved, + warning_level: trackedThreads.warningLevel, + closed: trackedThreads.closed, + last_message_count: trackedThreads.lastMessageCount, + received_at: trackedThreads.receivedAt + }) + .from(trackedThreads) + .where(filters.length > 0 ? and(...filters) : undefined) + .orderBy(sql`coalesce(${trackedThreads.lastChecked}, ${trackedThreads.createdAt}) asc`) + .limit(Math.min(limit, 500)) +} + +export type { ThreadUpsertPayload } diff --git a/src/db/schema.ts b/src/db/schema.ts index 7d70d75..fe38aea 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,5 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { sql } from "drizzle-orm" +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core" export const keyValue = sqliteTable("keyValue", { key: text().primaryKey(), @@ -12,5 +13,61 @@ export const keyValue = sqliteTable("keyValue", { .$onUpdateFn(() => new Date()) }) +export const helperEvents = sqliteTable( + "helper_events", + { + id: integer().primaryKey({ autoIncrement: true }), + eventType: text("event_type").notNull().default("helper_command"), + threadId: text("thread_id"), + messageCount: integer("message_count"), + eventTime: text("event_time").notNull(), + command: text().notNull(), + invokedById: text("invoked_by_id"), + invokedByUsername: text("invoked_by_username"), + invokedByGlobalName: text("invoked_by_global_name"), + receivedAt: text("received_at") + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + rawPayload: text("raw_payload").notNull() + }, + (table) => [ + index("idx_helper_events_event_time").on(table.eventTime), + index("idx_helper_events_command").on(table.command), + index("idx_helper_events_thread_id").on(table.threadId), + index("idx_helper_events_invoked_by_id").on(table.invokedById), + index("idx_helper_events_event_type").on(table.eventType), + index("idx_helper_events_thread_time").on(table.threadId, table.eventTime) + ] +) + +export const trackedThreads = sqliteTable( + "tracked_threads", + { + id: integer().primaryKey({ autoIncrement: true }), + threadId: text("thread_id").notNull().unique(), + createdAt: text("created_at").notNull(), + lastChecked: text("last_checked"), + solved: integer().notNull().default(0), + warningLevel: integer("warning_level").notNull().default(0), + closed: integer().notNull().default(0), + lastMessageCount: integer("last_message_count"), + receivedAt: text("received_at") + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + rawPayload: text("raw_payload").notNull() + }, + (table) => [ + index("idx_tracked_threads_solved").on(table.solved), + index("idx_tracked_threads_last_checked").on(table.lastChecked), + index("idx_tracked_threads_received_at").on(table.receivedAt), + index("idx_tracked_threads_closed").on(table.closed), + index("idx_tracked_threads_warning_level").on(table.warningLevel) + ] +) + export type KeyValue = typeof keyValue.$inferSelect export type NewKeyValue = typeof keyValue.$inferInsert +export type HelperEvent = typeof helperEvents.$inferSelect +export type NewHelperEvent = typeof helperEvents.$inferInsert +export type TrackedThread = typeof trackedThreads.$inferSelect +export type NewTrackedThread = typeof trackedThreads.$inferInsert diff --git a/src/index.ts b/src/index.ts index b56db91..c26ed2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ import AutoModerationActionExecution from "./events/autoModerationActionExecutio import AutoPublishMessageCreate from "./events/autoPublishMessageCreate.js" import Ready from "./events/ready.js" import ThreadCreateWelcome from "./events/threadCreateWelcome.js" +import { startHelperLogsServer } from "./server/helperLogsServer.js" + +startHelperLogsServer() const gateway = new GatewayPlugin({ intents: @@ -60,11 +63,13 @@ declare global { DISCORD_BOT_TOKEN: string; ANSWER_OVERFLOW_API_KEY?: string; ANSWER_OVERFLOW_API_BASE_URL?: string; - WORKER_EVENT_URL?: string; - WORKER_EVENT_SECRET?: string; HELPER_THREAD_WELCOME_PARENT_ID?: string; HELPER_THREAD_WELCOME_TEMPLATE?: string; THREAD_LENGTH_CHECK_INTERVAL_HOURS?: string; + HELPER_LOGS_HOST?: string; + HELPER_LOGS_PORT?: string; + DB_PATH?: string; + DRIZZLE_MIGRATIONS?: string; } } } diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts new file mode 100644 index 0000000..43957b6 --- /dev/null +++ b/src/scripts/migrate.ts @@ -0,0 +1,8 @@ +import { migrate } from "drizzle-orm/bun-sqlite/migrator" +import { db } from "../db.js" + +const migrationsFolder = Bun.env.DRIZZLE_MIGRATIONS ?? "drizzle" + +migrate(db, { migrationsFolder }) + +console.log(`Applied migrations from ${migrationsFolder}.`) diff --git a/src/server/helperLogsServer.ts b/src/server/helperLogsServer.ts new file mode 100644 index 0000000..04e1460 --- /dev/null +++ b/src/server/helperLogsServer.ts @@ -0,0 +1,430 @@ +import { + listEvents, + listTrackedThreads, +} from "../data/helperLogs.js" + +let serverStarted = false + +const asStringOrNull = (value: unknown): string | null => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null + +const json = (data: unknown, init?: ResponseInit) => + new Response(JSON.stringify(data), { + ...init, + headers: { + "content-type": "application/json; charset=utf-8", + ...init?.headers + } + }) + +const renderHtml = () => ` + + + + + Worker Events + + + +
+
+

Worker Events

+

Generic worker events captured from Hermit.

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
Loading...
+
+ + + + + + + + + + + + + + + + +
IDEvent TypeEvent TimeReceivedCommandThread IDMessage CountInvoker IDInvoker UsernameInvoker Global Name
+
+
+
+ + + +` + +const parsePort = () => { + const rawPort = process.env.HELPER_LOGS_PORT?.trim() + if (!rawPort) { + return 8787 + } + + const port = Number.parseInt(rawPort, 10) + if (!Number.isInteger(port) || port < 0) { + console.warn(`Invalid HELPER_LOGS_PORT "${rawPort}". Falling back to 8787.`) + return 8787 + } + + return port +} + +export const startHelperLogsServer = () => { + if (serverStarted) { + return + } + + const port = parsePort() + if (port === 0) { + console.log("Helper logs server disabled.") + return + } + + const hostname = process.env.HELPER_LOGS_HOST?.trim() || "127.0.0.1" + + Bun.serve({ + hostname, + port, + routes: { + "/": { + GET: () => + new Response(renderHtml(), { + headers: { + "content-type": "text/html; charset=utf-8" + } + }) + }, + "/api/events": { + GET: async (request) => { + const url = new URL(request.url) + const events = await listEvents({ + eventType: asStringOrNull(url.searchParams.get("eventType")), + command: asStringOrNull(url.searchParams.get("command")), + threadId: asStringOrNull(url.searchParams.get("threadId")), + invokedBy: asStringOrNull(url.searchParams.get("invokedBy")), + from: asStringOrNull(url.searchParams.get("from")), + to: asStringOrNull(url.searchParams.get("to")), + limit: + Number.parseInt(url.searchParams.get("limit") ?? "100", 10) || 100 + }) + + return json({ count: events.length, events }) + } + }, + "/api/threads": { + GET: async (request) => { + const url = new URL(request.url) + const threads = await listTrackedThreads({ + threadId: asStringOrNull(url.searchParams.get("threadId")), + solved: + url.searchParams.get("solved") === null + ? undefined + : url.searchParams.get("solved") === "1" || + url.searchParams.get("solved")?.toLowerCase() === "true", + closed: + url.searchParams.get("closed") === null + ? undefined + : url.searchParams.get("closed") === "1" || + url.searchParams.get("closed")?.toLowerCase() === "true", + limit: + Number.parseInt(url.searchParams.get("limit") ?? "100", 10) || 100 + }) + + return json({ count: threads.length, threads }) + } + } + }, + fetch: () => json({ error: "Not found" }, { status: 404 }), + error: (error) => { + console.error("Helper logs server error:", error) + return json({ error: "Internal Server Error" }, { status: 500 }) + } + }) + + serverStarted = true + console.log(`Helper logs server listening on http://${hostname}:${port}`) +} diff --git a/src/services/threadLengthMonitor.ts b/src/services/threadLengthMonitor.ts index 2bed5ca..c1bf2c4 100644 --- a/src/services/threadLengthMonitor.ts +++ b/src/services/threadLengthMonitor.ts @@ -216,13 +216,6 @@ export const startThreadLengthMonitor = (client: Client) => { return } - if (!process.env.WORKER_EVENT_URL) { - console.log( - "Thread length monitor disabled because WORKER_EVENT_URL is not configured." - ) - return - } - const run = async () => { if (monitorRunInFlight) { console.log("Skipping thread length monitor pass because the previous pass is still running.") diff --git a/src/utils/trackedThreads.ts b/src/utils/trackedThreads.ts index 7bb2fbf..08aa839 100644 --- a/src/utils/trackedThreads.ts +++ b/src/utils/trackedThreads.ts @@ -1,19 +1,12 @@ -type TrackedThreadRecord = { - id: number - thread_id: string - created_at: string - last_checked: string | null - solved: number - warning_level: number - closed: number - last_message_count: number | null - received_at: string -} +import { + listTrackedThreads as listTrackedThreadsFromDb, + type ThreadUpsertPayload, + upsertTrackedThread as upsertTrackedThreadInDb +} from "../data/helperLogs.js" -type TrackedThreadListResponse = { - count: number - threads?: TrackedThreadRecord[] -} +type TrackedThreadRecord = Awaited< + ReturnType +>[number] type TrackedThreadUpsertPayload = { threadId: string @@ -25,22 +18,6 @@ type TrackedThreadUpsertPayload = { lastMessageCount?: number | null } -const getWorkerApiUrl = (pathname: string) => { - const workerEventUrl = process.env.WORKER_EVENT_URL?.trim() - if (!workerEventUrl) { - return null - } - - return new URL(pathname, workerEventUrl) -} - -const getWorkerHeaders = () => ({ - "content-type": "application/json", - ...(process.env.WORKER_EVENT_SECRET - ? { "x-worker-event-secret": process.env.WORKER_EVENT_SECRET } - : {}) -}) - export const listTrackedThreads = async ( filters: { solved?: boolean @@ -48,51 +25,15 @@ export const listTrackedThreads = async ( limit?: number } = {} ) => { - const url = getWorkerApiUrl("/api/threads") - if (!url) { - return [] - } - - if (filters.solved !== undefined) { - url.searchParams.set("solved", filters.solved ? "1" : "0") - } - - if (filters.closed !== undefined) { - url.searchParams.set("closed", filters.closed ? "1" : "0") - } - - if (filters.limit !== undefined) { - url.searchParams.set("limit", String(filters.limit)) - } - - const response = await fetch(url, { - headers: getWorkerHeaders() - }) - - if (!response.ok) { - throw new Error(`Worker tracked thread request failed with ${response.status}.`) - } - - const payload = (await response.json()) as TrackedThreadListResponse - return payload.threads ?? [] + return listTrackedThreadsFromDb(filters) } export const upsertTrackedThread = async ( payload: TrackedThreadUpsertPayload ) => { - const url = getWorkerApiUrl("/api/threads") - if (!url) { - return - } - - const response = await fetch(url, { - method: "POST", - headers: getWorkerHeaders(), - body: JSON.stringify(payload) - }) - - if (!response.ok) { - throw new Error(`Worker tracked thread upsert failed with ${response.status}.`) + const result = await upsertTrackedThreadInDb(payload as ThreadUpsertPayload) + if ("error" in result) { + throw new Error(result.error) } } diff --git a/src/utils/workerEvent.ts b/src/utils/workerEvent.ts index adb1444..3b8247b 100644 --- a/src/utils/workerEvent.ts +++ b/src/utils/workerEvent.ts @@ -1,4 +1,5 @@ import type { CommandInteraction } from "@buape/carbon" +import { insertEvent, normalizeEventPayload } from "../data/helperLogs.js" type ThreadStatsChannel = { id?: string @@ -41,12 +42,6 @@ export const postWorkerEvent = async ({ context, data }: SendWorkerEventInput) => { - const workerUrl = process.env.WORKER_EVENT_URL - const workerSecret = process.env.WORKER_EVENT_SECRET - if (!workerUrl) { - return - } - const payload: WorkerEventPayload = { type, time: new Date().toISOString(), @@ -56,16 +51,14 @@ export const postWorkerEvent = async ({ } try { - await fetch(workerUrl, { - method: "POST", - headers: { - "content-type": "application/json", - ...(workerSecret ? { "x-worker-event-secret": workerSecret } : {}) - }, - body: JSON.stringify(payload) - }) + const normalizedEvent = normalizeEventPayload(payload) + if (!normalizedEvent) { + return + } + + await insertEvent(normalizedEvent) } catch { - // Ignore worker event failures so primary flows can continue. + // Ignore event persistence failures so primary flows can continue. } } From 0561315d6656a21f83d4bace81ffe73c8015608c Mon Sep 17 00:00:00 2001 From: Julian Engel Date: Wed, 11 Mar 2026 21:59:24 +0100 Subject: [PATCH 10/10] Improve Hermit README --- README.md | 412 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 357 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 49ba72f..19bf775 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,414 @@ -# hermit +# Hermit -A Discord bot built with [Carbon](https://carbon.buape.com). +Hermit is the OpenClaw Discord bot built on [Carbon](https://carbon.buape.com), Bun, and SQLite. -Repository: https://github.com/openclaw/hermit +It handles: -## Setup +- Discord slash commands and message-context moderation actions +- helper-thread onboarding and thread-length enforcement +- keyword-based automod responses +- announcement crossposting for selected channels +- local event and helper-thread state persistence in SQLite +- a read-only local dashboard for helper events and tracked threads + +Repository: [openclaw/hermit](https://github.com/openclaw/hermit) + +## Runtime Overview + +Hermit runs as a gateway-first Discord bot: + +- Bun is the runtime and package manager +- Carbon handles command registration, gateway events, and Discord API access +- Drizzle manages the SQLite schema and migrations +- SQLite stores helper event history and tracked helper thread state +- A small Bun HTTP server exposes read-only operational visibility + +Main entrypoint: [src/index.ts](src/index.ts) + +## Features + +- `/github` looks up GitHub issues and pull requests +- `Solved (Mod)` marks a thread as solved in Answer Overflow and closes it +- `/say ...` posts common guidance and documentation links +- `/helper ...` posts helper-thread moderation messages and closes threads +- `/role ...` toggles specific server roles +- helper-thread creation triggers a welcome message and thread tracking +- a background monitor warns on long threads and auto-closes very long ones +- automod rules can repost/redact matching messages and send guidance replies +- selected announcement channels are auto-crossposted + +## Requirements + +- Bun +- a Discord application and bot token +- access to the target Discord server +- SQLite filesystem access for `DB_PATH` + +## Installation + +1. Install dependencies: + +```bash +bun install +``` + +2. Create a `.env` file. + +Recommended variables: -1. Create a `.env` file with the following variables: ```env -BASE_URL="your-base-url" -DEPLOY_SECRET="your-deploy-secret" DISCORD_CLIENT_ID="your-client-id" -DISCORD_PUBLIC_KEY="discord-public-key" DISCORD_BOT_TOKEN="your-bot-token" +DISCORD_DEV_GUILDS="guild_id_1,guild_id_2" + ANSWER_OVERFLOW_API_KEY="your-answer-overflow-api-key" ANSWER_OVERFLOW_API_BASE_URL="https://www.answeroverflow.com" + HELPER_THREAD_WELCOME_PARENT_ID="123456789012345678" HELPER_THREAD_WELCOME_TEMPLATE="Welcome to helpers. Please include expected vs actual behavior, what you already tried, and relevant logs/code." THREAD_LENGTH_CHECK_INTERVAL_HOURS="2" + DB_PATH="data/hermit.sqlite" +DRIZZLE_MIGRATIONS="drizzle" + HELPER_LOGS_HOST="127.0.0.1" HELPER_LOGS_PORT="8787" ``` -2. Install dependencies: -```bash -bun install -``` +3. Apply migrations: -3. Apply database migrations: ```bash bun run db:migrate ``` -4. Start the development server: +4. Start Hermit: + ```bash bun run dev ``` +## Scripts + +- `bun run dev` starts Hermit in watch mode +- `bun run start` starts Hermit normally +- `bun run typecheck` runs TypeScript without emitting files +- `bun run db:migrate` applies Drizzle migrations to SQLite +- `bun run db:generate` generates Drizzle migration files from the schema + +## Environment Variables + +### Required + +- `DISCORD_CLIENT_ID`: Discord application client ID +- `DISCORD_BOT_TOKEN`: Discord bot token + +### Optional + +- `DISCORD_DEV_GUILDS`: comma-separated guild IDs for dev command registration +- `ANSWER_OVERFLOW_API_KEY`: required for `Solved (Mod)` to call Answer Overflow +- `ANSWER_OVERFLOW_API_BASE_URL`: defaults to `https://www.answeroverflow.com` +- `HELPER_THREAD_WELCOME_PARENT_ID`: parent forum or helper channel whose new threads should receive the welcome message +- `HELPER_THREAD_WELCOME_TEMPLATE`: overrides the default helper welcome text +- `THREAD_LENGTH_CHECK_INTERVAL_HOURS`: enables the helper thread monitor when set to a positive number +- `DB_PATH`: SQLite database path, defaults to `data/hermit.sqlite` +- `DRIZZLE_MIGRATIONS`: migration directory, defaults to `drizzle` +- `HELPER_LOGS_HOST`: host for the read-only helper dashboard, defaults to `127.0.0.1` +- `HELPER_LOGS_PORT`: port for the read-only helper dashboard, defaults to `8787`; set to `0` to disable it +- `SKIP_DB_MIGRATIONS`: set to `1` to skip automatic migration-on-startup + ## Commands -- `/github` - Look up an issue or PR (defaults to openclaw/hermit) -- `Solved (Mod)` - Moderator-only message context menu item that marks the current thread as solved in Answer Overflow -- `/helper warn-new-thread` - Post a helper-channel warning for long threads -- `/helper close` - Post a close notice and archive/lock the current thread -- `/helper close-thread` - Post a close notice and archive/lock the current thread +### `/github` -Hermit sends a welcome message for every newly created thread under the configured helper parent channel (`HELPER_THREAD_WELCOME_PARENT_ID`). +Looks up a GitHub issue or pull request and returns: -Hermit also registers those welcome threads directly in SQLite before posting the welcome message. Registration failures are logged but do not block the Discord welcome message. +- title and state +- repo and author +- labels +- description summary +- recent comments +- pull request change stats when applicable -## Thread Length Monitoring +Options: -Hermit now owns both the thread-length policy and the SQLite persistence layer. +- `number` required +- `user` optional, defaults to `openclaw` +- `repo` optional, defaults to `hermit` -When `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is set to a positive number, Hermit starts a background polling loop on startup that: +Available in: -- requests active tracked threads from SQLite -- fetches each Discord thread directly -- checks whether the thread is already archived or locked -- sends warning messages or auto-closes the thread based on live Discord message counts -- writes the latest thread state back to SQLite +- guilds +- bot DMs -Thresholds: +Source: [src/commands/github.ts](src/commands/github.ts) -- more than `100` messages: first warning -- more than `150` messages: second warning asking users to close solved threads and move new issues to a new thread -- more than `200` messages: automatic close notice, then archive + lock +### `Solved (Mod)` -Messages are stored in git-tracked files: +Message-context moderation action that: -- `src/config/threadLengthMessages.ts` +- posts the chosen solution message to Answer Overflow +- adds a checkmark reaction to the solved message +- archives and locks the thread +- records a `mark_solution` helper event in SQLite -Hermit tracks the following persisted fields for each thread: +Permissions: -- `threadId` +- `ManageMessages` +- `ManageThreads` + +Requires: + +- `ANSWER_OVERFLOW_API_KEY` + +Source: [src/commands/solvedMod.ts](src/commands/solvedMod.ts) + +### `/say` + +Posts common canned guidance messages. + +Subcommands: + +- `guide` +- `server-faq` +- `help` +- `user-help` +- `model` +- `stuck` +- `ci` +- `answeroverflow` +- `pinging` +- `docs` +- `security` +- `install` +- `blog-rename` + +Available in: + +- guilds +- bot DMs + +Source: [src/commands/say.ts](src/commands/say.ts) + +### `/helper` + +Helper-channel moderation utilities. + +Subcommands: + +- `warn-new-thread`: posts the long-thread warning message +- `close`: posts the close message, archives the thread, and locks it +- `close-thread`: same behavior as `close` + +These commands also emit helper events into SQLite. + +Source: [src/commands/helper.ts](src/commands/helper.ts) + +### `/role` + +Toggles specific hard-coded server roles. + +Current subcommands: + +- `showcase-ban` +- `clawtributor` + +Permissions: + +- command requires `ManageRoles` +- runtime access also checks that the invoking member has the hard-coded `communityStaff` role + +Source: [src/commands/role.ts](src/commands/role.ts) + +## Gateway Events And Background Behavior + +### Ready + +On startup, Hermit logs the connected username and starts the helper thread monitor when configured. + +Source: [src/events/ready.ts](src/events/ready.ts) + +### Thread Create Welcome + +When a new thread is created under `HELPER_THREAD_WELCOME_PARENT_ID`, Hermit: + +- stores the thread in `tracked_threads` +- records a `thread_welcome_created` event +- posts the helper welcome message + +Source: [src/events/threadCreateWelcome.ts](src/events/threadCreateWelcome.ts) + +### Thread Length Monitor + +When `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is set, Hermit polls tracked helper threads with `setInterval`. + +Behavior: + +- loads open tracked threads from SQLite +- fetches the Discord thread live +- updates message counts and close state +- warns at more than `100` messages +- warns again at more than `150` messages +- posts a close notice and archives/locks at more than `200` messages + +Configured messages live in: + +- [src/config/threadLengthMessages.ts](src/config/threadLengthMessages.ts) + +Source: [src/services/threadLengthMonitor.ts](src/services/threadLengthMonitor.ts) + +### AutoModeration Action Execution + +Hermit listens to automod keyword actions and can: + +- repost the triggering content through a webhook +- redact the matched trigger in the repost +- send a follow-up warning/guidance message +- optionally include a role mention in the guidance message + +Automod rule configuration lives in: + +- [src/config/automod-messages.json](src/config/automod-messages.json) + +Message template placeholders: + +- `{user}` +- `{keyword}` +- `{content}` + +Source: [src/events/autoModerationActionExecution.ts](src/events/autoModerationActionExecution.ts) + +### Auto Publish Message Create + +Hermit auto-crossposts messages from a fixed set of announcement channel IDs. + +Source: [src/events/autoPublishMessageCreate.ts](src/events/autoPublishMessageCreate.ts) + +## Database + +Hermit uses SQLite via Bun and Drizzle. + +Database bootstrap: [src/db.ts](src/db.ts) + +Schema definition: [src/db/schema.ts](src/db/schema.ts) + +### Tables + +#### `keyValue` + +Generic key/value storage with: + +- `key` +- `value` - `createdAt` -- `lastChecked` +- `updatedAt` + +#### `helper_events` + +Operational event log for helper-related actions. + +Fields: + +- `id` +- `event_type` +- `thread_id` +- `message_count` +- `event_time` +- `command` +- `invoked_by_id` +- `invoked_by_username` +- `invoked_by_global_name` +- `received_at` +- `raw_payload` + +Typical event types: + +- `mark_solution` +- `helper_command` +- `thread_welcome_created` + +#### `tracked_threads` + +Persistent helper-thread state used by the monitor. + +Fields: + +- `id` +- `thread_id` +- `created_at` +- `last_checked` - `solved` -- `warningLevel` +- `warning_level` - `closed` -- `lastMessageCount` +- `last_message_count` +- `received_at` +- `raw_payload` + +## Migrations + +Drizzle configuration: [drizzle.config.ts](drizzle.config.ts) + +Migration runner: [src/scripts/migrate.ts](src/scripts/migrate.ts) + +Committed SQL migrations live under: + +- [drizzle/](drizzle) + +On startup, Hermit automatically applies migrations unless `SKIP_DB_MIGRATIONS=1`. -Notes: +## Read-Only Helper Logs HTTP Server -- If `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is unset, the poller stays disabled. -- Threads that are already archived or locked are marked as closed in SQLite state and skipped on future passes. +Hermit starts a small Bun HTTP server for local visibility into helper activity. -## Helper Logs API +Default address: -Hermit starts a local Bun HTTP server for the former `helper-logs` functionality. By default it listens on `http://127.0.0.1:8787`. +- `http://127.0.0.1:8787` -Available routes: +Routes: -- `GET /` dashboard UI -- `GET /api/events` browse normalized event rows -- `GET /api/threads` browse tracked-thread rows +- `GET /`: dashboard UI for helper events +- `GET /api/events`: JSON event listing +- `GET /api/threads`: JSON tracked-thread listing -Set `HELPER_LOGS_PORT=0` to disable the local helper logs server entirely. +Supported `GET /api/events` query params: -## Gateway Events +- `eventType` +- `command` +- `threadId` +- `invokedBy` +- `from` +- `to` +- `limit` up to `500` + +Supported `GET /api/threads` query params: + +- `threadId` +- `solved` +- `closed` +- `limit` up to `500` + +Source: [src/server/helperLogsServer.ts](src/server/helperLogsServer.ts) + +## Configuration Files -The bot listens for the following Gateway events: -- AutoModeration Action Execution - Sends keyword-based responses +- [src/config/automod-messages.json](src/config/automod-messages.json): automod trigger-to-response mapping +- [src/config/threadLengthMessages.ts](src/config/threadLengthMessages.ts): warning and auto-close helper thread messages -## AutoMod Responses +## Development Notes -Edit `src/config/automod-messages.json` to map keywords to messages. Use `{user}` to mention the triggering user. +- Hermit is Bun-first; `package-lock.json` is intentionally not used +- command registration and gateway listeners are wired in [src/index.ts](src/index.ts) +- helper events and tracked-thread writes are internal; the HTTP server is read-only +- the thread-length scheduler is interval-based, not cron-based + +## Verification + +Useful local checks: + +```bash +bun run typecheck +bun run db:migrate +bun run dev +``` ## License