initial implementation
vi did:web:vt3e.cat
Thu, 11 Jun 2026 00:56:43 +0100
11 files changed,
830 insertions(+),
3 deletions(-)
A
.zed/settings.json
@@ -0,0 +1,80 @@
+{ + "lsp": { + "oxlint": { + "initialization_options": { + "settings": { + "configPath": null, + "run": "onType", + "disableNestedConfig": false, + "fixKind": "safe_fix", + "unusedDisableDirectives": "deny", + }, + }, + }, + "oxfmt": { + "initialization_options": { + "settings": { + "fmt.configPath": null, + "run": "onSave", + }, + }, + }, + }, + "languages": { + "JavaScript": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + { + "code_action": "source.fixAll.oxc", + }, + ], + }, + "JSON": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "Markdown": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "TypeScript": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + }, +}
M
bun.lock
→
bun.lock
@@ -15,6 +15,9 @@ },
}, "pkgs/core": { "name": "@kitten/core", + "dependencies": { + "discord.js": "^14.26.4", + }, "devDependencies": { "@types/bun": "latest", },@@ -24,6 +27,18 @@ },
}, }, "packages": { + "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], + + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], + + "@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="], + + "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], + + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@kitten/core": ["@kitten/core@workspace:pkgs/core"], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.53.0", "", { "os": "android", "cpu": "arm" }, "sha512-XfVM8AmIovBTKXCt14Op5wbfcoM8418nttd+nhMgM3RAVaJg1MtJc73FyWfUt0oxLyBGVwfniNVUsbV/b3VmPg=="],@@ -102,20 +117,56 @@ "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.68.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-NnKXr4Wgo4nps3erhrE0f8shBvBPZMHg72nDsvX0JyrRvsNiP3f1JNvbCKh+A6VFvpF7ZoJxu904P3cKMhvZnA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.68.0", "", { "os": "win32", "cpu": "x64" }, "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "discord-api-types": ["discord-api-types@0.38.48", "", {}, "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ=="], + + "discord.js": ["discord.js@14.26.4", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + + "magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="], + "oxfmt": ["oxfmt@0.53.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.53.0", "@oxfmt/binding-android-arm64": "0.53.0", "@oxfmt/binding-darwin-arm64": "0.53.0", "@oxfmt/binding-darwin-x64": "0.53.0", "@oxfmt/binding-freebsd-x64": "0.53.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.53.0", "@oxfmt/binding-linux-arm-musleabihf": "0.53.0", "@oxfmt/binding-linux-arm64-gnu": "0.53.0", "@oxfmt/binding-linux-arm64-musl": "0.53.0", "@oxfmt/binding-linux-ppc64-gnu": "0.53.0", "@oxfmt/binding-linux-riscv64-gnu": "0.53.0", "@oxfmt/binding-linux-riscv64-musl": "0.53.0", "@oxfmt/binding-linux-s390x-gnu": "0.53.0", "@oxfmt/binding-linux-x64-gnu": "0.53.0", "@oxfmt/binding-linux-x64-musl": "0.53.0", "@oxfmt/binding-openharmony-arm64": "0.53.0", "@oxfmt/binding-win32-arm64-msvc": "0.53.0", "@oxfmt/binding-win32-ia32-msvc": "0.53.0", "@oxfmt/binding-win32-x64-msvc": "0.53.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-9cB5glS3Ip6NMuZ+6NYTao9FCWkDhRtPYCtR3QBu/NxHoFbgzzTvi41N4jxz/GqGfuLKspui1qb/LlSu2IbMcw=="], "oxlint": ["oxlint@1.68.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.68.0", "@oxlint/binding-android-arm64": "1.68.0", "@oxlint/binding-darwin-arm64": "1.68.0", "@oxlint/binding-darwin-x64": "1.68.0", "@oxlint/binding-freebsd-x64": "1.68.0", "@oxlint/binding-linux-arm-gnueabihf": "1.68.0", "@oxlint/binding-linux-arm-musleabihf": "1.68.0", "@oxlint/binding-linux-arm64-gnu": "1.68.0", "@oxlint/binding-linux-arm64-musl": "1.68.0", "@oxlint/binding-linux-ppc64-gnu": "1.68.0", "@oxlint/binding-linux-riscv64-gnu": "1.68.0", "@oxlint/binding-linux-riscv64-musl": "1.68.0", "@oxlint/binding-linux-s390x-gnu": "1.68.0", "@oxlint/binding-linux-x64-gnu": "1.68.0", "@oxlint/binding-linux-x64-musl": "1.68.0", "@oxlint/binding-openharmony-arm64": "1.68.0", "@oxlint/binding-win32-arm64-msvc": "1.68.0", "@oxlint/binding-win32-ia32-msvc": "1.68.0", "@oxlint/binding-win32-x64-msvc": "1.68.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/rest/@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], } }
M
pkgs/core/package.json
→
pkgs/core/package.json
@@ -1,11 +1,17 @@
{ "name": "@kitten/core", + "type": "module", "module": "src/index.ts", - "type": "module", + "dependencies": { + "discord.js": "^14.26.4" + }, "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" - } + }, + "files": [ + "src/**/*" + ] }
A
pkgs/core/src/commands.ts
@@ -0,0 +1,123 @@
+import type { ChatInputCommandInteraction } from "discord.js"; +import type { Option, InferOptions } from "./options"; + +export class Subcommand { + constructor( + public name: string, + public config: { + description: string; + options?: Record<string, Option<any, any>>; + run: Function; + }, + ) {} +} + +export class SubcommandGroup<Ctx = {}> { + public subcommands: Record<string, Subcommand> = {}; + + constructor( + public name: string, + public description: string, + ) {} + + subcommand<O extends Record<string, Option<any, any>>>( + name: string, + config: { + description: string; + options?: O; + run: ( + interaction: ChatInputCommandInteraction, + args: InferOptions<O>, + ctx: Ctx, + ) => Promise<void> | void; + }, + ): this { + this.subcommands[name] = new Subcommand(name, config); + return this; + } +} + +export class SubcommandGroupBuilder<Ctx = {}> { + public subcommands: Record<string, Subcommand> = {}; + public groups: Record<string, SubcommandGroup<Ctx>> = {}; + + constructor( + public name: string, + public description: string, + public middlewares: Function[] = [], + ) {} + + subcommand<O extends Record<string, Option<any, any>>>( + name: string, + config: { + description: string; + options?: O; + run: ( + interaction: ChatInputCommandInteraction, + args: InferOptions<O>, + ctx: Ctx, + ) => Promise<void> | void; + }, + ): this { + this.subcommands[name] = new Subcommand(name, config); + return this; + } + + group( + name: string, + config: { description: string }, + setup: (group: SubcommandGroup<Ctx>) => void, + ): this { + const group = new SubcommandGroup<Ctx>(name, config.description); + setup(group); + this.groups[name] = group; + return this; + } +} + +export class Command { + constructor( + public name: string, + public config: { + description: string; + options?: Record<string, Option<any, any>>; + run: Function; + }, + public middlewares: Function[] = [], + ) {} +} + +export class CommandBuilder<Ctx = {}> { + constructor(private middlewares: Function[] = []) {} + + use<NewCtx extends Record<string, any>>( + mw: ( + interaction: ChatInputCommandInteraction, + ctx: Ctx, + ) => Promise<NewCtx | void> | NewCtx | void, + ): CommandBuilder<Ctx & NewCtx> { + return new CommandBuilder<Ctx & NewCtx>([...this.middlewares, mw]); + } + + command<O extends Record<string, Option<any, any>>>( + name: string, + config: { + description: string; + options?: O; + run: ( + interaction: ChatInputCommandInteraction, + args: InferOptions<O>, + ctx: Ctx, + ) => Promise<void> | void; + }, + ): Command; + + command(name: string, config: { description: string }): SubcommandGroupBuilder<Ctx>; + + command(name: string, config: any): any { + if (config.run) { + return new Command(name, config, this.middlewares); + } + return new SubcommandGroupBuilder<Ctx>(name, config.description, this.middlewares); + } +}
A
pkgs/core/src/components.ts
@@ -0,0 +1,49 @@
+import type { ButtonInteraction } from "discord.js"; +import type { Option } from "./options"; +import { CustomIdTooLong } from "./errors"; + +export class KittenComponent { + type = "component" as const; + + constructor( + public name: string, + public config: { + options?: Record<string, Option<any, any>>; + run: (interaction: ButtonInteraction, args: any) => Promise<void> | void; + }, + ) {} + + /** + * @throws {CustomIdTooLong} if the generated custom ID exceeds Discord's 100 character limit. + * @returns a custom ID string that encodes the component name and its options' values. + */ + id(args: Record<string, any> = {}): string { + const keys = Object.keys(this.config.options || {}); + const values = keys.map((key) => encodeURIComponent(String(args[key] ?? ""))); + const customId = [this.name, ...values].join(":"); + + if (customId.length > 100) throw new CustomIdTooLong(this.name, customId.length); + + return customId; + } + + parseArgs(customId: string): Record<string, any> { + const [, ...parts] = customId.split(":"); + const keys = Object.keys(this.config.options || {}); + const parsed: Record<string, any> = {}; + + keys.forEach((key, index) => { + const rawVal = parts[index]; + if (rawVal === undefined) return; + + const val = decodeURIComponent(rawVal); + const opt = this.config.options?.[key]; + + if (opt?.type === "boolean") parsed[key] = val === "true"; + else if (opt?.type === "integer" || opt?.type === "number") parsed[key] = Number(val); + else parsed[key] = val === "" ? undefined : val; + }); + + return parsed; + } +}
A
pkgs/core/src/errors.ts
@@ -0,0 +1,13 @@
+export class HaltExecution extends Error { + constructor() { + super("execution stopped by middleware"); + } +} + +export class CustomIdTooLong extends Error { + constructor(componentName: string, length: number) { + super( + `custom ID for component "${componentName}" is too long (${length} characters). discord limits custom IDs to 100 characters.`, + ); + } +}
M
pkgs/core/src/index.ts
→
pkgs/core/src/index.ts
@@ -1,1 +1,20 @@
-console.log("Hello via Bun!");+export * from "./options"; +export * from "./errors"; +export * from "./components"; +export * from "./commands"; +export * from "./kitten"; +export * from "./logger"; + +// TODO)) +// components +// - [ ] modals +// - [ ] select menus +// - [x] buttons +// +// commands +// - [ ] context menus (user, message) +// - [x] slash commands +// +// other +// - [ ] banish `any` in as many places as possible +// - [ ] custom error handlers
A
pkgs/core/src/kitten.ts
@@ -0,0 +1,314 @@
+import { + type Client, + type ChatInputCommandInteraction, + type AutocompleteInteraction, + type ButtonInteraction, + type ApplicationCommandNonOptionsData, + BaseInteraction, +} from "discord.js"; + +import { HaltExecution } from "./errors"; +import { type Option, type InferOptions, OPTION_TYPES } from "./options"; +import { KittenComponent } from "./components"; +import { Command, CommandBuilder, SubcommandGroupBuilder } from "./commands"; +import { baseLogger, type KittenLogger } from "./logger"; + +export interface KittenOptions { + logger?: KittenLogger; +} + +export class Kitten { + private commands = new Map<string, Command | SubcommandGroupBuilder<any>>(); + private components = new Map<string, KittenComponent>(); + private logger: KittenLogger; + + constructor( + private client: Client, + options: KittenOptions = {}, + ) { + this.client.on("interactionCreate", this.handleInteraction.bind(this)); + this.logger = options.logger ?? baseLogger; + } + + builder() { + return new CommandBuilder(); + } + + command<O extends Record<string, Option<any, any>>>( + name: string, + config: { + description: string; + options?: O; + run: ( + interaction: ChatInputCommandInteraction, + args: InferOptions<O>, + ) => Promise<void> | void; + }, + ): Command; + + command(name: string, config: { description: string }): SubcommandGroupBuilder<{}>; + command(name: string, config: any): any { + return this.builder().command(name, config); + } + + button<O extends Record<string, Option<any, any>>>( + name: string, + config: { + options?: O; + run: (interaction: ButtonInteraction, args: InferOptions<O>) => Promise<void> | void; + }, + ): KittenComponent { + return new KittenComponent(name, config); + } + + register(registry: { + commands?: (Command | SubcommandGroupBuilder<any>)[]; + components?: KittenComponent[]; + }) { + registry.commands?.forEach((cmd) => this.commands.set(cmd.name, cmd)); + registry.components?.forEach((comp) => this.components.set(comp.name, comp)); + } + + private async handleInteraction(interaction: BaseInteraction) { + // commands + if (interaction.isChatInputCommand()) await this.routeCommand(interaction); + else if (interaction.isAutocomplete()) await this.routeAutocomplete(interaction); + + // components + else if (interaction.isButton()) await this.routeButton(interaction); + } + + private async routeCommand(interaction: ChatInputCommandInteraction) { + const entry = this.commands.get(interaction.commandName); + if (!entry) return; + + if (entry instanceof SubcommandGroupBuilder) { + const groupName = interaction.options.getSubcommandGroup(false); + const subName = interaction.options.getSubcommand(false); + + if (groupName) { + const group = entry.groups[groupName]; + if (!group) return; + const subcommand = group.subcommands[subName ?? ""]; + if (!subcommand) return; + + await this.executeWithMiddlewares( + interaction, + entry.middlewares, + subcommand.config.run, + subcommand.config.options, + ); + } else if (subName) { + const subcommand = entry.subcommands[subName]; + if (!subcommand) return; + + await this.executeWithMiddlewares( + interaction, + entry.middlewares, + subcommand.config.run, + subcommand.config.options, + ); + } + } else { + await this.executeWithMiddlewares( + interaction, + entry.middlewares, + entry.config.run, + entry.config.options, + ); + } + } + + private async executeWithMiddlewares( + interaction: ChatInputCommandInteraction, + middlewares: Function[], + run: Function, + optionsSchema?: Record<string, Option<any, any>>, + ) { + try { + let context = {}; + for (const mw of middlewares) { + const result = await mw(interaction, context); + if (result) context = { ...context, ...result }; + } + + const args = this.parseSlashOptions(interaction, optionsSchema); + await run(interaction, args, context); + } catch (err) { + // TODO)) custom error handlers + if (err instanceof HaltExecution) return; + + this.logger.error(`unhandled error executing command ${interaction.commandName}:`, { + error: err instanceof Error ? err.stack : String(err), + command: interaction.commandName, + user: interaction.user.id, + }); + + if (!interaction.replied && !interaction.deferred) { + await interaction + .reply({ content: "an error occurred while executing this command.", ephemeral: true }) + .catch(() => null); + } + } + } + + private async routeAutocomplete(interaction: AutocompleteInteraction) { + const entry = this.commands.get(interaction.commandName); + if (!entry) return; + + const focused = interaction.options.getFocused(true); + let optionsSchema: Record<string, Option<any, any>> | undefined; + + if (entry instanceof SubcommandGroupBuilder) { + const groupName = interaction.options.getSubcommandGroup(false); + const subName = interaction.options.getSubcommand(false); + + if (groupName && subName) + optionsSchema = entry.groups[groupName]?.subcommands[subName]?.config.options; + else if (subName) optionsSchema = entry.subcommands[subName]?.config.options; + } else { + optionsSchema = entry.config.options; + } + + const opt = optionsSchema?.[focused.name]; + if (opt?.autocomplete) { + const choices = await opt.autocomplete(interaction, focused.value); + await interaction.respond(choices).catch(() => null); + } + } + + private async routeButton(interaction: ButtonInteraction) { + const [prefix] = interaction.customId.split(":"); + if (!prefix) return; + + const component = this.components.get(prefix); + if (!component) return; + + try { + const args = component.parseArgs(interaction.customId); + await component.config.run(interaction, args); + } catch (err) { + this.logger.error(`error executing button ${prefix}:`, { + error: err instanceof Error ? err.stack : String(err), + component: prefix, + user: interaction.user.id, + }); + } + } + + private parseSlashOptions( + interaction: ChatInputCommandInteraction, + optionsSchema?: Record<string, Option<any, any>>, + ): Record<string, any> { + if (!optionsSchema) return {}; + const parsed: Record<string, any> = {}; + + for (const [name, opt] of Object.entries(optionsSchema)) { + switch (opt.type) { + case "string": + parsed[name] = interaction.options.getString(name); + break; + case "integer": + parsed[name] = interaction.options.getInteger(name); + break; + case "number": + parsed[name] = interaction.options.getNumber(name); + break; + case "boolean": + parsed[name] = interaction.options.getBoolean(name); + break; + case "user": + parsed[name] = interaction.options.getUser(name); + break; + case "channel": + parsed[name] = interaction.options.getChannel(name); + break; + case "role": + parsed[name] = interaction.options.getRole(name); + break; + case "mentionable": + parsed[name] = interaction.options.getMentionable(name); + break; + case "attachment": + parsed[name] = interaction.options.getAttachment(name); + break; + } + } + return parsed; + } + + async sync({ guildId }: { guildId?: string } = {}) { + const payloads = Array.from(this.commands.values()).map((cmd) => { + if (cmd instanceof SubcommandGroupBuilder) { + const subcommandOptions = Object.values(cmd.subcommands).map((sub) => ({ + type: OPTION_TYPES.subcommand, + name: sub.name, + description: sub.config.description, + options: this.transformOptionsForDiscord(sub.config.options), + })); + + const groupOptions = Object.values(cmd.groups).map((group) => ({ + type: OPTION_TYPES.subcommandGroup, + name: group.name, + description: group.description, + options: Object.values(group.subcommands).map((sub) => ({ + type: OPTION_TYPES.subcommand, + name: sub.name, + description: sub.config.description, + options: this.transformOptionsForDiscord(sub.config.options), + })), + })); + + return { + name: cmd.name, + description: cmd.description, + options: [...subcommandOptions, ...groupOptions], + }; + } else { + return { + name: cmd.name, + description: cmd.config.description, + options: this.transformOptionsForDiscord(cmd.config.options), + }; + } + }); + + if (!this.client.application) { + throw new Error("client application not ready to sync. ensure you await client.login()"); + } + + if (guildId) { + await this.client.application.commands.set(payloads, guildId); + this.logger.info(`synced ${payloads.length} commands to guild ${guildId}.`, { + guildId, + commandCount: payloads.length, + }); + } else { + await this.client.application.commands.set(payloads); + this.logger.info(`synced ${payloads.length} global commands.`, { + commandCount: payloads.length, + }); + } + } + + private transformOptionsForDiscord( + options?: Record<string, Option<any, any>>, + ): ApplicationCommandNonOptionsData[] { + if (!options) return []; + + return Object.entries(options).map(([name, opt]) => { + const payload: any = { + name, + description: opt.description, + type: OPTION_TYPES[opt.type], + required: opt.required, + }; + + if (opt.autocomplete) { + payload.autocomplete = true; + } + + return payload as ApplicationCommandNonOptionsData; + }); + } +}
A
pkgs/core/src/logger.ts
@@ -0,0 +1,95 @@
+export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; + +export interface LogEntry { + level: LogLevel; + msg: string; + time: number; + [key: string]: unknown; +} + +export interface LoggerOptions { + level?: LogLevel; + name?: string; + base?: Record<string, unknown>; + transport?: (entry: LogEntry) => void; +} + +export interface KittenLogger { + trace(msg: string, data?: Record<string, unknown>): void; + debug(msg: string, data?: Record<string, unknown>): void; + info(msg: string, data?: Record<string, unknown>): void; + warn(msg: string, data?: Record<string, unknown>): void; + error(msg: string, data?: Record<string, unknown>): void; + fatal(msg: string, data?: Record<string, unknown>): void; + child(bindings: Record<string, unknown>): KittenLogger; +} + +const LEVELS: Record<LogLevel, number> = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, +}; + +const defaultTransport = (entry: LogEntry) => { + const out = JSON.stringify(entry); + if (entry.level === "error" || entry.level === "fatal") console.error(out); + else console.log(out); +}; + +export class Logger implements KittenLogger { + private readonly minLevel: number; + private readonly base: Record<string, unknown>; + private readonly transport: (entry: LogEntry) => void; + + constructor(private readonly options: LoggerOptions = {}) { + this.minLevel = LEVELS[options.level ?? "info"]; + this.base = { + ...(options.name ? { name: options.name } : {}), + ...options.base, + }; + this.transport = options.transport ?? defaultTransport; + } + + private write(level: LogLevel, msg: string, data?: Record<string, unknown>) { + if (LEVELS[level] < this.minLevel) return; + this.transport({ + level, + msg, + time: Date.now(), + ...this.base, + ...data, + }); + } + + trace(msg: string, data?: Record<string, unknown>) { + this.write("trace", msg, data); + } + debug(msg: string, data?: Record<string, unknown>) { + this.write("debug", msg, data); + } + info(msg: string, data?: Record<string, unknown>) { + this.write("info", msg, data); + } + warn(msg: string, data?: Record<string, unknown>) { + this.write("warn", msg, data); + } + error(msg: string, data?: Record<string, unknown>) { + this.write("error", msg, data); + } + fatal(msg: string, data?: Record<string, unknown>) { + this.write("fatal", msg, data); + } + + child(bindings: Record<string, unknown>): KittenLogger { + return new Logger({ + ...this.options, + base: { ...this.base, ...bindings }, + }); + } +} + +const baseLogger = new Logger(); +export { baseLogger };
A
pkgs/core/src/options.ts
@@ -0,0 +1,77 @@
+import { + type AutocompleteInteraction, + type User, + type Attachment, + type Role, + type GuildBasedChannel, + type ApplicationCommandOptionChoiceData, +} from "discord.js"; + +export const OPTION_TYPES = { + subcommand: 1, + subcommandGroup: 2, + string: 3, + integer: 4, + boolean: 5, + user: 6, + channel: 7, + role: 8, + mentionable: 9, + number: 10, + attachment: 11, +} as const; +type OptionType = keyof typeof OPTION_TYPES; + +export interface Option<T, Req extends boolean> { + type: OptionType; + description: string; + required: Req; + autocomplete?: ( + interaction: AutocompleteInteraction, + value: T, + ) => Promise<ApplicationCommandOptionChoiceData[]> | ApplicationCommandOptionChoiceData[]; + __type?: T; +} + +export type InferOptions<O> = { + [K in keyof O]: O[K] extends Option<infer Type, infer Required> + ? Required extends true + ? Type + : Type | undefined + : never; +}; + +export type AutocompleteFn<T> = ( + interaction: AutocompleteInteraction, + value: T, +) => Promise<ApplicationCommandOptionChoiceData[]> | ApplicationCommandOptionChoiceData[]; + +const createOption = <T>(type: OptionType) => { + return ((description: string, config?: any): Option<T, any> => ({ + type, + description, + required: config?.required ?? false, + autocomplete: config?.autocomplete, + })) as { + ( + description: string, + config: { required: true; autocomplete?: AutocompleteFn<T> }, + ): Option<T, true>; + ( + description: string, + config?: { required?: false; autocomplete?: AutocompleteFn<T> }, + ): Option<T, false>; + }; +}; + +export const option = { + string: createOption<string>("string"), + integer: createOption<number>("integer"), + number: createOption<number>("number"), + boolean: createOption<boolean>("boolean"), + user: createOption<User>("user"), + channel: createOption<GuildBasedChannel>("channel"), + role: createOption<Role>("role"), + mentionable: createOption<User | Role | any>("mentionable"), + attachment: createOption<Attachment>("attachment"), +};