From 8f09382a38b1bd3df06da055129d074f17f6db11 Mon Sep 17 00:00:00 2001 From: Ghom Date: Thu, 14 May 2026 20:30:35 +0200 Subject: [PATCH 1/3] docs: add .claude and .cursor AI assistant configurations Add comprehensive documentation for Claude Code and Cursor AI assistants to help developers scaffold bot.ts project files with correct patterns. - CLAUDE.md: full framework guide (architecture, aliases, style, patterns) - .claude/commands/: 7 slash commands (add-command, add-slash, add-listener, add-button, add-cron, add-table, add-namespace) - .cursor/rules/: 5 MDC rule files (bot-ts, typescript-style, commands, database, listeners-and-events) - .cursor/commands/: 7 thin wrappers referencing .claude/commands/ via @file Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/add-button.md | 92 ++++++ .claude/commands/add-command.md | 60 ++++ .claude/commands/add-cron.md | 101 +++++++ .claude/commands/add-listener.md | 92 ++++++ .claude/commands/add-namespace.md | 144 ++++++++++ .claude/commands/add-slash.md | 107 +++++++ .claude/commands/add-table.md | 137 +++++++++ .cursor/commands/add-button.md | 1 + .cursor/commands/add-command.md | 1 + .cursor/commands/add-cron.md | 1 + .cursor/commands/add-listener.md | 1 + .cursor/commands/add-namespace.md | 1 + .cursor/commands/add-slash.md | 1 + .cursor/commands/add-table.md | 1 + .cursor/rules/bot-ts.mdc | 73 +++++ .cursor/rules/commands.mdc | 203 +++++++++++++ .cursor/rules/database.mdc | 184 ++++++++++++ .cursor/rules/listeners-and-events.mdc | 184 ++++++++++++ .cursor/rules/typescript-style.mdc | 118 ++++++++ CLAUDE.md | 383 +++++++++++++++++++++++++ 20 files changed, 1885 insertions(+) create mode 100644 .claude/commands/add-button.md create mode 100644 .claude/commands/add-command.md create mode 100644 .claude/commands/add-cron.md create mode 100644 .claude/commands/add-listener.md create mode 100644 .claude/commands/add-namespace.md create mode 100644 .claude/commands/add-slash.md create mode 100644 .claude/commands/add-table.md create mode 100644 .cursor/commands/add-button.md create mode 100644 .cursor/commands/add-command.md create mode 100644 .cursor/commands/add-cron.md create mode 100644 .cursor/commands/add-listener.md create mode 100644 .cursor/commands/add-namespace.md create mode 100644 .cursor/commands/add-slash.md create mode 100644 .cursor/commands/add-table.md create mode 100644 .cursor/rules/bot-ts.mdc create mode 100644 .cursor/rules/commands.mdc create mode 100644 .cursor/rules/database.mdc create mode 100644 .cursor/rules/listeners-and-events.mdc create mode 100644 .cursor/rules/typescript-style.mdc create mode 100644 CLAUDE.md diff --git a/.claude/commands/add-button.md b/.claude/commands/add-button.md new file mode 100644 index 0000000..27b9678 --- /dev/null +++ b/.claude/commands/add-button.md @@ -0,0 +1,92 @@ +Create a new button interaction handler for this bot.ts project. + +The user will provide: a key/name for the button, a label, a description, and any typed parameters it receives. + +If the user hasn't provided details, ask for: +1. Button key (kebab-case, unique identifier, e.g. `confirm-delete`) +2. Display label (shown on the button) +3. Short description +4. Parameters the button receives (name: type pairs, e.g. `userId: string`, `amount: number`) + +Then create the file at `src/buttons/.ts` (Biome style: tabs, no semicolons, double quotes): + +### Button without parameters + +```typescript +import { Button } from "#core/button" + +export default new Button({ + key: "my-button", + description: "My button", + builder: (builder) => builder.setLabel("Click me"), + async run(interaction) { + await interaction.deferUpdate() + await interaction.followUp({ + content: "You clicked the button!", + ephemeral: true, + }) + }, +}) +``` + +### Button with typed parameters + +```typescript +import { Button } from "#core/button" + +export type ConfirmDeleteButtonParams = { + targetId: string + guildId: string +} + +export default new Button({ + key: "confirm-delete", + description: "Confirm deletion of a resource", + builder: (builder) => + builder.setLabel("Confirm Delete").setStyle(ButtonStyle.Danger), + async run(interaction, { targetId, guildId }) { + await interaction.deferUpdate() + // perform deletion using targetId and guildId + await interaction.followUp({ + content: `Deleted resource ${targetId} from guild ${guildId}.`, + ephemeral: true, + }) + }, +}) +``` + +### Using the button in a command or listener + +```typescript +import discord from "discord.js" +import confirmDeleteButton from "#buttons/confirm-delete" + +await channel.send({ + content: "Are you sure?", + components: [ + new discord.ActionRowBuilder().addComponents( + confirmDeleteButton.create({ targetId: "abc123", guildId: message.guildId! }) + ), + ], +}) +``` + +### ButtonStyle values (discord.js) + +- `ButtonStyle.Primary` — blue +- `ButtonStyle.Secondary` — grey +- `ButtonStyle.Success` — green +- `ButtonStyle.Danger` — red +- `ButtonStyle.Link` — link (use `setURL` instead of `create`) + +Import `ButtonStyle` from `discord.js` when needed. + +### Rules + +- The `key` must be unique across all button files +- Parameters are passed as a typed object to the `run` function +- Always call `interaction.deferUpdate()` or `interaction.deferReply()` first in `run` +- Use `interaction.followUp()` for responses after deferral +- Parameters are serialized into the button's custom ID — keep them short (total customId ≤ 100 chars) + +After creating the file, remind the user to run `bun run format`. diff --git a/.claude/commands/add-command.md b/.claude/commands/add-command.md new file mode 100644 index 0000000..4809b31 --- /dev/null +++ b/.claude/commands/add-command.md @@ -0,0 +1,60 @@ +Create a new textual (prefix) command for this bot.ts project. + +The user will provide: command name, description, channel type (guild/dm/all), and optionally arguments (positional, options, flags, rest), cooldown, and botOwnerOnly flag. + +If the user hasn't provided details, ask for: +1. Command name (kebab-case, e.g. `daily-reward`) +2. Short description +3. Channel type: `guild`, `dm`, or `all` +4. Arguments needed (if any) + +Then create the file at `src/commands/.ts` following this pattern exactly (Biome style: tabs, no semicolons, double quotes): + +```typescript +import { Command } from "#core/command" + +export default new Command({ + name: "", + description: "", + channelType: "", + async run(message) { + // todo: implement + }, +}) +``` + +### Rules to follow + +- File name matches the command `name` exactly +- Use `CooldownType` from `#core/command` if a cooldown is requested +- Add `botOwnerOnly: true` only when explicitly requested +- For positional args: use `positional: [{ name, description, type, required }]` +- For option args: use `options: [{ name, description, type }]` +- For flag args: use `flags: [{ name, flag, description }]` (single letter for `flag`) +- For rest: use `rest: { name, description, required }` +- Available argument types: `string`, `number`, `boolean`, `regex`, `date`, `duration`, `json`, `array`, `string[]`, `number[]`, `boolean[]`, `date[]`, `user`, `member`, `channel`, `role`, `emote`, `invite`, `command`, `slashCommand` +- Custom types are defined in `src/types.ts` +- Access parsed args via `message.args.` +- `message.triggerCoolDown()` must be called inside `run` if a cooldown is defined + +### Example with arguments and cooldown + +```typescript +import { Command, CooldownType } from "#core/command" + +export default new Command({ + name: "daily-reward", + description: "Claim your daily reward", + channelType: "guild", + cooldown: { + duration: 1000 * 60 * 60 * 24, // 24 hours + type: CooldownType.Global, + }, + async run(message) { + message.triggerCoolDown() + await message.channel.send(`${message.author} claimed their daily reward!`) + }, +}) +``` + +After creating the file, remind the user that they can run `bun run format` to apply Biome formatting. diff --git a/.claude/commands/add-cron.md b/.claude/commands/add-cron.md new file mode 100644 index 0000000..6bae2c3 --- /dev/null +++ b/.claude/commands/add-cron.md @@ -0,0 +1,101 @@ +Create a new cron job (scheduled task) for this bot.ts project. + +The user will provide: a name, description, schedule, and the task to execute. + +If the user hasn't provided details, ask for: +1. Cron job name (camelCase, e.g. `dailyReport`) +2. Short description +3. Schedule (see options below) +4. Should it run immediately on bot start? (yes/no) + +Then create the file at `src/cron/.ts` (Biome style: tabs, no semicolons, double quotes). + +### Schedule options + +**Interval key** (simplest): +```typescript +schedule: "minutely" // | "hourly" | "daily" | "weekly" | "monthly" | "yearly" +``` + +**Simple interval** (every N units): +```typescript +schedule: { type: "hour", duration: 6 } // every 6 hours +// type: "minute" | "hour" | "day" | "week" | "month" | "year" +``` + +**Advanced** (precise cron-style): +```typescript +import { Cron, CronDayOfWeek, CronMonth } from "#core/cron" + +schedule: { + minute: 30, // 0-59 or "*" + hour: 9, // 0-23 or "*" + dayOfMonth: "*", // 1-31 or "*" + month: "*", // 1-12 or CronMonth enum or "*" + dayOfWeek: CronDayOfWeek.Monday, // 0-6 or CronDayOfWeek enum or "*" +} +``` + +### Basic example + +```typescript +import { Cron } from "#core/cron" + +export default new Cron({ + name: "daily-report", + description: "Sends the daily activity report", + schedule: "daily", + runOnStart: false, + async run() { + // todo: implement report logic + }, +}) +``` + +### Advanced example (every Monday at 9:30 AM) + +```typescript +import { Cron, CronDayOfWeek } from "#core/cron" + +export default new Cron({ + name: "weekly-digest", + description: "Sends weekly digest every Monday at 9:30 AM", + schedule: { + minute: 30, + hour: 9, + dayOfWeek: CronDayOfWeek.Monday, + }, + runOnStart: false, + async run() { + // todo: send weekly digest + }, +}) +``` + +### Example accessing Discord client + +```typescript +import { Cron } from "#core/cron" +import * as app from "#all" + +export default new Cron({ + name: "status-update", + description: "Updates bot status every hour", + schedule: "hourly", + runOnStart: true, + async run() { + app.client.user?.setActivity(`Serving ${app.client.guilds.cache.size} servers`) + }, +}) +``` + +### Rules + +- File name should match the cron `name` in kebab-case or camelCase +- Timezone is read from `BOT_TIMEZONE` env var — configure it in `.env` +- `runOnStart: true` triggers the cron immediately when the bot boots +- `this.ranCount` tracks how many times this cron has executed +- Import `* as app from "#all"` to access the client, env, logger, etc. +- Cron jobs start automatically when the bot is ready (via `cron.clientReady.native.ts`) + +After creating the file, remind the user to run `bun run format`. diff --git a/.claude/commands/add-listener.md b/.claude/commands/add-listener.md new file mode 100644 index 0000000..1b676aa --- /dev/null +++ b/.claude/commands/add-listener.md @@ -0,0 +1,92 @@ +Create a new Discord event listener for this bot.ts project. + +The user will provide: the Discord.js event name, a category/purpose name, and a description. + +If the user hasn't provided details, ask for: +1. Discord.js event name (e.g. `guildMemberAdd`, `messageDelete`, `voiceStateUpdate`) +2. Category/purpose (e.g. `welcome`, `modlog`, `stats`) — used as file name prefix +3. Short description of what the listener does +4. Should it run only once? (yes/no — default no) + +Then create the file at `src/listeners/..ts` (Biome style: tabs, no semicolons, double quotes): + +```typescript +import { Listener } from "#core/listener" + +export default new Listener({ + event: "", + description: "", + async run() { + // todo: implement + }, +}) +``` + +### Common events and their arguments + +| Event | Arguments | +|---|---| +| `messageCreate` | `message: Message` | +| `messageDelete` | `message: Message \| PartialMessage` | +| `messageUpdate` | `oldMessage, newMessage` | +| `guildMemberAdd` | `member: GuildMember` | +| `guildMemberRemove` | `member: GuildMember \| PartialGuildMember` | +| `guildMemberUpdate` | `oldMember, newMember` | +| `interactionCreate` | `interaction: Interaction` | +| `voiceStateUpdate` | `oldState, newState: VoiceState` | +| `guildCreate` | `guild: Guild` | +| `guildDelete` | `guild: Guild` | +| `clientReady` | `client: Client` | +| `afterReady` | `client: Client` *(bot.ts custom event)* | +| `raw` | `packet: GatewayDispatchPayload` *(bot.ts custom event)* | + +### afterReady event (bot.ts specific) + +`afterReady` fires after ALL `clientReady` listeners have finished — use it for post-init work: + +```typescript +import { Listener } from "#core/listener" +import logger from "#core/logger" + +export default new Listener({ + event: "afterReady", + description: "Log successful startup", + once: true, + async run(client) { + logger.success(`Logged in as ${client.user.tag}`) + }, +}) +``` + +### Example: Welcome listener + +```typescript +import { Listener } from "#core/listener" +import discord from "discord.js" + +export default new Listener({ + event: "guildMemberAdd", + description: "Welcome new members with an embed", + async run(member) { + const channel = member.guild.systemChannel + if (!channel) return + + const embed = new discord.EmbedBuilder() + .setTitle("Welcome!") + .setDescription(`Welcome to **${member.guild.name}**, ${member}!`) + .setColor(0x5865f2) + + await channel.send({ embeds: [embed] }) + }, +}) +``` + +### Rules + +- File naming: `..ts` — the category appears in logs +- `.native.ts` files are framework files — NEVER modify them; copy + remove `.native` to override +- Errors thrown inside listeners are caught and logged automatically — no need for try/catch unless custom handling is needed +- `once: true` removes the listener after first trigger +- For `clientReady`, prefer `afterReady` to ensure all other ready listeners have completed first + +After creating the file, remind the user to run `bun run format`. diff --git a/.claude/commands/add-namespace.md b/.claude/commands/add-namespace.md new file mode 100644 index 0000000..43a478e --- /dev/null +++ b/.claude/commands/add-namespace.md @@ -0,0 +1,144 @@ +Create a new namespace (shared utility module) for this bot.ts project. + +Namespaces are TypeScript modules in `src/namespaces/` that export shared code: utilities, constants, API wrappers, database helpers, middlewares, etc. + +The user will provide: namespace name and its purpose. + +If the user hasn't provided details, ask for: +1. Namespace name (kebab-case, e.g. `utils`, `middlewares`, `constants`, `api`) +2. What it will contain (utilities, middlewares, constants, API wrappers, etc.) + +Then create the file at `src/namespaces/.ts` (Biome style: tabs, no semicolons, double quotes). + +**Important:** After creating the namespace, also update `package.json` to add the import alias. In the `imports` field, add: +```json +"#namespaces/": "./dist/namespaces/.js" +``` +(Note: the CLI `bot add namespace` handles this automatically — remind the user to use it or add it manually.) + +### Utility namespace example + +```typescript +// src/namespaces/utils.ts + +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000) % 60 + const minutes = Math.floor(ms / (1000 * 60)) % 60 + const hours = Math.floor(ms / (1000 * 60 * 60)) + + const parts: string[] = [] + if (hours > 0) parts.push(`${hours}h`) + if (minutes > 0) parts.push(`${minutes}m`) + if (seconds > 0) parts.push(`${seconds}s`) + + return parts.join(" ") || "0s" +} + +export function chunk(array: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(array.length / size) }, (_, i) => + array.slice(i * size, i * size + size) + ) +} +``` + +### Constants namespace example + +```typescript +// src/namespaces/constants.ts + +export const COLORS = { + primary: 0x5865f2, + success: 0x57f287, + warning: 0xfee75c, + error: 0xed4245, +} as const + +export const EMOJIS = { + success: "✅", + error: "❌", + warning: "⚠️", + loading: "⏳", +} as const + +export const LIMITS = { + commandsPerPage: 10, + maxEmbedFields: 25, + defaultCooldown: 3000, +} as const +``` + +### Middlewares namespace example + +```typescript +// src/namespaces/middlewares.ts + +import { Middleware } from "#core/command" + +export const requireAdmin = new Middleware( + "requireAdmin", + async (context, data) => { + if (!context.message.member?.permissions.has("Administrator")) { + return { + result: "You need Administrator permission to use this command.", + data: null, + } + } + return { result: true, data } + } +) + +export const requireLevel = (minLevel: number) => + new Middleware("requireLevel", async (context, data) => { + // const level = await getUserLevel(context.message.author.id) + // if (level < minLevel) return { result: `You need level ${minLevel}.`, data: null } + return { result: true, data } + }) +``` + +Usage in a command: +```typescript +import { requireAdmin, requireLevel } from "#namespaces/middlewares" + +export default new Command({ + name: "admin-cmd", + middlewares: [requireAdmin, requireLevel(10)], + // ... +}) +``` + +### Database helpers namespace example + +```typescript +// src/namespaces/db.ts + +import usersTable from "#tables/users" + +export async function getOrCreateUser(userId: string) { + let user = await usersTable.query.where("id", userId).first() + if (!user) { + await usersTable.query.insert({ id: userId }) + user = await usersTable.query.where("id", userId).first() + } + return user! +} +``` + +### Middleware return type + +```typescript +// result: true → continue command execution +// result: false → silently stop +// result: "message" → stop and display error message to user +// data: any → passed to next middleware and the command +``` + +### Rules + +- Namespaces are plain TypeScript modules — no default export required (use named exports) +- **Must register the import alias in `package.json` `imports`** (or use `bot add namespace`) +- Import via `#namespaces/` — never use relative paths into `src/namespaces/` +- Avoid circular dependencies between namespaces +- Middlewares must be in a namespace (not inline in command files) +- Middleware names should be descriptive — they appear in error logs + +After creating the file, remind the user to either run `bot add namespace` (which handles the `package.json` update) or manually add the alias to `package.json`. diff --git a/.claude/commands/add-slash.md b/.claude/commands/add-slash.md new file mode 100644 index 0000000..1c0c0c1 --- /dev/null +++ b/.claude/commands/add-slash.md @@ -0,0 +1,107 @@ +Create a new slash command for this bot.ts project. + +The user will provide: command name, description, and optionally: guild-only, bot-owner-only, required permissions, options/subcommands. + +If the user hasn't provided details, ask for: +1. Command name (lowercase, no spaces, e.g. `leaderboard`) +2. Short description +3. Guild-only? (yes/no) +4. Bot owner only? (yes/no) +5. Options or subcommands needed? + +Then create the file at `src/slash/.ts` following this pattern (Biome style: tabs, no semicolons, double quotes): + +### Basic slash command + +```typescript +import { SlashCommand } from "#core/slash" + +export default new SlashCommand({ + name: "", + description: "", + async run(interaction) { + await interaction.reply({ + content: " command is not yet implemented.", + ephemeral: true, + }) + }, +}) +``` + +### With options (string, user, channel, role, integer, boolean, number) + +```typescript +import { SlashCommand } from "#core/slash" + +export default new SlashCommand({ + name: "greet", + description: "Greet a user", + build(builder) { + builder.addUserOption((opt) => + opt.setName("user").setDescription("Who to greet").setRequired(true) + ) + }, + async run(interaction) { + const user = interaction.options.getUser("user", true) + await interaction.reply(`Hello, ${user}!`) + }, +}) +``` + +### With subcommands + +```typescript +import { SlashCommand } from "#core/slash" + +export default new SlashCommand({ + name: "settings", + description: "Manage settings", + build(builder) { + builder + .addSubcommand((sub) => + sub.setName("show").setDescription("Show current settings") + ) + .addSubcommand((sub) => + sub + .setName("set") + .setDescription("Change a setting") + .addStringOption((opt) => + opt.setName("key").setDescription("Setting key").setRequired(true) + ) + ) + }, + async run(interaction) { + const sub = interaction.options.getSubcommand() + if (sub === "show") { + await interaction.reply("Current settings: ...") + } else if (sub === "set") { + const key = interaction.options.getString("key", true) + await interaction.reply(`Setting ${key} updated.`) + } + }, +}) +``` + +### Available SlashCommand options + +| Property | Type | Description | +|---|---|---| +| `guildOnly` | `boolean` | Restrict to guild channels | +| `guildOwnerOnly` | `boolean` | Restrict to guild owner | +| `botOwnerOnly` | `boolean` | Restrict to bot owner | +| `userPermissions` | `PermissionsString[]` | Required Discord permissions | +| `allowRoles` | `RoleResolvable[]` | Whitelist roles | +| `denyRoles` | `RoleResolvable[]` | Blacklist roles | +| `middlewares` | `Middleware[]` | Pre-execution middleware | +| `build` | `function` | SlashCommandBuilder customization | +| `run` | `function` | Command handler | + +### Rules + +- Slash commands are auto-registered on bot start +- Set `BOT_GUILD` in `.env` for instant dev registration (global = up to 1 hour) +- Always `return` or `await` the `interaction.reply()` / `interaction.deferReply()` call +- Use `ephemeral: true` for responses that shouldn't be visible to others +- For long operations, use `await interaction.deferReply()` then `interaction.editReply()` + +After creating the file, remind the user to run `bun run format`. diff --git a/.claude/commands/add-table.md b/.claude/commands/add-table.md new file mode 100644 index 0000000..abb0188 --- /dev/null +++ b/.claude/commands/add-table.md @@ -0,0 +1,137 @@ +Create a new database table for this bot.ts project. + +The framework uses `@ghom/orm` (Knex-based ORM with SQLite3 by default). + +The user will provide: table name, description, and columns. + +If the user hasn't provided details, ask for: +1. Table name (snake_case, e.g. `guild_members`) +2. Short description +3. Columns: name, type, nullable/required, unique, primary key + +Then create the file at `src/tables/.ts` (Biome style: tabs, no semicolons, double quotes). + +### Basic example + +```typescript +import { Table } from "@ghom/orm" + +export interface GuildMember { + id: number + guild_id: string + user_id: string + score?: number + joined_at: Date +} + +export default new Table({ + name: "guild_members", + description: "Members tracked per guild", + setup: (table) => { + table.increments("id").primary().unsigned() + table.string("guild_id").notNullable() + table.string("user_id").notNullable() + table.integer("score").defaultTo(0) + table.timestamp("joined_at").defaultTo(table.client.fn.now()) + }, +}) +``` + +### With migrations (add columns in future versions) + +```typescript +export default new Table({ + name: "guild_members", + description: "Members tracked per guild", + setup: (table) => { + table.increments("id").primary().unsigned() + table.string("guild_id").notNullable() + table.string("user_id").notNullable() + }, + migrations: { + 1: (table) => { + table.integer("score").defaultTo(0) + }, + 2: (table) => { + table.boolean("is_premium").defaultTo(false) + }, + }, +}) +``` + +### With caching + +```typescript +export default new Table({ + name: "guild_members", + description: "Members tracked per guild", + caching: 300_000, // cache for 5 minutes + setup: (table) => { + // ... + }, +}) +``` + +### Knex column types reference + +```typescript +table.increments("id") // auto-increment integer PK +table.string("name") // VARCHAR(255) +table.text("content") // TEXT +table.integer("count") // INTEGER +table.bigInteger("big_id") // BIGINT +table.float("ratio") // FLOAT +table.boolean("active") // BOOLEAN +table.timestamp("created_at") // TIMESTAMP +table.date("birthday") // DATE +table.json("metadata") // JSON + +// Modifiers +.notNullable() +.nullable() +.defaultTo(value) +.unique() +.unsigned() +.primary() +.references("id").inTable("other_table").onDelete("cascade") +``` + +### Using the table in commands/listeners + +```typescript +import guildMembersTable from "#tables/guild_members" + +// SELECT +const member = await guildMembersTable.query + .where("guild_id", guildId) + .where("user_id", userId) + .first() + +// INSERT +await guildMembersTable.query.insert({ guild_id: guildId, user_id: userId }) + +// UPDATE +await guildMembersTable.query + .where("user_id", userId) + .update({ score: newScore }) + +// DELETE +await guildMembersTable.query.where("id", id).delete() + +// With cache: +const cached = await guildMembersTable.cache.get( + `member:${guildId}:${userId}`, + (q) => q.where("guild_id", guildId).where("user_id", userId) +) +``` + +### Rules + +- The `name` in `Table()` must match the actual database table name +- The TypeScript interface must have `?` for nullable/optional columns +- Migrations run automatically in ascending numeric order on each bot start +- Higher `priority` number = loads before lower priority tables (useful for foreign keys) +- Configure database engine with `bot config database` — defaults to SQLite3 +- Full Knex docs: https://knexjs.org/guide/schema-builder.html + +After creating the file, remind the user to run `bun run format`. diff --git a/.cursor/commands/add-button.md b/.cursor/commands/add-button.md new file mode 100644 index 0000000..679b4c6 --- /dev/null +++ b/.cursor/commands/add-button.md @@ -0,0 +1 @@ +Follow the instructions defined in @.claude/commands/add-button.md to create a new button interaction handler for this bot.ts project. diff --git a/.cursor/commands/add-command.md b/.cursor/commands/add-command.md new file mode 100644 index 0000000..94c5fe6 --- /dev/null +++ b/.cursor/commands/add-command.md @@ -0,0 +1 @@ +Follow the instructions defined in @.claude/commands/add-command.md to create a new textual (prefix) command for this bot.ts project. diff --git a/.cursor/commands/add-cron.md b/.cursor/commands/add-cron.md new file mode 100644 index 0000000..96b5bdf --- /dev/null +++ b/.cursor/commands/add-cron.md @@ -0,0 +1 @@ +Follow the instructions defined in @.claude/commands/add-cron.md to create a new cron job (scheduled task) for this bot.ts project. diff --git a/.cursor/commands/add-listener.md b/.cursor/commands/add-listener.md new file mode 100644 index 0000000..371ac61 --- /dev/null +++ b/.cursor/commands/add-listener.md @@ -0,0 +1 @@ +Follow the instructions defined in @.claude/commands/add-listener.md to create a new Discord event listener for this bot.ts project. diff --git a/.cursor/commands/add-namespace.md b/.cursor/commands/add-namespace.md new file mode 100644 index 0000000..3eee449 --- /dev/null +++ b/.cursor/commands/add-namespace.md @@ -0,0 +1 @@ +Follow the instructions defined in @.claude/commands/add-namespace.md to create a new namespace (shared utility module) for this bot.ts project. diff --git a/.cursor/commands/add-slash.md b/.cursor/commands/add-slash.md new file mode 100644 index 0000000..6ea4c51 --- /dev/null +++ b/.cursor/commands/add-slash.md @@ -0,0 +1 @@ +Follow the instructions defined in @.claude/commands/add-slash.md to create a new slash command for this bot.ts project. diff --git a/.cursor/commands/add-table.md b/.cursor/commands/add-table.md new file mode 100644 index 0000000..4e5fd93 --- /dev/null +++ b/.cursor/commands/add-table.md @@ -0,0 +1 @@ +Follow the instructions defined in @.claude/commands/add-table.md to create a new database table for this bot.ts project. diff --git a/.cursor/rules/bot-ts.mdc b/.cursor/rules/bot-ts.mdc new file mode 100644 index 0000000..56d02e2 --- /dev/null +++ b/.cursor/rules/bot-ts.mdc @@ -0,0 +1,73 @@ +--- +description: Core rules for working in the bot.ts Discord bot framework project +globs: ["src/**/*.ts", "*.ts", "*.json"] +alwaysApply: true +--- + +# bot.ts Framework Rules + +This project is a TypeScript Discord bot built with the **bot.ts** framework (`@ghom/bot.ts`), which wraps [discord.js](https://discord.js.org) with a file-based, auto-loading architecture. + +## Project structure + +``` +src/ +├── index.ts # Bootstrap — do not modify unless necessary +├── config.ts # Discord.js client config + env Zod schema +├── types.ts # Custom argument type resolvers +├── core/ # Framework internals — NEVER EDIT *.native.ts files +├── commands/ # Prefix (textual) commands +├── slash/ # Slash commands (Discord native) +├── listeners/ # Discord event listeners (category.event.ts) +├── buttons/ # Button interaction handlers +├── cron/ # Scheduled cron jobs +├── tables/ # Database table definitions (@ghom/orm) +└── namespaces/ # Shared utilities, constants, middlewares +``` + +## Path aliases — always use these, never relative imports into src/core + +| Alias | Resolves to | +|---|---| +| `#core/command` | `src/core/command.ts` | +| `#core/slash` | `src/core/slash.ts` | +| `#core/listener` | `src/core/listener.ts` | +| `#core/button` | `src/core/button.ts` | +| `#core/cron` | `src/core/cron.ts` | +| `#core/logger` | `src/core/logger.ts` | +| `#config` | `src/config.ts` | +| `#types` | `src/types.ts` | +| `#tables/*` | `src/tables/*` | +| `#buttons/*` | `src/buttons/*` | +| `#namespaces/*` | `src/namespaces/*` | +| `#all` | re-exports all core modules | + +## Native files rule + +Files ending in `.native.ts` are **framework core files**. Never modify them. To customize, copy the file and remove `.native` from the name — the framework loads the custom version automatically. + +## CLI scaffold commands + +Always prefer the CLI for generating new files: + +```bash +bot add command # src/commands/.ts +bot add slash # src/slash/.ts +bot add listener # src/listeners/..ts +bot add button # src/buttons/.ts +bot add cron # src/cron/.ts +bot add table # src/tables/.ts +bot add namespace # src/namespaces/.ts +``` + +## Key runtime scripts + +```bash +bun run build # Compile src/ → dist/ (Rollup) +bun run start # Build + run bot +bun run watch # Build + run + watch +bun run test # TypeScript check + boot test +bun run format # Biome format src/ +bun run lint # Biome lint src/ +bun run update # Update framework core/native files +``` diff --git a/.cursor/rules/commands.mdc b/.cursor/rules/commands.mdc new file mode 100644 index 0000000..0cba93d --- /dev/null +++ b/.cursor/rules/commands.mdc @@ -0,0 +1,203 @@ +--- +description: Rules for creating textual commands and slash commands in bot.ts +globs: ["src/commands/**/*.ts", "src/slash/**/*.ts"] +alwaysApply: false +--- + +# bot.ts Commands Rules + +## Textual commands (`src/commands/`) + +Import from `#core/command`. The file name must match the command `name`. + +### Minimal structure + +```typescript +import { Command } from "#core/command" + +export default new Command({ + name: "ping", + description: "Replies with Pong!", + channelType: "all", + async run(message) { + await message.channel.send("Pong!") + }, +}) +``` + +### Full structure with all options + +```typescript +import { Command, CooldownType } from "#core/command" +import { requireAdmin } from "#namespaces/middlewares" + +export default new Command({ + name: "my-command", + description: "Does something useful", + channelType: "guild", // "guild" | "dm" | "all" + botOwnerOnly: false, // restrict to bot owner + cooldown: { + duration: 5000, // 5 seconds in ms + type: CooldownType.Global, // Global | PerUser + }, + middlewares: [requireAdmin], + positional: [ + { + name: "target", + description: "The target member", + type: "member", + required: true, + }, + ], + options: [ + { + name: "reason", + description: "Reason for the action", + type: "string", + }, + ], + flags: [ + { + name: "silent", + flag: "s", + description: "Don't send a confirmation message", + }, + ], + rest: { + name: "message", + description: "Additional message content", + required: false, + }, + async run(message) { + const target = message.args.target // GuildMember + const reason = message.args.reason // string | null + const silent = message.args.silent // boolean + const msg = message.args.message // string | null + + message.triggerCoolDown() // must call if cooldown is defined + + await message.channel.send(`Done: ${target} — ${reason ?? "no reason"}`) + }, +}) +``` + +### Argument types + +| Type | Returns | +|---|---| +| `string` | `string \| null` | +| `number` | `number \| null` | +| `boolean` | `boolean \| null` | +| `regex` | `RegExp \| null` | +| `date` | `Date \| null` | +| `duration` | `number \| null` (ms) | +| `json` | `object \| null` | +| `array` | `string[] \| null` | +| `string[]` | `string[] \| null` | +| `number[]` | `number[] \| null` | +| `user` | `User \| null` | +| `member` | `GuildMember \| null` | +| `channel` | `Channel \| null` | +| `role` | `Role \| null` | +| `emote` | `GuildEmoji \| null` | +| `invite` | `Invite \| null` | +| `command` | `Command \| null` | +| `slashCommand` | `SlashCommand \| null` | + +Required args (`required: true`) never return `null`. + +## Slash commands (`src/slash/`) + +Import from `#core/slash`. File name = command name. + +### Minimal structure + +```typescript +import { SlashCommand } from "#core/slash" + +export default new SlashCommand({ + name: "hello", + description: "Say hello", + async run(interaction) { + await interaction.reply("Hello!") + }, +}) +``` + +### With options + +```typescript +import { SlashCommand } from "#core/slash" + +export default new SlashCommand({ + name: "kick", + description: "Kick a member", + guildOnly: true, + userPermissions: ["KickMembers"], + build(builder) { + builder + .addUserOption((opt) => + opt.setName("target").setDescription("Member to kick").setRequired(true) + ) + .addStringOption((opt) => + opt.setName("reason").setDescription("Reason for kick") + ) + }, + async run(interaction) { + const target = interaction.options.getMember("target") + const reason = interaction.options.getString("reason") ?? "No reason provided" + + if (!target) return interaction.reply({ content: "Member not found", ephemeral: true }) + + await (target as GuildMember).kick(reason) + await interaction.reply(`Kicked ${target}. Reason: ${reason}`) + }, +}) +``` + +### With subcommands + +```typescript +import { SlashCommand } from "#core/slash" + +export default new SlashCommand({ + name: "config", + description: "Bot configuration", + build(builder) { + builder + .addSubcommand((sub) => + sub.setName("show").setDescription("Show current config") + ) + .addSubcommand((sub) => + sub + .setName("set") + .setDescription("Update config") + .addStringOption((opt) => + opt.setName("key").setDescription("Config key").setRequired(true) + ) + .addStringOption((opt) => + opt.setName("value").setDescription("New value").setRequired(true) + ) + ) + }, + async run(interaction) { + const sub = interaction.options.getSubcommand() + if (sub === "show") { + return interaction.reply("Config: ...") + } + if (sub === "set") { + const key = interaction.options.getString("key", true) + const value = interaction.options.getString("value", true) + return interaction.reply(`Set ${key} = ${value}`) + } + }, +}) +``` + +## Key constraints + +- Cooldowns **require** calling `message.triggerCoolDown()` inside `run` — a warning appears in logs if forgotten +- Slash commands do not support custom argument types (use `build()` with discord.js option builders) +- Custom types for textual commands go in `src/types.ts` +- Use `interaction.deferReply()` + `interaction.editReply()` for operations taking > 3 seconds +- Slash commands register globally (up to 1h) unless `BOT_GUILD` is set in `.env` (instant) diff --git a/.cursor/rules/database.mdc b/.cursor/rules/database.mdc new file mode 100644 index 0000000..a0183a7 --- /dev/null +++ b/.cursor/rules/database.mdc @@ -0,0 +1,184 @@ +--- +description: Rules for database tables, ORM usage, and caching in bot.ts +globs: ["src/tables/**/*.ts", "src/namespaces/**/*.ts", "src/commands/**/*.ts", "src/slash/**/*.ts"] +alwaysApply: false +--- + +# bot.ts Database Rules + +This project uses `@ghom/orm`, a Knex-based ORM. Database tables are defined in `src/tables/` and imported via the `#tables/*` alias. + +## Table definition + +```typescript +import { Table } from "@ghom/orm" + +export interface MyEntity { + id: number + name: string + score?: number // optional = nullable column + created_at: Date +} + +export default new Table({ + name: "my_entities", + description: "Description for logs", + setup: (table) => { + table.increments("id").primary().unsigned() + table.string("name").notNullable() + table.integer("score").defaultTo(0) + table.timestamp("created_at").defaultTo(table.client.fn.now()) + }, +}) +``` + +## Migrations + +Add new columns without breaking existing data: + +```typescript +export default new Table({ + name: "my_entities", + setup: (table) => { + table.increments("id").primary().unsigned() + table.string("name").notNullable() + }, + migrations: { + 1: (table) => table.integer("score").defaultTo(0), + 2: (table) => table.boolean("is_premium").defaultTo(false), + }, +}) +``` + +- Migrations run in ascending numeric order on every bot start +- Never remove or renumber existing migration keys +- Only add new keys with higher numbers + +## Table loading priority + +```typescript +export default new Table({ + name: "my_entities", + priority: 10, // higher = loads/migrates first (useful for FK dependencies) + setup: (table) => { /* ... */ }, +}) +``` + +## Querying + +Import your table and use `.query` (typed Knex query builder): + +```typescript +import myEntitiesTable from "#tables/my_entities" + +// SELECT one +const entity = await myEntitiesTable.query.where("id", 1).first() + +// SELECT many +const entities = await myEntitiesTable.query.where("score", ">", 100).orderBy("score", "desc") + +// INSERT +await myEntitiesTable.query.insert({ name: "Alice", score: 50 }) + +// UPDATE +await myEntitiesTable.query.where("id", 1).update({ score: 100 }) + +// DELETE +await myEntitiesTable.query.where("id", 1).delete() + +// Increment/Decrement +await myEntitiesTable.query.where("id", 1).increment("score", 10) + +// Count +const [{ count }] = await myEntitiesTable.query.count("id as count") +``` + +Full Knex API: https://knexjs.org/guide/query-builder.html + +## Caching + +Enable caching per-table with the `caching` option (value = TTL in ms): + +```typescript +export default new Table({ + name: "my_entities", + caching: 600_000, // cache results for 10 minutes + setup: (table) => { /* ... */ }, +}) +``` + +Use the cache in your code: + +```typescript +// GET with cache (cache miss → runs query, stores result) +const entity = await myEntitiesTable.cache.get( + `entity:${id}`, // cache key + (query) => query.where("id", id) // query to run on miss +) + +// SET (runs mutation + invalidates cache entries) +await myEntitiesTable.cache.set( + (query) => query.where("id", id).update({ score: newScore }) +) +``` + +## Knex column type reference + +```typescript +// Numeric +table.increments("id") // auto-increment INTEGER PK +table.integer("count") // INTEGER +table.bigInteger("discord_id") // BIGINT (use string in TS interface for Discord IDs) +table.float("ratio") // FLOAT +table.decimal("amount", 10, 2) // DECIMAL + +// Text +table.string("name") // VARCHAR(255) +table.string("name", 64) // VARCHAR(64) +table.text("content") // TEXT + +// Boolean +table.boolean("active") // BOOLEAN / TINYINT(1) + +// Date/time +table.timestamp("created_at") // TIMESTAMP +table.date("birthday") // DATE +table.time("starts_at") // TIME + +// Other +table.json("metadata") // JSON +table.uuid("uuid") // UUID + +// Modifiers (chainable) +.notNullable() +.nullable() +.defaultTo(value) +.unique() +.unsigned() +.index() +.references("id").inTable("other_table").onDelete("cascade") +``` + +## TypeScript interface conventions + +```typescript +export interface MyEntity { + id: number // auto-increment PK + discord_id: string // Discord snowflake IDs as strings (not number) + name: string // notNullable column + description?: string // nullable column (? makes it optional) + score: number // has defaultTo() → always present + created_at: Date // timestamp +} +``` + +## Database configuration + +Change the database engine with the CLI: + +```bash +bot config database +``` + +Default: SQLite3. Supported: PostgreSQL, MySQL, SQLite3. +Connection details are configured via `DB_*` env vars in `.env`. diff --git a/.cursor/rules/listeners-and-events.mdc b/.cursor/rules/listeners-and-events.mdc new file mode 100644 index 0000000..b4576af --- /dev/null +++ b/.cursor/rules/listeners-and-events.mdc @@ -0,0 +1,184 @@ +--- +description: Rules for creating event listeners, buttons, and cron jobs in bot.ts +globs: ["src/listeners/**/*.ts", "src/buttons/**/*.ts", "src/cron/**/*.ts"] +alwaysApply: false +--- + +# bot.ts Listeners, Buttons & Cron Rules + +## Event Listeners (`src/listeners/`) + +File naming convention: `..ts` +The category prefix is extracted and shown in logs — use it semantically (e.g. `welcome`, `modlog`, `stats`). + +### Minimal structure + +```typescript +import { Listener } from "#core/listener" + +export default new Listener({ + event: "guildMemberAdd", + description: "Welcome new members", + async run(member) { + await member.guild.systemChannel?.send(`Welcome ${member}!`) + }, +}) +``` + +### With `once: true` (fires only once) + +```typescript +import { Listener } from "#core/listener" +import logger from "#core/logger" + +export default new Listener({ + event: "afterReady", + description: "Log startup completion", + once: true, + async run(client) { + logger.success(`Bot ready as ${client.user.tag}`) + }, +}) +``` + +### Common events + +| Event | Description | Key args | +|---|---|---| +| `messageCreate` | Message sent | `message: Message` | +| `messageDelete` | Message deleted | `message: Message \| PartialMessage` | +| `messageUpdate` | Message edited | `oldMessage, newMessage` | +| `guildMemberAdd` | Member joined | `member: GuildMember` | +| `guildMemberRemove` | Member left | `member: GuildMember \| PartialGuildMember` | +| `interactionCreate` | Any interaction | `interaction: Interaction` | +| `voiceStateUpdate` | Voice state changed | `oldState, newState: VoiceState` | +| `guildCreate` | Bot joined a guild | `guild: Guild` | +| `clientReady` | Bot connected | `client: Client` | +| `afterReady` | *(bot.ts)* After all ready listeners | `client: Client` | +| `raw` | *(bot.ts)* Raw gateway packet | `packet: GatewayDispatchPayload` | + +**Prefer `afterReady` over `clientReady`** — it fires after all other ready listeners have completed, making it safe for post-initialization work. + +### Native listeners — do not modify + +`.native.ts` listener files are framework core. To customize one: +1. Copy the file: `src/listeners/command.messageCreate.native.ts` → `src/listeners/command.messageCreate.ts` +2. The framework loads the custom version automatically + +### Error handling + +Errors thrown inside listeners are automatically caught and logged by the framework. No need for try/catch unless you want custom error handling. + +--- + +## Button Handlers (`src/buttons/`) + +```typescript +import { Button } from "#core/button" + +export type BuyButtonParams = { + articleId: string + quantity: number +} + +export default new Button({ + key: "buy", + description: "Purchase button", + builder: (builder) => + builder.setLabel("Buy").setStyle(ButtonStyle.Success), + async run(interaction, { articleId, quantity }) { + await interaction.deferUpdate() + // perform purchase logic + await interaction.followUp({ + content: `Bought ${quantity}x article ${articleId}!`, + ephemeral: true, + }) + }, +}) +``` + +### Creating button instances in commands/listeners + +```typescript +import discord, { ButtonStyle } from "discord.js" +import buyButton from "#buttons/buy" + +await channel.send({ + content: "Confirm purchase?", + components: [ + new discord.ActionRowBuilder().addComponents( + buyButton.create({ articleId: "sword-001", quantity: 1 }) + ), + ], +}) +``` + +### Button rules + +- `key` must be unique across all button files +- Parameters are serialized into the button `customId` — keep them short (total ≤ 100 chars) +- Always call `interaction.deferUpdate()` or `interaction.deferReply()` at the start of `run` +- Use `interaction.followUp()` for responses after deferral +- `ButtonStyle` is imported from `discord.js` + +--- + +## Cron Jobs (`src/cron/`) + +```typescript +import { Cron } from "#core/cron" + +export default new Cron({ + name: "cleanup", + description: "Clean up expired data every 6 hours", + schedule: { type: "hour", duration: 6 }, + runOnStart: false, + async run() { + // cleanup logic + }, +}) +``` + +### Schedule formats + +```typescript +// Interval key (simplest) +schedule: "minutely" // | "hourly" | "daily" | "weekly" | "monthly" | "yearly" + +// Simple interval +schedule: { type: "hour", duration: 6 } +// type: "minute" | "hour" | "day" | "week" | "month" | "year" + +// Advanced (cron-style) +import { CronDayOfWeek, CronMonth } from "#core/cron" +schedule: { + minute: 0, + hour: 8, + dayOfWeek: CronDayOfWeek.Monday, +} +``` + +### Accessing the Discord client in cron + +```typescript +import { Cron } from "#core/cron" +import * as app from "#all" + +export default new Cron({ + name: "status-update", + description: "Rotate bot status every hour", + schedule: "hourly", + runOnStart: true, + async run() { + const guildCount = app.client.guilds.cache.size + app.client.user?.setActivity(`${guildCount} servers`, { type: 0 }) + }, +}) +``` + +### Cron rules + +- Timezone uses `BOT_TIMEZONE` from `.env` (e.g. `Europe/Paris`) +- `runOnStart: true` executes the cron immediately when the bot boots +- `this.ranCount` tracks how many times the cron has run +- Crons start automatically via `cron.clientReady.native.ts` diff --git a/.cursor/rules/typescript-style.mdc b/.cursor/rules/typescript-style.mdc new file mode 100644 index 0000000..8c0301e --- /dev/null +++ b/.cursor/rules/typescript-style.mdc @@ -0,0 +1,118 @@ +--- +description: TypeScript and Biome code style rules for the bot.ts project +globs: ["src/**/*.ts"] +alwaysApply: true +--- + +# TypeScript & Biome Style Rules + +This project uses **Biome** for formatting and linting. All generated or edited code must comply. + +## Formatting rules (biome.json) + +- **Indentation**: tabs (not spaces) +- **Semicolons**: none — no trailing semicolons +- **Quotes**: double quotes `"string"` for strings, backticks for templates +- **Trailing commas**: always in multi-line objects and arrays +- **Line width**: 80 characters + +## TypeScript strictness + +These Biome lint rules are enforced — never violate them: + +```typescript +// ❌ WRONG +const x: any = getValue() +const el = document.getElementById("foo")! // non-null assertion +function doSomething(): void {} // void return type + +// ✅ CORRECT +const x: unknown = getValue() +const el = document.getElementById("foo") // handle null explicitly +function doSomething(): undefined {} +``` + +- **No `any`** — use `unknown` and narrow with type guards +- **No non-null assertions** (`!`) — use optional chaining or explicit null checks +- **No `void` type** — use `undefined` for explicit return types +- **Organize imports** — Biome auto-sorts; run `bun run format` to apply + +## Import order (managed by Biome) + +1. Node built-ins +2. External packages (discord.js, @ghom/*, etc.) +3. Internal aliases (`#core/*`, `#tables/*`, `#namespaces/*`) + +## Naming conventions + +| Thing | Convention | Example | +|---|---|---| +| Files | kebab-case | `daily-reward.ts` | +| Variables/functions | camelCase | `getUserScore` | +| Classes/interfaces/types | PascalCase | `GuildMember`, `UserTable` | +| Constants | SCREAMING_SNAKE_CASE | `MAX_SCORE` | +| Discord IDs | string (not number) | `"712345678901234567"` | +| Enum values | PascalCase | `CooldownType.Global` | + +## Async/await patterns + +```typescript +// ✅ Always await Discord.js API calls +await interaction.reply("...") +await message.channel.send("...") + +// ✅ Return the promise from run() when there's nothing after it +async run(interaction) { + return interaction.reply("...") +} + +// ❌ Don't forget to await — silent failures +interaction.reply("...") +``` + +## Error handling + +```typescript +// ✅ In commands/listeners — let the framework catch and log +async run(message) { + const result = await riskyOperation() // framework catches thrown errors + await message.channel.send(result) +} + +// ✅ Custom error handling when needed +async run(message) { + try { + await riskyOperation() + } catch (error) { + await message.channel.send("Something went wrong.") + app.error(error, import.meta.url) + } +} +``` + +## discord.js patterns + +```typescript +// ✅ Check for null before using optional guild properties +const channel = member.guild.systemChannel +if (!channel) return + +// ✅ Use optional chaining for uncertain properties +await member.guild.systemChannel?.send("...") + +// ✅ Embed builder pattern +const embed = new discord.EmbedBuilder() + .setTitle("Title") + .setDescription("Description") + .setColor(0x5865f2) + +// ✅ Ephemeral replies for private responses +await interaction.reply({ content: "Only you can see this", ephemeral: true }) +``` + +## Run Biome after any edits + +```bash +bun run format # fix formatting +bun run lint # check lint errors +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1c88c1f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,383 @@ +# bot.ts Framework — Claude Code Guide + +## What is this project? + +**bot.ts** (`@ghom/bot.ts`) is a TypeScript framework for building Discord bots on top of [discord.js](https://discord.js.org). It provides a structured, opinionated file-based architecture with automatic file loading, CLI generation, ORM integration, and multi-runtime support (Node.js, Bun, Deno). + +- Documentation: https://ghom.gitbook.io/bot-ts/ +- GitHub: https://github.com/bot-ts/framework +- CLI package: `@ghom/bot.ts-cli` + +--- + +## Project Architecture + +``` +src/ +├── index.ts # Bootstrap — initializes all handlers then logs in +├── config.ts # Discord.js client config + env schema (Zod) +├── types.ts # Custom type resolvers for textual commands +├── core/ # Framework internals — DO NOT EDIT (*.native.ts) +│ ├── argument.ts # Type resolver engine +│ ├── button.ts # Button handler +│ ├── client.ts # discord.js Client setup +│ ├── command.ts # Textual command handler +│ ├── config.ts # Config class +│ ├── cron.ts # Cron scheduler +│ ├── database.ts # ORM (Knex via @ghom/orm) +│ ├── env.ts # Env vars (Zod-validated) +│ ├── listener.ts # Event listener handler +│ ├── logger.ts # @ghom/logger +│ ├── pagination.ts # Reaction-based pagination +│ ├── slash.ts # Slash command handler +│ └── util.ts # Utilities +├── commands/ # Textual (prefix) commands — *.ts or *.native.ts +├── slash/ # Slash commands — *.ts or *.native.ts +├── listeners/ # Discord event listeners — category.event.ts +├── buttons/ # Button interaction handlers +├── cron/ # Scheduled cron jobs +├── tables/ # Database table definitions +└── namespaces/ # Shared utilities / middlewares +``` + +### Path aliases (tsconfig + package.json `imports`) + +| Alias | Resolves to | +|---|---| +| `#core/*` | `src/core/*` | +| `#config` | `src/config.ts` | +| `#types` | `src/types.ts` | +| `#tables/*` | `src/tables/*` | +| `#buttons/*` | `src/buttons/*` | +| `#namespaces/*` | `src/namespaces/*` | +| `#all` | re-exports everything | + +--- + +## Code Style + +This project uses **Biome** for formatting and linting. Always follow these rules: + +- **Indentation**: tabs (not spaces) +- **Semicolons**: none (no-semi) +- **Quotes**: double quotes `""` +- **Import order**: organized by Biome (run `bun run format`) +- **No `any`**: `no-explicit-any` is enforced +- **No non-null assertions** (`!`): avoid them +- **No `void` type**: use `undefined` instead + +Run `bun run format` after any edits. Run `bun run lint` to check. + +--- + +## Environment Variables + +Defined in `.env` (copy from `.template.env`): + +| Variable | Description | +|---|---| +| `BOT_TOKEN` | Discord bot token (required) | +| `BOT_PREFIX` | Command prefix (e.g. `!`) | +| `BOT_OWNER` | Bot owner Discord user ID | +| `BOT_ID` | Bot's own Discord application ID | +| `BOT_MODE` | `development` \| `production` \| `test` | +| `BOT_GUILD` | Guild ID for development (slash commands register instantly) | +| `BOT_NAME` | Bot display name | +| `BOT_LOCALE` | Default locale (e.g. `en`) | +| `BOT_TIMEZONE` | Timezone for cron jobs (e.g. `Europe/Paris`) | +| `DB_*` | Database connection credentials | +| `PACKAGE_MANAGER` | `npm` \| `yarn` \| `pnpm` \| `bun` | +| `RUNTIME` | `node` \| `bun` \| `deno` | + +Custom env vars must be added to the Zod schema in `src/config.ts`: + +```typescript +envSchema: z.object({ + MY_VAR: z.string(), +}) +``` + +Then access them via `app.env.MY_VAR` (fully typed). + +--- + +## Native Files Rule + +Files ending in `.native.ts` are **framework core files**. Never modify them directly. To override a native file, copy it and remove the `.native` suffix — the framework will load the custom version instead. + +--- + +## Creating Project Files + +Always prefer using the CLI to scaffold new files: + +```bash +bot add command # textual command in src/commands/ +bot add slash # slash command in src/slash/ +bot add listener # event listener in src/listeners/ +bot add button # button handler in src/buttons/ +bot add cron # cron job in src/cron/ +bot add table # database table in src/tables/ +bot add namespace # shared module in src/namespaces/ +bot config database # configure database engine +bot config engine # switch runtime/package manager +``` + +If the CLI is not available, use the patterns documented below. + +--- + +## Textual Commands (`src/commands/`) + +Import from `#core/command`. File name = command name. + +```typescript +import { Command } from "#core/command" + +export default new Command({ + name: "ping", + description: "Replies with pong", + channelType: "all", // "guild" | "dm" | "all" + // optional: + // botOwnerOnly: true, + // cooldown: { duration: 5000, type: CooldownType.Global }, + // middlewares: [myMiddleware], + // positional: [{ name: "target", description: "...", type: "member", required: true }], + // options: [{ name: "reason", description: "...", type: "string" }], + // flags: [{ name: "silent", flag: "s", description: "..." }], + // rest: { name: "content", description: "...", required: true }, + async run(message) { + await message.channel.send("Pong!") + }, +}) +``` + +### Argument types (textual commands only) + +Basic: `string`, `number`, `boolean`, `regex`, `date`, `duration`, `json` +Arrays: `array`, `string[]`, `number[]`, `boolean[]`, `date[]` +Discord: `user`, `member`, `channel`, `role`, `emote`, `invite` +Bot: `command`, `slashCommand` +Custom types are defined in `src/types.ts`. + +### Argument categories + +- **positional** — positional values (`!cmd value`) +- **options** — named flags with values (`!cmd --name value`) +- **flags** — boolean switches (`!cmd -s` or `!cmd --silent`) +- **rest** — captures remaining text (`!cmd all the rest`) + +--- + +## Slash Commands (`src/slash/`) + +Import from `#core/slash`. File name = command name. + +```typescript +import { SlashCommand } from "#core/slash" + +export default new SlashCommand({ + name: "hello", + description: "Greet someone", + // guildOnly: true, + // botOwnerOnly: true, + // userPermissions: ["ManageMessages"], + // middlewares: [myMiddleware], + build(builder) { + builder.addStringOption((opt) => + opt.setName("user").setDescription("Who to greet").setRequired(true) + ) + }, + async run(interaction) { + const user = interaction.options.getString("user", true) + await interaction.reply(`Hello, ${user}!`) + }, +}) +``` + +Slash commands are auto-registered on bot start. Set `BOT_GUILD` for instant dev registration (global commands take up to 1 hour). + +--- + +## Listeners (`src/listeners/`) + +File naming: `category.eventName.ts` + +```typescript +import { Listener } from "#core/listener" + +export default new Listener({ + event: "guildMemberAdd", + description: "Welcome new members", + // once: true, + async run(member) { + await member.guild.systemChannel?.send(`Welcome ${member}!`) + }, +}) +``` + +### Extra events (beyond Discord.js) + +- `afterReady` — fires after ALL `clientReady` listeners have completed +- `raw` — raw Gateway packets (`GatewayDispatchPayload`) + +--- + +## Buttons (`src/buttons/`) + +```typescript +import { Button } from "#core/button" + +export type MyButtonParams = { userId: string } + +export default new Button({ + key: "my-button", + description: "My button", + builder: (builder) => builder.setLabel("Click me"), + async run(interaction, { userId }) { + await interaction.deferUpdate() + await interaction.followUp({ content: `Clicked by ${userId}`, ephemeral: true }) + }, +}) +``` + +Use the button in a command: + +```typescript +import discord from "discord.js" +import myButton from "#buttons/my-button" + +await channel.send({ + components: [ + new discord.ActionRowBuilder().addComponents( + myButton.create({ userId: "123" }) + ), + ], +}) +``` + +--- + +## Cron Jobs (`src/cron/`) + +```typescript +import { Cron } from "#core/cron" + +export default new Cron({ + name: "daily-report", + description: "Sends the daily report", + // Interval key: "minutely" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" + schedule: "daily", + // Or simple interval: { type: "hour", duration: 6 } + // Or advanced: { minute: 0, hour: 8, dayOfWeek: CronDayOfWeek.Monday } + runOnStart: false, + async run() { + // task code here + }, +}) +``` + +Timezone is read from `BOT_TIMEZONE` env var. + +--- + +## Database Tables (`src/tables/`) + +Uses `@ghom/orm` (Knex-based with SQLite3 by default). + +```typescript +import { Table } from "@ghom/orm" + +export interface User { + id: number + username: string + score?: number +} + +export default new Table({ + name: "users", + description: "Bot users", + // priority: 1, // higher = loads first + // caching: 600_000, // ms, enables built-in cache + setup: (table) => { + table.increments("id").primary() + table.string("username").notNullable() + table.integer("score").defaultTo(0) + }, + migrations: { + 1: (table) => table.boolean("is_premium").defaultTo(false), + }, +}) +``` + +Query examples: + +```typescript +import usersTable from "#tables/users" + +const user = await usersTable.query.where("id", 1).first() +await usersTable.query.insert({ username: "Alice" }) +await usersTable.query.where("id", 1).update({ score: 100 }) + +// With cache: +const cached = await usersTable.cache.get("user:1", (q) => q.where("id", 1)) +``` + +--- + +## Namespaces (`src/namespaces/`) + +Shared utilities, constants, API wrappers, and middlewares. + +```typescript +// src/namespaces/utils.ts +export function formatMs(ms: number): string { + const s = Math.floor(ms / 1000) + return `${Math.floor(s / 60)}m ${s % 60}s` +} +``` + +Import via `#namespaces/utils`. Must be registered via CLI (`bot add namespace`) to update `package.json` imports. + +### Middlewares (in namespaces) + +```typescript +import { Middleware } from "#core/command" + +export const requireAdmin = new Middleware( + "requireAdmin", + async (context, data) => { + if (!context.message.member?.permissions.has("Administrator")) + return { result: "You need Administrator permission.", data: null } + return { result: true, data } + } +) +``` + +--- + +## Scripts + +| Script | Command | Description | +|---|---|---| +| Build | `bun run build` | Compile `src/` → `dist/` via Rollup | +| Start | `bun run start` | Build then run bot | +| Watch | `bun run watch` | Build, run, and watch for changes | +| Test | `bun run test` | TypeScript type check + boot test | +| Format | `bun run format` | Biome format `src/` | +| Lint | `bun run lint` | Biome lint `src/` | +| Update | `bun run update` | Update core/native framework files | +| Readme | `bun run readme` | Generate README from bot metadata | +| Final | `bun run final` | Production-optimized build | + +--- + +## Important Constraints + +1. **Never edit `src/core/*.native.ts`** — copy + remove `.native` suffix to customize +2. **Never edit `src/*/**.native.ts`** without very good reason +3. **Always run `bun run format`** after editing TypeScript files +4. **Use path aliases** (`#core/*`, `#tables/*`, etc.) — never use relative imports into `src/core/` +5. **Namespaces must be registered via CLI** (`bot add namespace`) to add the `imports` entry in `package.json` +6. **Slash commands take up to 1 hour** to propagate globally — set `BOT_GUILD` during development +7. **Custom argument types** go in `src/types.ts`, not in slash commands (not supported there) From c6414d716e3c2df70247e437337e13b6541064a3 Mon Sep 17 00:00:00 2001 From: Ghom Date: Thu, 14 May 2026 20:33:33 +0200 Subject: [PATCH 2/3] fix: approve pnpm build scripts for biome and sqlite3 pnpm requires explicit approval for dependency build scripts via pnpm.onlyBuiltDependencies. Also adds sqlite3 to trustedDependencies. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b095ade..fe427c2 100644 --- a/package.json +++ b/package.json @@ -80,5 +80,8 @@ "npm": ">=10.x.x", "yarn": ">=1.22.22" }, - "trustedDependencies": ["@biomejs/biome"] + "trustedDependencies": ["@biomejs/biome", "sqlite3"], + "pnpm": { + "onlyBuiltDependencies": ["@biomejs/biome", "sqlite3"] + } } From bcf1d95a1d1ad65d4e5b08f4bba40edbafd82d05 Mon Sep 17 00:00:00 2001 From: Ghom Date: Thu, 14 May 2026 20:36:38 +0200 Subject: [PATCH 3/3] fix: approve pnpm build scripts via pnpm-workspace.yaml pnpm v10+ stores build script approvals in pnpm-workspace.yaml (via `pnpm approve-builds`), not in package.json. This allows @biomejs/biome and sqlite3 to run their install scripts in CI. Co-Authored-By: Claude Sonnet 4.6 --- pnpm-workspace.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pnpm-workspace.yaml diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..8fb313f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@biomejs/biome': true + sqlite3: true