From b3738de684ec02a26b7dae105f18d25011bb2259 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:51:35 +0000 Subject: [PATCH 01/22] Add typed command framework; migrate selfroles and paginator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces src/framework as the pilot for a sound replacement of the CommandModule shape, and converts the first two consumers. Why the old shape was unsafe: execute: (i: T) => โ€ฆ The generic sits on the method, so the *caller* chooses T. That signature accepts any interaction, and every implementation that narrowed to ChatInputCommandInteraction<"cached"> was unchecked. Meanwhile all five optional handlers were required, which is why botadmin carries an empty `async modal() {}`. What replaces it: - registry.ts defineCommand/defineComponent/defineEvent. A handler's interaction type is fixed by the definition, not the caller. `guildOnly: true` is a claim the dispatcher enforces before calling, which is what earns the <"cached"> narrowing. - ids.ts Typed custom IDs. `customId.split("-")[0]` was an untyped contract between the code minting an ID and the code reading it. Params are now encoded/decoded through codecs; OneOf gives a literal union so a switch can be exhaustive. Enforces Discord's 100-char cap, and a stale ID from before a deploy decodes to a friendly "run the command again" instead of a crash. - session.ts The other half: ephemeral UI owned by one invocation. The ownership check, timeout and disable-on-end are written once. - dispatch.ts One place where a raw Interaction becomes a typed call. All remaining casts live here, each on the line after the runtime check that justifies it. - loader.ts Validates modules instead of casting the dynamic import, so a malformed module fails at boot rather than on first use. Registered components and sessions share one custom-ID space: session IDs carry a `~` prefix the dispatcher skips, and an unknown namespace is ignored rather than treated as an error. Migrated: - selfroles: three defineComponents replace the hand-rolled second routing layer (customId.split("-")[1]) inside button(). Fixes a crash where setMaxValues(assignable.length) was called with 0 on a server that had no configured roles. - paginator: rebuilt on runSession, 116 lines -> 71. Fixes the button interaction being captured before the user check (any user could redirect someone else's paginator), and the final edit dropping IsComponentsV2. Unmigrated commands are unaffected: the dispatcher keeps a legacy path that routes them the old way, to be deleted when the last one is converted. deploy-commands now shares the loader, so what is deployed is exactly what is registered. Incidentally fixed by the validating loader: events/joinleave.ts exports an array of two listeners, which the old loader read `.name` off of, producing client.on(undefined, โ€ฆ). Join/leave logging had never fired. The loader now accepts arrays; the bot registers 11 listeners where it previously registered 9 working ones and 1 dead. Verified: tsc reports the same 9 pre-existing errors as before this change (4 in djsx/, 5 unused symbols in about.ts) and none in the new or modified files. Dispatcher routing, ID round-tripping, the session state machine and paginator navigation were exercised against stub interactions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- scripts/deploy-commands.ts | 38 +++---- src/commands/addons.ts | 10 +- src/commands/selfroles.ts | 183 +++++++++++++++++++------------ src/events/interaction.ts | 81 ++------------ src/framework/README.md | 109 +++++++++++++++++++ src/framework/dispatch.ts | 216 +++++++++++++++++++++++++++++++++++++ src/framework/ids.ts | 105 ++++++++++++++++++ src/framework/index.ts | 6 ++ src/framework/loader.ts | 134 +++++++++++++++++++++++ src/framework/registry.ts | 101 +++++++++++++++++ src/framework/session.ts | 122 +++++++++++++++++++++ src/framework/ui.ts | 33 ++++++ src/index.ts | 54 +++++----- src/paginator.ts | 170 +++++++++++------------------ src/types/base.ts | 21 +--- src/util/stats.ts | 16 +++ 16 files changed, 1072 insertions(+), 327 deletions(-) create mode 100644 src/framework/README.md create mode 100644 src/framework/dispatch.ts create mode 100644 src/framework/ids.ts create mode 100644 src/framework/index.ts create mode 100644 src/framework/loader.ts create mode 100644 src/framework/registry.ts create mode 100644 src/framework/session.ts create mode 100644 src/framework/ui.ts create mode 100644 src/util/stats.ts diff --git a/scripts/deploy-commands.ts b/scripts/deploy-commands.ts index fc0d70c..1bd4331 100644 --- a/scripts/deploy-commands.ts +++ b/scripts/deploy-commands.ts @@ -1,9 +1,8 @@ -import fs from "node:fs"; import path from "node:path"; -import {fileURLToPath, pathToFileURL} from "node:url"; +import {fileURLToPath} from "node:url"; import {REST, type RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; import {API} from "@discordjs/core"; -import type {CommandModule} from "../src/types"; +import {loadCommands} from "../src/framework"; import "dotenv/config"; @@ -48,32 +47,21 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman } if (!shouldClear) { - const commands = []; - const ownerCommands = []; - const commandsPath = path.join(__dirname, "..", "src", "commands"); - const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith(".ts") || file.endsWith(".tsx")); - - for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - const commandModule = await import(pathToFileURL(filePath).href) as CommandModule | {default: CommandModule;}; - const command = ("default" in commandModule) ? commandModule.default : commandModule; - - if (!command.data) { - console.warn(`โš ๏ธ Command ${file} has no data property, skipping...`); - continue; - } - - const commandData = "toJSON" in command.data ? command.data.toJSON() : command.data; + const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + const ownerCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + // Shared with the bot's own startup path, so what gets deployed is exactly + // what gets registered. Throws on a malformed module instead of skipping it. + for (const command of await loadCommands(path.join(__dirname, "..", "src", "commands"))) { // Separate owner commands to "privileged" guild - if (command.owner) { - ownerCommands.push(commandData); - console.log(`๐Ÿ”’ Owner command: ${commandData.name}`); + if (command.ownerOnly) { + ownerCommands.push(command.data); + console.log(`๐Ÿ”’ Owner command: ${command.name}`); } else { - commands.push(commandData); - console.log(`๐ŸŒ Global command: ${commandData.name}`); - if (commandData.integration_types?.includes(1)) console.log(` ๐Ÿ“ฑ User-installable`); + commands.push(command.data); + console.log(`๐ŸŒ Global command: ${command.name}${command.migrated ? "" : " (legacy module)"}`); + if (command.data.integration_types?.includes(1)) console.log(` ๐Ÿ“ฑ User-installable`); } } diff --git a/src/commands/addons.ts b/src/commands/addons.ts index 7ed3bd1..16ee6bc 100644 --- a/src/commands/addons.ts +++ b/src/commands/addons.ts @@ -3,7 +3,7 @@ import Messages from "../util/messages"; import type {BdWebAddon, BdWebTag} from "../types"; import Similarity from "string-similarity"; import Web from "../util/web"; -import Paginator from "../paginator"; +import {paginate} from "../paginator"; import {cache, ensureCache, createAddonComponent, paginateAddonPages, sortAddons, createAddonList} from "../util/addons"; @@ -90,14 +90,12 @@ export default { if (tag) title.push(`with tag \`${tag}\``); title.push(`sorted by ${sort.replace(/_/g, " ")}`); - const paginator = new Paginator({ + await paginate({ interaction, items: filteredAddons, - itemsPerPage: 3, - renderPage: addons => createAddonList(`${title.join(" ")}`, addons), + perPage: 3, + renderPage: addons => createAddonList(title.join(" "), addons), }); - - await paginator.paginate(); }, async search(interaction: ChatInputCommandInteraction<"cached">) { diff --git a/src/commands/selfroles.ts b/src/commands/selfroles.ts index 6d005ec..c7e7139 100644 --- a/src/commands/selfroles.ts +++ b/src/commands/selfroles.ts @@ -1,96 +1,143 @@ -import {ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, EmbedBuilder, MessageComponentInteraction, PermissionFlagsBits, RoleSelectMenuBuilder, RoleSelectMenuInteraction, SlashCommandBuilder, StringSelectMenuBuilder, StringSelectMenuInteraction, StringSelectMenuOptionBuilder} from "discord.js"; +import { + ApplicationCommandType, ButtonStyle, ComponentType, EmbedBuilder, InteractionContextType, + MessageFlags, PermissionFlagsBits, SelectMenuDefaultValueType, + type InteractionReplyOptions, type InteractionUpdateOptions, type MessageActionRowComponentData +} from "discord.js"; +import {defineCommand, defineComponent, OneOf, row} from "../framework"; import {selfrolesDB} from "../db"; import Messages from "../util/messages"; import Colors from "../util/colors"; +const RETURN_TO_PANEL_DELAY = 3000; +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -export default { - data: new SlashCommandBuilder() - .setName("selfroles") - .setDescription("Allows users to self-assign roles.") - .setDMPermission(false), +/** The listing plus its controls. Shared by the command and every component. */ +function panel(roleIds: string[], canManage: boolean): InteractionReplyOptions & InteractionUpdateOptions { + const listing = new EmbedBuilder().setColor(Colors.Info).setTitle("Available Roles") + .setDescription(roleIds.length ? roleIds.map(id => `- <@&${id}>`).join("\n") : "No roles have been configured by the admins."); - async execute(interaction: MessageComponentInteraction<"cached">) { - const selfroles = await selfrolesDB.get(interaction.guild.id) ?? []; - const listingEmbed = new EmbedBuilder().setColor(Colors.Info).setTitle("Available Roles") - .setDescription(selfroles.length ? selfroles.map((r: string) => `- <@&${r}>`).join("\n") : "No roles have been configured by the admins."); + const controls: MessageActionRowComponentData[] = [ + {type: ComponentType.Button, customId: openPicker.customId({mode: "user"}), label: "Manage Your Roles", style: ButtonStyle.Success} + ]; + if (canManage) { + controls.push({type: ComponentType.Button, customId: openPicker.customId({mode: "admin"}), label: "Set Assignable Roles", style: ButtonStyle.Primary}); + } - const controls = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId("selfroles-user").setLabel("Manage Your Roles").setStyle(ButtonStyle.Success) - ); - const member = interaction.guild.members.cache.get(interaction.user.id)!; - if (member.permissions.has(PermissionFlagsBits.ManageRoles)) { - controls.addComponents( - new ButtonBuilder().setCustomId("selfroles-admin").setLabel("Set Assignable Roles").setStyle(ButtonStyle.Primary) - ); - } - - if (!interaction.replied) return await interaction.reply({embeds: [listingEmbed], components: [controls], ephemeral: true}); - await interaction.editReply({embeds: [listingEmbed], components: [controls]}); - }, + return {embeds: [listing], components: [row(...controls)]}; +} - async button(interaction: ButtonInteraction<"cached">) { - const id = interaction.customId.split("-")[1]; - if (id === "user") return await this.buttonUser(interaction); - if (id === "admin") return await this.buttonAdmin(interaction); - }, - +/** + * One definition for both buttons. `mode` is a typed param, so the switch below + * is exhaustive by construction and `customId({mode: "usr"})` will not compile. + * This replaces the old `customId.split("-")[1]` dispatch inside `button()`. + */ +const openPicker = defineComponent({ + id: "selfroles.open", + kind: "button", + guildOnly: true, + params: {mode: OneOf("user", "admin")}, - async buttonUser(interaction: ButtonInteraction<"cached">) { - const member = interaction.guild.members.cache.get(interaction.user.id)!; + async run(interaction, {mode}) { const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; - const controls = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder().setCustomId("selfroles") - .setMinValues(0) - .setMaxValues(assignable.length) - .setOptions(assignable.map( - (roleId: string) => new StringSelectMenuOptionBuilder() - .setLabel(interaction.guild.roles.cache.get(roleId)!.name) - .setValue(roleId) - .setDefault(member.roles.cache.has(roleId)) - )) - ); - - await interaction.update(Messages.info("Please select which roles you want.", {components: [controls]})); - }, + if (mode === "admin") { + if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles)) { + return await interaction.reply(Messages.error("You need the `Manage Roles` permission to do that.", {ephemeral: true})); + } + + return await interaction.update(Messages.info("Please select which roles should be self-assignable.", { + components: [row({ + type: ComponentType.RoleSelect, + customId: setAssignable.customId({}), + minValues: 0, + maxValues: 25, + defaultValues: assignable.map(id => ({id, type: SelectMenuDefaultValueType.Role})) + })] + })); + } + + // The previous version called setMaxValues(0) here, which Discord rejects, + // so the first press on a server with no configured roles always failed. + if (!assignable.length) { + return await interaction.reply(Messages.info("No self-assignable roles have been set up yet.", {ephemeral: true})); + } - async select(interaction: StringSelectMenuInteraction<"cached">) { - const member = interaction.guild.members.cache.get(interaction.user.id)!; + return await interaction.update(Messages.info("Please select which roles you want.", { + components: [row({ + type: ComponentType.StringSelect, + customId: chooseRoles.customId({}), + minValues: 0, + maxValues: assignable.length, + options: assignable.map(id => ({ + label: interaction.guild.roles.cache.get(id)?.name ?? id, + value: id, + default: interaction.member.roles.cache.has(id) + })) + })] + })); + } +}); + + +const chooseRoles = defineComponent({ + id: "selfroles.choose", + kind: "stringSelect", + guildOnly: true, + params: {}, + + async run(interaction) { const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; + try { - if (assignable.length) await member.roles.remove(assignable); - if (interaction.values.length) await member.roles.add(interaction.values); + const toRemove = assignable.filter(id => !interaction.values.includes(id)); + if (toRemove.length) await interaction.member.roles.remove(toRemove, "Self-roles"); + if (interaction.values.length) await interaction.member.roles.add(interaction.values, "Self-roles"); await interaction.update(Messages.success("Successfully assigned your roles!", {components: []})); } catch { - await interaction.update(Messages.error("Could not assign your roles. It may be a permission issue.")); + await interaction.update(Messages.error("Could not assign your roles. It may be a permission issue.", {components: []})); } - // Restart the flow - await new Promise(r => setTimeout(r, 3000)); - await this.execute(interaction); - }, + await wait(RETURN_TO_PANEL_DELAY); + await interaction.editReply(panel(assignable, interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles))); + } +}); - async buttonAdmin(interaction: ButtonInteraction<"cached">) { - const defaultRoles = await selfrolesDB.get(interaction.guild.id) ?? []; - const controls = new ActionRowBuilder().addComponents( - new RoleSelectMenuBuilder().setCustomId("selfroles").setMaxValues(25).setDefaultRoles(defaultRoles) - ); - await interaction.update(Messages.info("Please select which roles should be self-assignable.", {components: [controls]})); - }, +const setAssignable = defineComponent({ + id: "selfroles.set", + kind: "roleSelect", + guildOnly: true, + params: {}, - - async role(interaction: RoleSelectMenuInteraction<"cached">) { - await selfrolesDB.set(interaction.guild.id, [...interaction.roles.keys()]); + async run(interaction) { + const roleIds = [...interaction.roles.keys()]; + await selfrolesDB.set(interaction.guild.id, roleIds); await interaction.update(Messages.success("Self-assignable roles set successfully.", {components: []})); - // Restart the flow - await new Promise(r => setTimeout(r, 3000)); - await this.execute(interaction); + await wait(RETURN_TO_PANEL_DELAY); + await interaction.editReply(panel(roleIds, true)); + } +}); + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "selfroles", + description: "Allows users to self-assign roles.", + contexts: [InteractionContextType.Guild] }, -}; + + async execute(interaction) { + const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; + const canManage = interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles); + return await interaction.reply({...panel(assignable, canManage), flags: MessageFlags.Ephemeral}); + } +}); + +export const components = [openPicker, chooseRoles, setAssignable]; diff --git a/src/events/interaction.ts b/src/events/interaction.ts index 880bae3..b6893da 100644 --- a/src/events/interaction.ts +++ b/src/events/interaction.ts @@ -1,74 +1,15 @@ -import {MessageFlags, type ChatInputCommandInteraction, type Interaction} from "discord.js"; -import type {CommandStats} from "../types"; -import {statsDB} from "../db"; +import {Events} from "discord.js"; +import {defineEvent} from "../framework"; -export default { - name: "interactionCreate", +/** + * Routing lives in the dispatcher (`src/framework/dispatch.ts`), which is built + * once at startup so it can validate every command and component up front. + */ +export default defineEvent({ + name: Events.InteractionCreate, - async execute(interaction: Interaction) { - let commandName = ""; - let executor: "execute" | "autocomplete" | "button" | "modal" | "role" | "select" = "execute"; - - if (interaction.isChatInputCommand()) { - commandName = interaction.commandName; - executor = "execute"; - await this.addStat(interaction); - } - else if (interaction.isAutocomplete()) { - commandName = interaction.commandName; - executor = "autocomplete"; - } - else if (interaction.isButton()) { - executor = "button"; - commandName = interaction.customId.split("-")[0]; - } - else if (interaction.isModalSubmit()) { - executor = "modal"; - commandName = interaction.customId.split("-")[0]; - } - else if (interaction.isStringSelectMenu()) { - executor = "select"; - commandName = interaction.customId.split("-")[0]; - } - else if (interaction.isRoleSelectMenu()) { - executor = "role"; - commandName = interaction.customId.split("-")[0]; - } - - const command = interaction.client.commands.get(commandName); - if (!commandName || !command || !command[executor]) { - if (interaction.isChatInputCommand() && interaction.isRepliable()) { - console.error("Unrecognized interaction", commandName, executor); - await interaction.reply({content: "Something went wrong! If this persists, please report it to the bot owner!", flags: MessageFlags.Ephemeral}); - } - // TODO: maybe add a pino logger here - return; - } - - try { - await command[executor](interaction); - } - catch (error) { - console.error(error); - if (interaction.isRepliable()) await interaction.reply({content: "There was an error while executing this command!", flags: MessageFlags.Ephemeral}); - } - }, - - async addStat(interaction: ChatInputCommandInteraction) { - const key = interaction.guildId ?? interaction.client.user?.id; - const name = interaction.commandName; - - // More type-safe approach - const existingData = await statsDB.get(key) as CommandStats | undefined; - const data: CommandStats = existingData ?? {commands: {}}; - - // Ensure commands object exists - data.commands ??= {}; - - // Increment command count - data.commands[name] = (data.commands[name] ?? 0) + 1; - - await statsDB.set(key, data); + async execute(interaction) { + await interaction.client.dispatcher.dispatch(interaction); } -}; \ No newline at end of file +}); diff --git a/src/framework/README.md b/src/framework/README.md new file mode 100644 index 0000000..036c05d --- /dev/null +++ b/src/framework/README.md @@ -0,0 +1,109 @@ +# framework + +Command, component and event plumbing. Two mechanisms, chosen by **lifetime**. + +## Which one do I want? + +> Does this UI need to work after a bot restart, or more than 15 minutes after it +> was sent? + +**Yes โ†’ a registered component.** State cannot live in a closure, so it goes in +the custom id (and the database). Handled by the global dispatcher. + +**No โ†’ a session.** The UI belongs to one invocation by one user and dies with +the interaction token. State lives in a closure. Handled by its own collector. + +Using the wrong one is the usual source of "this button stopped working after a +deploy" (a session that should have been a component) and of duplicated +ownership/timeout logic (a component that should have been a session). + +## Commands + +```ts +export const command = defineCommand({ + guildOnly: true, // dispatcher checks inCachedGuild() for you + data: {type: ApplicationCommandType.ChatInput, name: "โ€ฆ", description: "โ€ฆ"}, + async execute(interaction) { // ChatInputCommandInteraction<"cached"> + โ€ฆ + } +}); + +export const components = [/* defineComponent(...) results */]; +``` + +`guildOnly` is a claim the dispatcher enforces *before* calling you, which is +what earns the `<"cached">` narrowing. Omit it and `interaction.guild` is +`null`-checked, as it should be. + +`ownerOnly: true` restricts to `BOT_OWNER_ID` and routes the command to the +private guild at deploy time. + +## Components (durable) + +```ts +const picker = defineComponent({ + id: "selfroles.open", // unique namespace, prefix of every id it mints + kind: "button", // fixes the interaction type + guildOnly: true, + params: {mode: OneOf("user", "admin")}, + + async run(interaction, {mode}) { // ButtonInteraction<"cached">, mode: "user" | "admin" + โ€ฆ + } +}); + +// emitting โ€” checked in both directions +{type: ComponentType.Button, customId: picker.customId({mode: "admin"}), โ€ฆ} +``` + +`OneOf` yields a literal union, so a `switch` over the param can be exhaustive. +A missing param, a wrong type, or a typo in a literal is a compile error. + +Custom ids are capped at Discord's 100 characters; `customId()` throws if you +exceed it rather than letting the API reject the message. A stale id from before +a deploy fails to decode and the user is told to re-run the command. + +## Sessions (ephemeral) + +```ts +await runSession({ + interaction, + initial: 1, + render: (page, {ended}) => ({โ€ฆ}), // pure: state in, message out + reduce: (action, page) => action === "next" ? page + 1 : undefined, +}); +``` + +Controls use `sessionId("next")`, which carries a `~` prefix. The dispatcher +ignores those, so sessions and registered components share one custom-id space +without colliding. + +The ownership check, the timeout and disabling the controls when the collector +ends all happen inside `runSession` โ€” do not reimplement them per command. +`audience: "anyone"` opts out of the ownership check. + +`awaitModal(interaction, modal, ["title", "content"])` is the one-shot version: +it returns `{submission, values}`, or `null` on timeout. + +## Events + +```ts +export default defineEvent({name: Events.MessageCreate, async execute(message) {โ€ฆ}}); +export default defineEvents( // a file may register several + {name: Events.GuildMemberAdd, async execute(member) {โ€ฆ}}, + {name: Events.GuildMemberRemove, async execute(member) {โ€ฆ}}, +); +``` + +## Migrating a command + +1. Replace the `SlashCommandBuilder` chain with a plain + `RESTPostAPIChatInputApplicationCommandsJSONBody` object. +2. `export const command = defineCommand({โ€ฆ})` instead of `export default {โ€ฆ}`. +3. Move each `button` / `modal` / `select` / `role` handler to its own + `defineComponent`, and export them as `components`. +4. Replace `customId.split("-")[n]` with typed `params`. + +Unmigrated commands keep working โ€” the dispatcher has a legacy path that routes +them the old way. Delete `LegacyEntry` and friends from `dispatch.ts` once the +last command is converted. diff --git a/src/framework/dispatch.ts b/src/framework/dispatch.ts new file mode 100644 index 0000000..233ddba --- /dev/null +++ b/src/framework/dispatch.ts @@ -0,0 +1,216 @@ +/** + * The one place a raw Interaction becomes a typed handler call. + * + * Every unsafe narrowing in the app lives here, each on the line after the + * runtime check that justifies it. That is the point: not zero unsafety, but + * unsafety that is located, guarded and auditable. + * + * The `legacy` half supports command modules that have not been migrated to + * `defineCommand` yet, and should be deleted once they all have. + */ + +import { + type ChatInputCommandInteraction, type Interaction, MessageFlags, type RepliableInteraction +} from "discord.js"; +import {IdError, namespaceOf} from "./ids"; +import type {Command, Component, ComponentKind} from "./registry"; +import {isSessionId} from "./session"; + + +type CommandHandler = (interaction: never) => Promise; +type ComponentHandler = (interaction: never, params: never) => Promise; + +const KIND_GUARD: {[K in ComponentKind]: (interaction: Interaction) => boolean} = { + button: interaction => interaction.isButton(), + stringSelect: interaction => interaction.isStringSelectMenu(), + roleSelect: interaction => interaction.isRoleSelectMenu(), + userSelect: interaction => interaction.isUserSelectMenu(), + channelSelect: interaction => interaction.isChannelSelectMenu(), + mentionableSelect: interaction => interaction.isMentionableSelectMenu(), + modal: interaction => interaction.isModalSubmit() +}; + + +/** @deprecated Shape of a not-yet-migrated command module. */ +export type LegacyKind = "execute" | "autocomplete" | "button" | "modal" | "select" | "role"; + +/** @deprecated Remove once every command uses `defineCommand`. */ +export interface LegacyEntry { + name: string; + ownerOnly: boolean; + handlers: Partial>; +} + + +export interface DispatcherOptions { + ownerId: string; + /** Called before a chat-input command runs. Used for command stats. */ + onCommandRun?(interaction: ChatInputCommandInteraction): Promise; +} + + +export class Dispatcher { + private commands = new Map(); + private components = new Map(); + private legacy = new Map(); + private options: DispatcherOptions; + + constructor(options: DispatcherOptions) { + this.options = options; + } + + addCommand(command: Command): void { + const name = command.data.name; + if (this.commands.has(name) || this.legacy.has(name)) throw new Error(`duplicate command "${name}"`); + this.commands.set(name, command); + } + + addComponent(component: Component): void { + if (this.components.has(component.id)) throw new Error(`duplicate component namespace "${component.id}"`); + this.components.set(component.id, component); + } + + /** @deprecated */ + addLegacyCommand(entry: LegacyEntry): void { + if (this.commands.has(entry.name) || this.legacy.has(entry.name)) throw new Error(`duplicate command "${entry.name}"`); + this.legacy.set(entry.name, entry); + } + + get counts(): {commands: number; legacy: number; components: number;} { + return {commands: this.commands.size, legacy: this.legacy.size, components: this.components.size}; + } + + + async dispatch(interaction: Interaction): Promise { + try { + if (interaction.isChatInputCommand()) return await this.runCommand(interaction); + if (interaction.isAutocomplete()) return await this.runAutocomplete(interaction); + if (interaction.isMessageComponent() || interaction.isModalSubmit()) return await this.runComponent(interaction); + } + catch (error) { + await this.reportFailure(interaction, error); + } + } + + + private async runCommand(interaction: ChatInputCommandInteraction): Promise { + const command = this.commands.get(interaction.commandName); + const legacy = this.legacy.get(interaction.commandName); + if (!command && !legacy) { + console.error("unregistered command", interaction.commandName); + return await this.reply(interaction, "That command isn't registered any more."); + } + + await this.options.onCommandRun?.(interaction); + + if (legacy) { + if (legacy.ownerOnly && interaction.user.id !== this.options.ownerId) return await this.reply(interaction, "That command is owner-only."); + return void await legacy.handlers.execute?.(interaction as never); + } + + if (!this.permitted(command!, interaction)) return await this.reply(interaction, "You can't use that command here."); + + // Guarded above: `guildOnly` was checked, so the `<"cached">` the handler + // declares is actually true by this point. + await (command!.execute as CommandHandler)(interaction as never); + } + + + private async runAutocomplete(interaction: Interaction): Promise { + if (!interaction.isAutocomplete()) return; + + const legacy = this.legacy.get(interaction.commandName); + if (legacy) return void await legacy.handlers.autocomplete?.(interaction as never); + + const command = this.commands.get(interaction.commandName); + if (!command?.autocomplete) return await interaction.respond([]); + if (command.guildOnly && !interaction.inCachedGuild()) return await interaction.respond([]); + + await (command.autocomplete as CommandHandler)(interaction as never); + } + + + private async runComponent(interaction: Interaction): Promise { + if (!interaction.isMessageComponent() && !interaction.isModalSubmit()) return; + + // Session-owned. Its own collector handles it; this is the contract that + // lets registered components and sessions share one custom-id space. + if (isSessionId(interaction.customId)) return; + + const component = this.components.get(namespaceOf(interaction.customId)); + if (!component) return await this.runLegacyComponent(interaction); + + if (!KIND_GUARD[component.kind](interaction)) { + console.warn(`component "${component.id}" is registered as ${component.kind} but received a ${interaction.isModalSubmit() ? "modal submit" : "component"} interaction`); + return; + } + if (!this.permitted(component, interaction)) return await this.reply(interaction, "You can't use that."); + + let params; + try { + params = component.decode(interaction.customId); + } + catch (error) { + // Almost always a message from before the last deploy. + if (error instanceof IdError) { + console.warn("stale custom id", interaction.customId, error.message); + return await this.reply(interaction, "This message is out of date. Please run the command again."); + } + throw error; + } + + await (component.run as ComponentHandler)(interaction as never, params as never); + } + + + /** @deprecated Routing by `customId.split("-")[0]`, kept for unmigrated commands. */ + private async runLegacyComponent(interaction: Interaction): Promise { + if (!interaction.isMessageComponent() && !interaction.isModalSubmit()) return; + + const entry = this.legacy.get(interaction.customId.split("-")[0]); + if (!entry) return; + + let kind: LegacyKind | undefined; + if (interaction.isButton()) kind = "button"; + else if (interaction.isModalSubmit()) kind = "modal"; + else if (interaction.isStringSelectMenu()) kind = "select"; + else if (interaction.isRoleSelectMenu()) kind = "role"; + if (!kind) return; + + if (entry.ownerOnly && interaction.user.id !== this.options.ownerId) return; + await entry.handlers[kind]?.(interaction as never); + } + + + private permitted(definition: {guildOnly?: boolean; ownerOnly?: boolean;}, interaction: Interaction): boolean { + if (definition.guildOnly && !interaction.inCachedGuild()) return false; + if (definition.ownerOnly && interaction.user.id !== this.options.ownerId) return false; + return true; + } + + + private async reply(interaction: Interaction, content: string): Promise { + if (!interaction.isRepliable()) return; + await this.send(interaction, content); + } + + + private async send(interaction: RepliableInteraction, content: string): Promise { + const payload = {content, flags: MessageFlags.Ephemeral} as const; + if (interaction.deferred || interaction.replied) await interaction.followUp(payload); + else await interaction.reply(payload); + } + + + private async reportFailure(interaction: Interaction, error: unknown): Promise { + console.error(error); + if (!interaction.isRepliable()) return; + try { + await this.send(interaction, "Something went wrong running that. It has been logged."); + } + catch (replyError) { + // The token may already be dead. The reporter must never throw. + console.error("could not report failure to user", replyError); + } + } +} diff --git a/src/framework/ids.ts b/src/framework/ids.ts new file mode 100644 index 0000000..cf383e5 --- /dev/null +++ b/src/framework/ids.ts @@ -0,0 +1,105 @@ +/** + * Typed custom IDs. + * + * Discord gives us one 100-character string to carry state from a component back + * to its handler. Parsing that string by hand means the code that mints the ID + * and the code that reads it have no contract. This makes it a typed one. + */ + +export interface ParamCodec { + parse(raw: string): T; + format(value: T): string; +} + +const SEP = ":"; + +/** Escaped so string params may contain the separator. */ +const escape = (value: string) => value.replace(/%/g, "%25").replace(/:/g, "%3A"); +const unescape = (value: string) => value.replace(/%3A/g, ":").replace(/%25/g, "%"); + +export class IdError extends Error {} + +export const Str: ParamCodec = { + parse: unescape, + format: escape +}; + +export const Num: ParamCodec = { + parse(raw) { + const value = Number(raw); + if (!Number.isFinite(value)) throw new IdError(`expected a number, got ${JSON.stringify(raw)}`); + return value; + }, + format: value => String(value) +}; + +export const Bool: ParamCodec = { + parse: raw => raw === "1", + format: value => value ? "1" : "0" +}; + +/** A snowflake, validated on the way out and on the way back in. */ +export const Id: ParamCodec = { + parse(raw) { + if (!/^\d{15,25}$/.test(raw)) throw new IdError(`expected a snowflake, got ${JSON.stringify(raw)}`); + return raw; + }, + format(value) { + if (!/^\d{15,25}$/.test(value)) throw new IdError(`${JSON.stringify(value)} is not a snowflake`); + return value; + } +}; + +/** Produces a literal union, so a switch over the param can be exhaustive. */ +export function OneOf(...allowed: T): ParamCodec { + return { + parse(raw) { + if (!allowed.includes(raw)) throw new IdError(`expected one of ${allowed.join("|")}, got ${JSON.stringify(raw)}`); + return raw as T[number]; + }, + format: value => value + }; +} + +/** + * `ParamCodec` is invariant in T (it both produces and consumes a T), so no + * single `ParamCodec` is a supertype of all codecs. This is the supertype: + * produces `unknown`, consumes `never`. Constraint position only โ€” at each + * definition site the concrete codec types are still inferred. + */ +export interface AnyParamCodec { + parse(raw: string): unknown; + format(value: never): string; +} + +export type ParamSpec = Record; +export type Params = {[K in keyof S]: S[K] extends ParamCodec ? T : never}; + +/** Discord's hard limit on custom_id. Better to fail here than at send time. */ +export const MAX_CUSTOM_ID = 100; + +export function encodeId(namespace: string, spec: S, params: Params): string { + const values = params as Record; + const parts = [namespace]; + for (const key of Object.keys(spec)) parts.push(spec[key].format(values[key])); + + const id = parts.join(SEP); + if (id.length > MAX_CUSTOM_ID) { + throw new IdError(`custom id for "${namespace}" is ${id.length} chars (max ${MAX_CUSTOM_ID}). Store the payload and reference it by key instead.`); + } + return id; +} + +export function decodeId(spec: S, raw: string): Params { + const [, ...values] = raw.split(SEP); + const keys = Object.keys(spec); + if (values.length !== keys.length) { + throw new IdError(`expected ${keys.length} params, got ${values.length} in ${JSON.stringify(raw)}`); + } + + const parsed: Record = {}; + keys.forEach((key, index) => {parsed[key] = spec[key].parse(values[index]);}); + return parsed as Params; +} + +export const namespaceOf = (raw: string): string => raw.split(SEP, 1)[0]; diff --git a/src/framework/index.ts b/src/framework/index.ts new file mode 100644 index 0000000..a7604ef --- /dev/null +++ b/src/framework/index.ts @@ -0,0 +1,6 @@ +export * from "./ids"; +export * from "./registry"; +export * from "./session"; +export * from "./ui"; +export {Dispatcher} from "./dispatch"; +export {loadCommands, loadEvents} from "./loader"; diff --git a/src/framework/loader.ts b/src/framework/loader.ts new file mode 100644 index 0000000..b5fa250 --- /dev/null +++ b/src/framework/loader.ts @@ -0,0 +1,134 @@ +/** + * Module loading, with validation. + * + * The old loader cast the result of a dynamic `import()` straight to + * `CommandModule`, so a malformed module became a runtime mystery instead of a + * startup error. `events/joinleave.ts` exports an array of two listeners, which + * that cast turned into `client.on(undefined, โ€ฆ)` โ€” a feature that has never + * fired. Everything here is checked, and a bad module fails loudly at boot. + */ + +import fs from "node:fs"; +import path from "node:path"; +import {pathToFileURL} from "node:url"; +import type {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; +import type {Dispatcher, LegacyEntry, LegacyKind} from "./dispatch"; +import type {Command, Component, EventDef} from "./registry"; + + +const LEGACY_KINDS: LegacyKind[] = ["execute", "autocomplete", "button", "modal", "select", "role"]; + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const isFn = (value: unknown): value is (...args: never[]) => Promise => typeof value === "function"; + + +export interface LoadedCommand { + name: string; + /** Ready to send to the API, whichever style the module was written in. */ + data: RESTPostAPIChatInputApplicationCommandsJSONBody; + ownerOnly: boolean; + migrated: boolean; + register(dispatcher: Dispatcher): void; +} + + +function sourceFiles(directory: string): string[] { + return fs.readdirSync(directory) + .filter(file => file.endsWith(".ts") || file.endsWith(".tsx")) + .map(file => path.join(directory, file)); +} + +async function importModule(file: string): Promise> { + const imported: unknown = await import(pathToFileURL(file).href); + if (!isRecord(imported)) throw new Error(`${path.basename(file)} did not export a module object`); + return imported; +} + + +/** Reads `data` off either style, normalising a builder to plain JSON. */ +function commandData(source: unknown, file: string): RESTPostAPIChatInputApplicationCommandsJSONBody { + if (!isRecord(source)) throw new Error(`${path.basename(file)}: command has no data`); + + const data: unknown = "toJSON" in source && typeof source.toJSON === "function" + ? (source as {toJSON(): unknown;}).toJSON() + : source; + + if (!isRecord(data) || typeof data.name !== "string") { + throw new Error(`${path.basename(file)}: command data has no name`); + } + return data as unknown as RESTPostAPIChatInputApplicationCommandsJSONBody; +} + + +export async function loadCommands(directory: string): Promise { + const loaded: LoadedCommand[] = []; + + for (const file of sourceFiles(directory)) { + const module = await importModule(file); + + // Migrated: `export const command = defineCommand(...)`, plus an optional + // `export const components = [...]`. + if (isRecord(module.command)) { + const command = module.command as unknown as Command; + if (!isFn(command.execute)) throw new Error(`${path.basename(file)}: exported command has no execute()`); + + const components = Array.isArray(module.components) ? module.components as Component[] : []; + const data = commandData(command.data, file); + + loaded.push({ + name: data.name, + data, + ownerOnly: command.ownerOnly === true, + migrated: true, + register(dispatcher) { + dispatcher.addCommand(command); + for (const component of components) dispatcher.addComponent(component); + } + }); + continue; + } + + // Not yet migrated: `export default {data, execute, button, ...}`. + const legacyModule = isRecord(module.default) ? module.default : module; + if (!isFn(legacyModule.execute)) throw new Error(`${path.basename(file)}: no exported command (expected \`export const command\` or a default export with execute())`); + + const data = commandData(legacyModule.data, file); + const handlers: LegacyEntry["handlers"] = {}; + for (const kind of LEGACY_KINDS) { + const handler = legacyModule[kind]; + if (isFn(handler)) handlers[kind] = handler.bind(legacyModule) as LegacyEntry["handlers"][LegacyKind]; + } + + const entry: LegacyEntry = {name: data.name, ownerOnly: legacyModule.owner === true, handlers}; + loaded.push({ + name: data.name, + data, + ownerOnly: entry.ownerOnly, + migrated: false, + register(dispatcher) {dispatcher.addLegacyCommand(entry);} + }); + } + + return loaded.sort((a, b) => a.name.localeCompare(b.name)); +} + + +/** Accepts one listener or an array of them from a single file. */ +export async function loadEvents(directory: string): Promise { + const events: EventDef[] = []; + + for (const file of sourceFiles(directory)) { + const module = await importModule(file); + const exported: unknown = module.default ?? module.event ?? module.events; + const candidates: unknown[] = Array.isArray(exported) ? exported : [exported]; + + for (const candidate of candidates) { + if (!isRecord(candidate) || typeof candidate.name !== "string" || !isFn(candidate.execute)) { + throw new Error(`${path.basename(file)}: exported an event without a name and execute()`); + } + events.push(candidate as unknown as EventDef); + } + } + + return events; +} diff --git a/src/framework/registry.ts b/src/framework/registry.ts new file mode 100644 index 0000000..811a628 --- /dev/null +++ b/src/framework/registry.ts @@ -0,0 +1,101 @@ +/** + * Command, component and event definitions. + * + * Two rules this file exists to enforce: + * + * 1. A handler's interaction type is fixed by the definition, not chosen by the + * caller. The old `CommandModule` used ``, + * which puts T under the caller's control and makes every implementation's + * narrowing unchecked. + * 2. `guildOnly` is a claim the dispatcher enforces before calling you, not an + * `as` cast you assert afterwards. + */ + +import type { + AutocompleteInteraction, ButtonInteraction, CacheType, ChannelSelectMenuInteraction, + ChatInputCommandInteraction, ClientEvents, MentionableSelectMenuInteraction, + ModalSubmitInteraction, RESTPostAPIChatInputApplicationCommandsJSONBody, + RoleSelectMenuInteraction, StringSelectMenuInteraction, UserSelectMenuInteraction +} from "discord.js"; +import {decodeId, encodeId, type ParamSpec, type Params} from "./ids"; + + +/** `true` gives handlers `<"cached">` interactions. */ +export type Cache = G extends true ? "cached" : CacheType; + + +/* -------------------------------------------------------------------- commands */ + +export interface Command { + data: RESTPostAPIChatInputApplicationCommandsJSONBody; + /** Dispatcher rejects the interaction unless it is in a cached guild. */ + guildOnly?: G; + /** Dispatcher rejects the interaction unless the user is BOT_OWNER_ID. */ + ownerOnly?: boolean; + execute(interaction: ChatInputCommandInteraction>): Promise; + autocomplete?(interaction: AutocompleteInteraction>): Promise; +} + +export function defineCommand(command: Command): Command { + return command; +} + + +/* ------------------------------------------------------------------ components */ + +export interface ComponentInteractions { + button: ButtonInteraction>; + stringSelect: StringSelectMenuInteraction>; + roleSelect: RoleSelectMenuInteraction>; + userSelect: UserSelectMenuInteraction>; + channelSelect: ChannelSelectMenuInteraction>; + mentionableSelect: MentionableSelectMenuInteraction>; + modal: ModalSubmitInteraction>; +} + +export type ComponentKind = keyof ComponentInteractions; + +export interface ComponentDef { + /** Unique namespace, and the prefix of every custom id it mints. */ + id: string; + kind: K; + params: S; + guildOnly?: G; + ownerOnly?: boolean; + run(interaction: ComponentInteractions[K], params: Params): Promise; +} + +/** A registered definition: a handler and a type-checked id minter. */ +export interface Component extends ComponentDef { + customId(params: Params): string; + decode(raw: string): Params; +} + +export function defineComponent(definition: ComponentDef): Component { + return { + ...definition, + customId: params => encodeId(definition.id, definition.params, params), + decode: raw => decodeId(definition.params, raw) + }; +} + + +/* ---------------------------------------------------------------------- events */ + +export interface EventDef { + name: E; + once?: boolean; + execute(...args: ClientEvents[E]): Promise; +} + +export function defineEvent(event: EventDef): EventDef { + return event; +} + +/** + * For files that register more than one listener. The loader accepts an array + * from any event file, which is what `events/joinleave.ts` already assumed. + */ +export function defineEvents(...events: EventDef[]): EventDef[] { + return events; +} diff --git a/src/framework/session.ts b/src/framework/session.ts new file mode 100644 index 0000000..d931408 --- /dev/null +++ b/src/framework/session.ts @@ -0,0 +1,122 @@ +/** + * Ephemeral, single-invocation UI. + * + * The other half of the interaction story. Registered components (registry.ts) + * are for UI that must survive a restart, so their state lives in the custom id. + * A session is for UI that belongs to one invocation by one user and dies with + * the interaction token, so its state lives in a closure. + * + * Written once so that the ownership check, the timeout, the disable-on-end and + * the error path are identical everywhere. + */ + +import { + MessageFlags, + type AwaitModalSubmitOptions, type InteractionEditReplyOptions, + type MessageComponentInteraction, type ModalComponentData, type ModalSubmitInteraction, + type RepliableInteraction +} from "discord.js"; +import {msInMinute} from "../util/time"; + + +const SESSION_PREFIX = "~"; + +/** + * Session-owned custom ids carry a prefix that can never be a registered + * namespace, so the global dispatcher knows to leave them to this collector. + */ +export const sessionId = (action: string): string => `${SESSION_PREFIX}${action}`; +export const isSessionId = (customId: string): boolean => customId.startsWith(SESSION_PREFIX); +const actionOf = (customId: string): string => customId.slice(SESSION_PREFIX.length); + + +export interface SessionOptions { + interaction: RepliableInteraction; + initial: S; + /** Pure: state in, message out. Called again after every accepted action. */ + render(state: S, options: {ended: boolean;}): InteractionEditReplyOptions; + /** + * Return the next state, or `undefined` to acknowledge without re-rendering. + * `action` is whatever was passed to `sessionId()`. + */ + reduce(action: string, state: S, interaction: MessageComponentInteraction): S | undefined | Promise; + timeout?: number; + /** Who may use the controls. Defaults to whoever ran the command. */ + audience?: "invoker" | "anyone"; +} + + +/** Resolves with the final state once the collector ends. */ +export async function runSession(options: SessionOptions): Promise { + const {interaction, render, reduce, timeout = msInMinute * 2, audience = "invoker"} = options; + let state = options.initial; + + // Always defer/editReply. Ephemerality is decided by how the caller defers, + // which is the only point at which Discord lets it be decided anyway. + if (!interaction.deferred && !interaction.replied) await interaction.deferReply(); + const message = await interaction.editReply(render(state, {ended: false})); + + const collector = message.createMessageComponentCollector({time: timeout}); + + return await new Promise(resolve => { + collector.on("collect", async componentInteraction => { + // The guard comes first. Nothing is captured before it passes. + if (audience === "invoker" && componentInteraction.user.id !== interaction.user.id) { + await componentInteraction.reply({ + content: "This menu belongs to someone else. Run the command yourself to get your own.", + flags: MessageFlags.Ephemeral + }); + return; + } + + try { + const next = await reduce(actionOf(componentInteraction.customId), state, componentInteraction); + if (next === undefined) { + if (!componentInteraction.replied && !componentInteraction.deferred) await componentInteraction.deferUpdate(); + return; + } + state = next; + await componentInteraction.update(render(state, {ended: false})); + } + catch (error) { + console.error("session action failed", error); + collector.stop("error"); + } + }); + + collector.on("end", async () => { + try { + // The single place that disables the controls. + await interaction.editReply(render(state, {ended: true})); + } + catch (error) { + console.error("could not finalise session", error); + } + resolve(state); + }); + }); +} + + +/** + * Show a modal and wait for it, with the fields already pulled out. + * Returns `null` on timeout so it cannot be confused with a real failure. + */ +export async function awaitModal( + interaction: RepliableInteraction, + modal: ModalComponentData, + fields: readonly F[], + options: AwaitModalSubmitOptions = {time: msInMinute * 5} +): Promise<{submission: ModalSubmitInteraction; values: Record;} | null> { + if (!interaction.isChatInputCommand() && !interaction.isMessageComponent()) return null; + await interaction.showModal(modal); + + try { + const submission = await interaction.awaitModalSubmit(options); + const values = Object.fromEntries(fields.map(field => [field, submission.fields.getTextInputValue(field)])) as Record; + return {submission, values}; + } + catch { + return null; + } +} diff --git a/src/framework/ui.ts b/src/framework/ui.ts new file mode 100644 index 0000000..1f65235 --- /dev/null +++ b/src/framework/ui.ts @@ -0,0 +1,33 @@ +/** + * Annotation helpers for plain component data. + * + * When an array mixes component shapes, TypeScript infers a union of object + * literals, fails to match a branch of discord.js's `components` union, and + * falls through to the snake_case API branch with an unreadable error. Pinning + * the element type fixes it. + * + * These are annotations, not casts. Everything inside stays checked. + */ + +import { + ComponentType, + type ActionRowData, type ComponentInContainerData, type ContainerComponentData, + type MessageActionRowComponentData +} from "discord.js"; + + +export const row = (...components: MessageActionRowComponentData[]): ActionRowData => ({ + type: ComponentType.ActionRow, + components +}); + +export const container = (components: ComponentInContainerData[], options: Omit = {}): ContainerComponentData => ({ + type: ComponentType.Container, + components, + ...options +}); + +export const text = (content: string): ComponentInContainerData => ({ + type: ComponentType.TextDisplay, + content +}); diff --git a/src/index.ts b/src/index.ts index 26b4ca8..9c9e0df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ -import fs from "node:fs"; import path from "node:path"; -import {ActivityType, Client, Collection, GatewayIntentBits, Partials} from "discord.js"; -import type {CommandModule, EventModule} from "./types"; -import {pathToFileURL} from "node:url"; +import {fileURLToPath} from "node:url"; +import {ActivityType, Client, GatewayIntentBits, Partials} from "discord.js"; +import {Dispatcher, loadCommands, loadEvents} from "./framework"; +import {recordCommandRun} from "./util/stats"; +const here = path.dirname(fileURLToPath(import.meta.url)); + // Create a new client instance const client = new Client({ intents: [ @@ -22,37 +24,29 @@ const client = new Client({ }); -client.commands = new Collection(); -const commandsPath = path.join(__dirname, "commands"); -const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith(".ts") || file.endsWith(".tsx")); +// Build the dispatcher up front so a malformed command or a duplicate component +// namespace fails at startup rather than on the first interaction. +const dispatcher = new Dispatcher({ + ownerId: process.env.BOT_OWNER_ID!, + onCommandRun: recordCommandRun +}); -for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - const command = await import(pathToFileURL(filePath).href) as {default: CommandModule;}; +const commands = await loadCommands(path.join(here, "commands")); +for (const command of commands) command.register(dispatcher); - // Handle both default and named exports - const commandData = "default" in command ? command.default : command; +const {commands: migrated, legacy, components} = dispatcher.counts; +console.log(`Loaded ${migrated + legacy} commands (${migrated} migrated, ${legacy} legacy) and ${components} components.`); - // Set a new item in the Collection - // With the key as the command name and the value as the exported module - client.commands.set(commandData.data.name, commandData); -} +client.dispatcher = dispatcher; -const eventsPath = path.join(__dirname, "events"); -const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith(".ts") || file.endsWith(".tsx")); - -for (const file of eventFiles) { - const filePath = path.join(eventsPath, file); - const event = await import(pathToFileURL(filePath).href) as {default: EventModule;}; - // Handle both default and named exports - const eventData = event.default || event; - if (eventData.once) { - client.once(eventData.name, (...args: Parameters) => eventData.execute(...args)); - } - else { - client.on(eventData.name, (...args: Parameters) => eventData.execute(...args)); - } + +const events = await loadEvents(path.join(here, "events")); +for (const event of events) { + if (event.once) client.once(event.name, (...args) => void event.execute(...args)); + else client.on(event.name, (...args) => void event.execute(...args)); } +console.log(`Registered ${events.length} event listeners.`); + // Login to Discord with your client's token await client.login(process.env.BOT_TOKEN); diff --git a/src/paginator.ts b/src/paginator.ts index c981917..00a5e51 100644 --- a/src/paginator.ts +++ b/src/paginator.ts @@ -1,116 +1,68 @@ -import {ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, ButtonInteraction, type JSONEncodable, type APIMessageTopLevelComponent, MessageFlags} from "discord.js"; -import {msInMinute} from "./util/time"; - - -interface PaginatorOptions { - interaction: CommandInteraction; +/** + * Button pagination, built on `runSession`. + * + * The ownership check, the timeout and disabling the controls when the collector + * ends are no longer this file's concern โ€” they happen once, in + * `src/framework/session.ts`, which is why the previous version's two bugs + * (capturing the button interaction before the user check, and dropping + * `IsComponentsV2` on the final edit) are no longer expressible here. + */ + +import { + ButtonStyle, ComponentType, MessageFlags, + type InteractionEditReplyOptions, type MessageActionRowComponentData, type RepliableInteraction +} from "discord.js"; +import {row, runSession, sessionId} from "./framework"; + + +type PageComponent = NonNullable[number]; + +export interface PaginateOptions { + interaction: RepliableInteraction; items: T[]; - renderPage: (items: T[], page?: number, totalPages?: number) => JSONEncodable | Array>; - itemsPerPage?: number; + /** Top-level components for one page. Controls are appended automatically. */ + renderPage(items: T[], page: number, pages: number): readonly PageComponent[]; + perPage?: number; timeout?: number; + audience?: "invoker" | "anyone"; } -export default class Paginator { - private interaction: CommandInteraction; - private entries: T[]; - private itemsPerPage: number; - private timeout: number; - private renderPage: PaginatorOptions["renderPage"]; - private pages: Array | Array>> = []; - - private numPages: number; - private currentPage: number = 1; - private buttonInteraction?: ButtonInteraction; - - constructor(options: PaginatorOptions) { - this.interaction = options.interaction; - this.entries = options.items; - this.itemsPerPage = options.itemsPerPage || 10; - this.timeout = options.timeout || msInMinute * 2; - this.renderPage = options.renderPage; - this.numPages = Math.floor(this.entries.length / this.itemsPerPage); - if (this.entries.length % this.itemsPerPage) this.numPages = this.numPages + 1; - for (let i = 1; i <= this.numPages; i++) { - const pageEntries = this.getEntriesForPage(i); - this.pages.push(this.renderPage(pageEntries, i, this.numPages)); +export async function paginate(options: PaginateOptions): Promise { + const {interaction, items, renderPage, perPage = 10, timeout, audience} = options; + const pages = Math.max(1, Math.ceil(items.length / perPage)); + + const controls = (page: number, ended: boolean): MessageActionRowComponentData[] => [ + {type: ComponentType.Button, customId: sessionId("first"), label: "<< First", style: ButtonStyle.Secondary, disabled: ended || page === 1}, + {type: ComponentType.Button, customId: sessionId("previous"), label: "< Previous", style: ButtonStyle.Primary, disabled: ended || page === 1}, + {type: ComponentType.Button, customId: sessionId("info"), label: `Page ${page} of ${pages}`, style: ButtonStyle.Secondary, disabled: true}, + {type: ComponentType.Button, customId: sessionId("next"), label: "Next >", style: ButtonStyle.Primary, disabled: ended || page === pages}, + {type: ComponentType.Button, customId: sessionId("last"), label: "Last >>", style: ButtonStyle.Secondary, disabled: ended || page === pages} + ]; + + await runSession({ + interaction, + initial: 1, + timeout, + audience, + + render: (page, {ended}) => ({ + // Set on every render, including the final one. + flags: MessageFlags.IsComponentsV2, + components: [ + ...renderPage(items.slice((page - 1) * perPage, page * perPage), page, pages), + row(...controls(page, ended)) + ] + }), + + reduce(action, page) { + switch (action) { + case "first": return 1; + case "previous": return Math.max(1, page - 1); + case "next": return Math.min(pages, page + 1); + case "last": return pages; + default: return undefined; + } } - } - - get buttons() { - return new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId("first") - .setLabel("<< First") - .setStyle(ButtonStyle.Secondary) - .setDisabled(this.currentPage === 1), - new ButtonBuilder() - .setCustomId("previous") - .setLabel("< Previous") - .setStyle(ButtonStyle.Primary) - .setDisabled(this.currentPage === 1), - new ButtonBuilder() - .setCustomId("page-info") - .setLabel(`Page ${this.currentPage} of ${this.numPages}`) - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - new ButtonBuilder() - .setCustomId("next") - .setLabel("Next >") - .setStyle(ButtonStyle.Primary) - .setDisabled(this.currentPage === this.numPages), - new ButtonBuilder() - .setCustomId("last") - .setLabel("Last >>") - .setStyle(ButtonStyle.Secondary) - .setDisabled(this.currentPage === this.numPages), - ); - } - - getEntriesForPage(page: number): T[] { - const base = (page - 1) * this.itemsPerPage; - return this.entries.slice(base, base + this.itemsPerPage); - } - - async firstPage() {await this.showPage(1);} - async lastPage() {await this.showPage(this.numPages);} - async nextPage() {await this.validatedShowPage(this.currentPage + 1);} - async previousPage() {await this.validatedShowPage(this.currentPage - 1);} - async validatedShowPage(page: number) { - if (page > 0 && page <= this.numPages) await this.showPage(page); - } - - async showPage(page: number) { - this.currentPage = page; - - const renderedPage = this.pages[this.currentPage - 1]; - const componentList = Array.isArray(renderedPage) ? renderedPage : [renderedPage]; - - if (this.buttonInteraction) return await this.buttonInteraction.update({components: [...componentList, this.buttons], flags: MessageFlags.IsComponentsV2}); - await this.interaction.editReply({components: [...componentList, this.buttons], flags: MessageFlags.IsComponentsV2}); - } - - async paginate() { - await this.showPage(1); - - const msg = await this.interaction.fetchReply(); - const collector = msg.createMessageComponentCollector({time: this.timeout}); - - collector.on("collect", async i => { - this.buttonInteraction = i as ButtonInteraction; - if (i.user.id !== this.interaction.user.id) return await i.reply({content: "You cannot interact with this menu.", flags: MessageFlags.Ephemeral}); - if (i.customId === "first") await this.firstPage(); - if (i.customId === "last") await this.lastPage(); - if (i.customId === "previous") await this.previousPage(); - if (i.customId === "next") await this.nextPage(); - if (i.customId === "page-info") await i.reply({content: `You are on page ${this.currentPage} of ${this.numPages}.`, flags: MessageFlags.Ephemeral}); - }); - - collector.on("end", async () => { - const renderedPage = this.pages[this.currentPage - 1]; - const componentList = Array.isArray(renderedPage) ? renderedPage : [renderedPage]; - await this.interaction.editReply({components: componentList}); - }); - } + }); } diff --git a/src/types/base.ts b/src/types/base.ts index f39d3bb..d9eabc6 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -1,34 +1,17 @@ // src/types.ts -import {AutocompleteInteraction, BaseInteraction, ButtonInteraction, ChatInputCommandInteraction, Collection, ModalSubmitInteraction, RoleSelectMenuInteraction, SlashCommandBuilder, StringSelectMenuInteraction} from "discord.js"; +import type {Dispatcher} from "../framework/dispatch"; // Extend the Discord.js Client interface globally declare module "discord.js" { interface Client { cpuUsage: NodeJS.CpuUsage; - commands: Collection; + dispatcher: Dispatcher; } } export type AtLeast = Partial & Pick; -export type CommandModule = { - data: SlashCommandBuilder | ReturnType; - owner?: boolean; - execute: (interaction: T) => Promise; - autocomplete: (i: T) => unknown; - button: (i: T) => unknown; - modal: (i: T) => unknown; - select: (i: T) => unknown; - role: (i: T) => unknown; -}; - -export interface EventModule { - name: string; - once?: boolean; - execute: (...args: unknown[]) => Promise; -} - export interface CommandStats { commands?: { [key: string]: number; diff --git a/src/util/stats.ts b/src/util/stats.ts new file mode 100644 index 0000000..931bec3 --- /dev/null +++ b/src/util/stats.ts @@ -0,0 +1,16 @@ +import type {ChatInputCommandInteraction} from "discord.js"; +import type {CommandStats} from "../types"; +import {statsDB} from "../db"; + + +/** Counts a command run against its guild, or against the bot for DMs. */ +export async function recordCommandRun(interaction: ChatInputCommandInteraction): Promise { + const key = interaction.guildId ?? interaction.client.user.id; + const name = interaction.commandName; + + const data: CommandStats = await statsDB.get(key) ?? {commands: {}}; + data.commands ??= {}; + data.commands[name] = (data.commands[name] ?? 0) + 1; + + await statsDB.set(key, data); +} From dba93b8dd69ef91e97f8f6b49648b0f1c5b595d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:00:38 +0000 Subject: [PATCH 02/22] Fix remaining silent bugs: stateful regexes, tag replies, addon cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stateful regexes (src/util/names.ts, events/detectspam.ts) const weirdCharsRegex = /[^A-Za-z0-9\-_\\. ]/g; ... if (!weirdCharsRegex.test(member.displayName)) continue; RegExp.prototype.test advances lastIndex on a /g regex and resumes from there on the next call, so a shared module-level global regex returns alternating answers for the same input. Verified against four display names that all contain disallowed characters: the third returned false. That regex sat inside the member loop of `/cleanname server`, so the command silently skipped a share of the members it should have renamed on every run, and the GuildMemberAdd handler misfired intermittently. The same shape was in detectspam's sketchyRuRegex ("ig" + .test()), where it let roughly half of matching .ru.com links through. Confirmed: the old pattern detects 2 of 4 sketchy hosts, the fixed one detects 4 of 4. The regex was duplicated across commands/cleanname.ts and events/cleanname.ts with a TODO about double maintenance; it now lives in src/util/names.ts with a comment explaining why it must not be global. invitefilter's regexes keep /g โ€” they are used with matchAll, which requires it. detectcryptoscam's has no /g and was already correct. Tag command replies (commands/tags.tsx) - The success message read "Tag `x` has been $updated successfully!". `${isUpdating ? ...}` is template-literal syntax, which JSX does not interpolate; it emitted a literal "$" and treated the rest as a JSX expression. Now `{isUpdating ? ...}`. - create(), update() and delete() called editReply() on their permission-denied paths before anything had deferred or replied, so they threw InteractionNotReplied and the user saw the generic "There was an error while executing this command!" instead of the message written there. create() and update() end in showModal() and so can never defer; their early exits now reply ephemerally. delete() now defers first, which makes every later editReply valid. Addon cache (util/addons.ts) ensureCache stamped addonCacheLastUpdate and cleared both the in-memory and persisted cache before issuing either request. A failed fetch therefore left an empty cache with a fresh timestamp, so every /addons command returned nothing for a full hour. It now fetches into a local array and only replaces the cache once both requests have succeeded, stamping the timestamp last. A failure is logged and the existing cache is served rather than propagating to the user, and the next call retries. Concurrent callers now share one in-flight refresh instead of racing. Verified: display-name and .ru.com checks return stable results across repeated calls; the tag success message renders "updated"/"created" with no stray "$" and keeps both Ephemeral and IsComponentsV2 flags. The addon cache failure path was exercised against real failing requests โ€” no throw reached the caller and the timestamp was not advanced, so each subsequent call retried. The fetch-before-mutate ordering is structural: both requests complete before cache.clear() or either globalDB.set() runs. tsc reports the same 9 pre-existing errors as before (4 in djsx/, 5 unused symbols in about.ts) and none in the modified files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/cleanname.ts | 8 ++--- src/commands/tags.tsx | 14 ++++----- src/events/cleanname.ts | 7 ++--- src/events/detectspam.ts | 4 ++- src/util/addons.ts | 65 +++++++++++++++++++++++++-------------- src/util/names.ts | 16 ++++++++++ 6 files changed, 73 insertions(+), 41 deletions(-) create mode 100644 src/util/names.ts diff --git a/src/commands/cleanname.ts b/src/commands/cleanname.ts index 7f40f9f..359fbf6 100644 --- a/src/commands/cleanname.ts +++ b/src/commands/cleanname.ts @@ -3,11 +3,9 @@ import {humanReadableUptime} from "../util/time"; import Colors from "../util/colors"; import Messages from "../util/messages"; import {guildDB} from "../db"; +import {hasDisallowedChars} from "../util/names"; - -const weirdCharsRegex = /[^A-Za-z0-9\-_\\. ]/g; - export default { data: new SlashCommandBuilder() .setName("cleanname") @@ -70,7 +68,7 @@ export default { const members = interaction.guild.members.cache; for (const [, member] of members) { // If their name is fine continue - if (!weirdCharsRegex.test(member.displayName)) continue; + if (!hasDisallowedChars(member.displayName)) continue; // If they have a role that was selected as a bypass role, continue if (member.roles.cache.hasAny(...roleIds)) continue; @@ -106,7 +104,7 @@ export default { const targetUser = interaction.options.getUser("user", true); const member = interaction.guild.members.cache.get(targetUser.id); if (!member) return await interaction.reply(Messages.error("This user is not in the server.", {ephemeral: true})); - const isClean = !weirdCharsRegex.test(member.displayName); + const isClean = !hasDisallowedChars(member.displayName); if (isClean) return await interaction.reply(Messages.info("This member's display name already conforms to the username standards.")); try { await member.setNickname(member.user.username); diff --git a/src/commands/tags.tsx b/src/commands/tags.tsx index 2866fb8..f0c1ce9 100644 --- a/src/commands/tags.tsx +++ b/src/commands/tags.tsx @@ -36,7 +36,7 @@ export default { if (command === "delete") return await this.delete(interaction); if (command === "list") return await this.list(interaction); - return await interaction.editReply(This command is not yet implemented. as MessageOptions); + return await interaction.reply(This command is not yet implemented. as MessageOptions); }, async view(interaction: ChatInputCommandInteraction<"cached">) { @@ -56,26 +56,26 @@ export default { }, async create(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to create tags. as MessageOptions); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(You do not have permission to create tags. as MessageOptions); const tagName = interaction.options.getString("name", true); const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tag = guildTags[tagName]; - if (tag) return await interaction.editReply(Tag with name `{tagName}` already exists. as MessageOptions); + if (tag) return await interaction.reply(Tag with name `{tagName}` already exists. as MessageOptions); return await this.showTagModal(interaction, {name: tagName}); }, async update(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to update tags. as MessageOptions); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(You do not have permission to update tags. as MessageOptions); const tagName = interaction.options.getString("name", true); const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tag = guildTags[tagName]; - if (!tag) return await interaction.editReply(Tag with name `{tagName}` does not exist. as MessageOptions); + if (!tag) return await interaction.reply(Tag with name `{tagName}` does not exist. as MessageOptions); return await this.showTagModal(interaction, tag); }, async delete(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to delete tags. as MessageOptions); await interaction.deferReply({flags: MessageFlags.Ephemeral}); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to delete tags. as MessageOptions); const tagName = interaction.options.getString("name", true); const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tag = guildTags[tagName]; @@ -127,7 +127,7 @@ export default { }; await tagsDB.set(interaction.guildId, guildTags); - await modalInteraction.reply(Tag `{tag.name}` has been ${isUpdating ? "updated" : "created"} successfully! as MessageOptions); + await modalInteraction.reply(Tag `{tag.name}` has been {isUpdating ? "updated" : "created"} successfully! as MessageOptions); } catch { await interaction.followUp(Modal submission timed out! as MessageOptions); diff --git a/src/events/cleanname.ts b/src/events/cleanname.ts index 3d0edc6..d2dcb63 100644 --- a/src/events/cleanname.ts +++ b/src/events/cleanname.ts @@ -1,15 +1,12 @@ import {Events, type GuildMember} from "discord.js"; import {guildDB} from "../db"; - - -// TODO: put this somewhere common to avoid double maintenance -const weirdCharsRegex = /[^A-Za-z0-9\-_\\. ]/g; +import {hasDisallowedChars} from "../util/names"; export default { name: Events.GuildMemberAdd, async execute(member: GuildMember) { - if (!weirdCharsRegex.test(member.displayName)) return; // TODO: maybe log? + if (!hasDisallowedChars(member.displayName)) return; // TODO: maybe log? const guildSettings = await guildDB.get(member.guild.id); if (!guildSettings?.cleanOnJoin) return; diff --git a/src/events/detectspam.ts b/src/events/detectspam.ts index 8fce715..1b60175 100644 --- a/src/events/detectspam.ts +++ b/src/events/detectspam.ts @@ -6,7 +6,9 @@ import Colors from "../util/colors"; const fakeDiscordRegex = new RegExp(`([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\\.(com|net|app|gift|ru|uk)`, "ig"); const okayDiscordRegex = new RegExp(`([a-zA-Z-\\.]+\\.)?discord((?:app)|(?:status))?\\.(com|net|app)`, "i"); const fakeSteamRegex = new RegExp(`str?e[ea]?mcomm?m?un[un]?[un]?[tl]?[il][tl]?ty\\.(com|net|ru|us)`, "ig"); -const sketchyRuRegex = new RegExp(`([a-zA-Z-\\.]+).ru.com`, "ig"); +// No `g` flag: this one is used with .test(), which advances lastIndex on a +// global regex and would make results alternate between messages. +const sketchyRuRegex = new RegExp(`([a-zA-Z-\\.]+).ru.com`, "i"); // TODO: consider de-duping with invitefilter event export default { diff --git a/src/util/addons.ts b/src/util/addons.ts index dff295b..0626ea3 100644 --- a/src/util/addons.ts +++ b/src/util/addons.ts @@ -8,40 +8,59 @@ import {msInHour, msInMinute} from "./time"; export const cache = new Set(); -export async function ensureCache() { - const previousCacheUpdate = await globalDB.get("addonCacheLastUpdate") as number ?? 0; - if ((Date.now() - previousCacheUpdate) < msInHour) { - if (cache.size) return; - console.log("Loading addon cache from storage..."); - const storedCache = await globalDB.get("addonCache") as BdWebAddon[] ?? []; - for (const addon of storedCache) { - cache.add(addon); - } - return; - } - console.log(cache.size ? "Refreshing" : "Building", "addon cache..."); - await globalDB.set("addonCacheLastUpdate", Date.now()); - // Clear previous cache in DB and in-memory - await globalDB.set("addonCache", []); - cache.clear(); +/** De-duplicates concurrent refreshes so two commands don't both hit the store. */ +let inFlight: Promise | null = null; - let res = await request(Web.store.plugins); - let data = await res.body.json() as BdWebAddon[]; - for (const addon of data) { +async function loadFromStorage(): Promise { + console.log("Loading addon cache from storage..."); + const storedCache = await globalDB.get("addonCache") as BdWebAddon[] ?? []; + for (const addon of storedCache) { cache.add(addon); } +} + +async function refreshFromStore(): Promise { + console.log(cache.size ? "Refreshing" : "Building", "addon cache..."); + + // Fetch everything before touching what we already have. The previous + // version cleared the cache and stamped the timestamp up front, so a failed + // request left an empty cache that would not retry for an hour. + const fetched: BdWebAddon[] = []; + for (const url of [Web.store.plugins, Web.store.themes]) { + const res = await request(url); + fetched.push(...await res.body.json() as BdWebAddon[]); + } - res = await request(Web.store.themes); - data = await res.body.json() as BdWebAddon[]; - for (const addon of data) { + cache.clear(); + for (const addon of fetched) { cache.add(addon); } - await globalDB.set("addonCache", Array.from(cache)); + await globalDB.set("addonCache", fetched); + await globalDB.set("addonCacheLastUpdate", Date.now()); console.log(`Cached ${cache.size} addons from store.`); } +export async function ensureCache() { + const previousCacheUpdate = await globalDB.get("addonCacheLastUpdate") as number ?? 0; + if ((Date.now() - previousCacheUpdate) < msInHour) { + if (cache.size) return; + return await loadFromStorage(); + } + + try { + inFlight ??= refreshFromStore().finally(() => {inFlight = null;}); + await inFlight; + } + catch (error) { + // The timestamp was not advanced, so the next call retries. Serve + // whatever we have rather than failing the command outright. + console.error("Could not refresh addon cache:", error); + if (!cache.size) await loadFromStorage(); + } +} + export function sortAddons(addons: BdWebAddon[], sortBy: "likes" | "downloads" | "initial_release_date" | "latest_release_date"): BdWebAddon[] { return addons.sort((a, b) => { diff --git a/src/util/names.ts b/src/util/names.ts new file mode 100644 index 0000000..fd422cb --- /dev/null +++ b/src/util/names.ts @@ -0,0 +1,16 @@ +/** + * Display-name hygiene, shared by the `cleanname` command and its join handler. + * Previously duplicated in both, with a `TODO` acknowledging it. + */ + +/** + * Anything outside Discord's username standards. + * + * Deliberately NOT global. `RegExp.prototype.test` advances `lastIndex` on a + * `/g` regex and resumes from there on the next call, so a shared global regex + * returns alternating results across calls. That made `/cleanname server` skip + * a share of the members it should have renamed on every run. + */ +const disallowedChars = /[^A-Za-z0-9\-_\\. ]/; + +export const hasDisallowedChars = (displayName: string): boolean => disallowedChars.test(displayName); From 8bc6bf2d0eff2532df9f74dcdf226721b2a388d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:05:21 +0000 Subject: [PATCH 03/22] Make the build enforceable: typecheck, lint and CI all green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo had no typescript dependency, no typecheck script, 9 tsc errors, and an eslint setup nobody but the author could run. None of that could gate a pull request, so nothing below stays fixed without it. Dependencies and scripts - Added typescript ^5.9.3. It was never a devDependency, so `tsc` was not available from a clean clone. - @zerebos/eslint-config and @zerebos/eslint-config-typescript pointed at file:../../eslint-configs/packages/*, a path outside the repository, so `bun install` failed on both for anyone else. Both are published; switched to ^1.0.3 and ^1.1.1. A clean `bun install --frozen-lockfile` now succeeds with no failed packages, which also unbreaks the Dockerfile's --production install. - Added `typecheck` (tsc --noEmit) and `lint` (eslint .) scripts, and made `test` exit 0 with a note until there is a suite, so it can sit in CI without being a permanent red. Cleared all 9 type errors - djsx/utils.ts: discord.js types its `components` arrays readonly, so four call sites (ComponentMessage, MediaGallery, Modal, StringSelect) passed a readonly array to childrenToArray, which demanded a mutable one. Widened the parameter; one change fixed all four. - src/commands/about.ts: five unused-symbol errors, all from a commented-out invite-button block. Removed the block, its three imports and its two OAuth URL constants, leaving a comment pointing at 335cf56 where they were parked. Restoring them is a git show away if they were meant to come back. Cleared all 18 lint errors Most were in the framework added last commit, and fixing them improved it: - dispatch.ts: the type checker reports every `as CommandHandler` / `as ComponentHandler` / `as never` in the dispatch path as unnecessary. Removed them. dispatch.ts now performs no casts at all โ€” the runtime guard is still what makes the narrowing sound, but nothing is asserted. - session.ts / paginator.ts: `render`, `reduce` and `renderPage` were declared with method shorthand, which trips unbound-method when destructured. Declared as function-typed properties, which is also more honest since none of them use `this`. - ids.ts: renamed `OneOf` to `oneOf`. It is a factory, not a constructor, and lowercase matches row/container/text. - loader.ts: stopped passing a method reference to a type guard, dropped a redundant assertion. - selfroles.ts: quoted the reserved-word property. - djsx Container/Label/Modal: three redundant assertions the type checker confirmed were doing nothing. - eslint.config.js: the flat config is not in the TS program (allowJs is false), so the imported config arrays resolve to `error` and the spread trips no-unsafe-argument. Disabled on that line with a reason. CI .github/workflows/ci.yml runs bun install --frozen-lockfile, typecheck and lint on every push and pull request. Verified from a clean node_modules: `bun install --frozen-lockfile` succeeds, `bun run typecheck` exits 0, `bun run lint` exits 0, and the loader still registers 10 commands, 3 components and 11 event listeners. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- .github/workflows/ci.yml | 27 +++++++++ bun.lock | 123 +++++++++++++++----------------------- djsx/Container.tsx | 2 +- djsx/Label.tsx | 2 +- djsx/Modal.tsx | 2 +- djsx/utils.ts | 8 +-- eslint.config.js | 1 + package.json | 11 ++-- src/commands/about.ts | 18 ++---- src/commands/selfroles.ts | 10 ++-- src/framework/README.md | 4 +- src/framework/dispatch.ts | 11 ++-- src/framework/ids.ts | 4 +- src/framework/loader.ts | 4 +- src/framework/session.ts | 4 +- src/paginator.ts | 2 +- 16 files changed, 114 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cda731 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + check: + name: Typecheck and lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint diff --git a/bun.lock b/bun.lock index 6f8d865..09555dd 100644 --- a/bun.lock +++ b/bun.lock @@ -17,9 +17,10 @@ }, "devDependencies": { "@types/string-similarity": "^4.0.2", - "@zerebos/eslint-config": "file:../../eslint-configs/packages/base", - "@zerebos/eslint-config-typescript": "file:../../eslint-configs/packages/typescript", + "@zerebos/eslint-config": "^1.0.3", + "@zerebos/eslint-config-typescript": "^1.1.1", "eslint": "^9.39.1", + "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", }, }, @@ -54,7 +55,7 @@ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], - "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], @@ -74,12 +75,6 @@ "@keyv/sqlite": ["@keyv/sqlite@4.0.6", "", { "dependencies": { "sqlite3": "^5.1.7" }, "peerDependencies": { "keyv": "^5.5.3" } }, "sha512-xfUYps2HtxuQFsZlXv3qTs9p9mJMOSlNmCnd9R4UYxFlQJ1qLnKT+P0vjhQX9HBwnhKuhsinkmwTbooYFeFr4A=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@npmcli/fs": ["@npmcli/fs@1.1.1", "", { "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" } }, "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ=="], "@npmcli/move-file": ["@npmcli/move-file@1.1.2", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg=="], @@ -124,9 +119,9 @@ "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], - "@zerebos/eslint-config": ["@zerebos/eslint-config@file:../../eslint-configs/packages/base", { "dependencies": { "@eslint/js": "^8.57.0", "globals": "^13.24.0" }, "peerDependencies": { "eslint": ">=8.0.0" } }], + "@zerebos/eslint-config": ["@zerebos/eslint-config@1.0.3", "", { "dependencies": { "@eslint/js": "^10.0.1", "globals": "^17.11.0" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-ZNKLp/7qano5Y28t0yUE8Yn1HfqRRgm7AiKcsXJ3FpUIAqszOdV/yBbN6BaquQ7m9cjKzBV3OvSUWxHMrkoCow=="], - "@zerebos/eslint-config-typescript": ["@zerebos/eslint-config-typescript@file:../../eslint-configs/packages/typescript", { "dependencies": { "typescript-eslint": "^8.34.0" }, "peerDependencies": { "eslint": ">=8.0.0" } }], + "@zerebos/eslint-config-typescript": ["@zerebos/eslint-config-typescript@1.1.1", "", { "dependencies": { "typescript-eslint": "^8.67.0" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-2hq9t3XzwvOkdCPwjZ95K+W0Zw3a0W0R+dic6ekFTptvrbM+uiKt6hkTjWA9h35MVLPfvq7UL6deNJ/OjtgQdA=="], "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], @@ -162,8 +157,6 @@ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "cacache": ["cacache@15.3.0", "", { "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "glob": "^7.1.4", "infer-owner": "^1.0.4", "lru-cache": "^6.0.0", "minipass": "^3.1.1", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.2", "mkdirp": "^1.0.3", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" } }, "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ=="], @@ -238,22 +231,16 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -274,7 +261,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + "globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -322,8 +309,6 @@ "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -354,10 +339,6 @@ "make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], @@ -432,8 +413,6 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -442,12 +421,8 @@ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -498,8 +473,6 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], @@ -510,9 +483,7 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.48.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A=="], @@ -556,7 +527,7 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@zerebos/eslint-config-typescript/typescript-eslint": ["typescript-eslint@8.34.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.34.1", "@typescript-eslint/parser": "8.34.1", "@typescript-eslint/utils": "8.34.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow=="], + "@zerebos/eslint-config-typescript/typescript-eslint": ["typescript-eslint@8.68.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.68.0", "@typescript-eslint/parser": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/utils": "8.68.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ=="], "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -568,16 +539,12 @@ "eslint/@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "flat-cache/keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -598,11 +565,13 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/type-utils": "8.34.1", "@typescript-eslint/utils": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.34.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.68.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.68.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.68.0", "@typescript-eslint/tsconfig-utils": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA=="], + + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.68.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ=="], "discord.js/@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -612,66 +581,70 @@ "discord.js/@discordjs/ws/discord-api-types": ["discord-api-types@0.38.12", "", {}, "sha512-vqkRM50N5Zc6OVckAqtSslbUEoXmpN4bd9xq2jkoK9fgO3KNRIOyMMQ7ipqjwjKuAgzWvU6G8bRIcYWaUe1sCA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0" } }, "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.34.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.34.1", "@typescript-eslint/utils": "8.34.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/utils": "8.68.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0" } }, "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.68.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.68.0", "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.68.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0" } }, "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], } } diff --git a/djsx/Container.tsx b/djsx/Container.tsx index 4d58b68..64cfa26 100644 --- a/djsx/Container.tsx +++ b/djsx/Container.tsx @@ -9,5 +9,5 @@ export function Container({children, ...props}: ContainerProps): ContainerCompon type: ComponentType.Container, components: childrenToArray(children), ...props - } as ContainerComponentData; + }; } \ No newline at end of file diff --git a/djsx/Label.tsx b/djsx/Label.tsx index 6eb04f1..d8a3141 100644 --- a/djsx/Label.tsx +++ b/djsx/Label.tsx @@ -8,7 +8,7 @@ export function ModalLabel({children, ...restProps}: LabelProps): LabelComponent // console.log("ModalLabel called with children:", children); return { type: ComponentType.Label, - component: singleChild("ModalLabel", children) as LabelComponentData["component"], + component: singleChild("ModalLabel", children), ...restProps }; } \ No newline at end of file diff --git a/djsx/Modal.tsx b/djsx/Modal.tsx index 4b0d1ac..61cf138 100644 --- a/djsx/Modal.tsx +++ b/djsx/Modal.tsx @@ -8,5 +8,5 @@ export function Modal({children, ...props}: ModalProps): ModalComponentData { return { components: childrenToArray(children), ...props - } as ModalComponentData; + }; } \ No newline at end of file diff --git a/djsx/utils.ts b/djsx/utils.ts index 9086ad8..53c5955 100644 --- a/djsx/utils.ts +++ b/djsx/utils.ts @@ -1,7 +1,7 @@ const isFalseOrNullish = (value: unknown) => value === false || value == null; const isNotFalseOrNullish = (value: unknown) => !isFalseOrNullish(value); -export function transformChildrenArray(children: Array): T[] { +export function transformChildrenArray(children: ReadonlyArray): T[] { return children.flat(Infinity).filter(isNotFalseOrNullish) as T[]; } @@ -18,14 +18,14 @@ export function childrenToString(name: string, children: string | string[] | nul throw new Error(`${name} children must be a string or an array of strings`); } -export function childrenToArray(children: T | T[]): T[] { +export function childrenToArray(children: T | readonly T[]): T[] { if (Array.isArray(children)) { - return transformChildrenArray(children); + return transformChildrenArray(children as ReadonlyArray); } if (isFalseOrNullish(children)) { return []; } - return [children]; + return [children as T]; } export function singleChild(name: string, children: T | T[]): T { diff --git a/eslint.config.js b/eslint.config.js index 3f1b0eb..903913d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,7 @@ import {defineConfig} from "eslint/config"; /** @type {import("@zerebos/eslint-config-typescript").ConfigArray} */ export default defineConfig( ...node, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- this file is not in the TS program, so the imported config array types resolve to `error` ...ts.configs.recommendedWithTypes, { rules: { diff --git a/package.json b/package.json index 9f09fbe..f8926cf 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,22 @@ "main": "src/index.ts", "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "echo \"No test suite yet\" && exit 0", "start": "bun run --tsconfig-override tsconfig.bun.json --bun src/index.ts", "deploy": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/deploy-commands.ts", "clear": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/deploy-commands.ts --clear", - "validate": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/validate-env.ts" + "validate": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/validate-env.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint ." }, "author": "Zerebos", "license": "MIT", "devDependencies": { "@types/string-similarity": "^4.0.2", - "@zerebos/eslint-config": "file:../../eslint-configs/packages/base", - "@zerebos/eslint-config-typescript": "file:../../eslint-configs/packages/typescript", + "@zerebos/eslint-config": "^1.0.3", + "@zerebos/eslint-config-typescript": "^1.1.1", "eslint": "^9.39.1", + "typescript": "^5.9.3", "typescript-eslint": "^8.48.1" }, "dependencies": { diff --git a/src/commands/about.ts b/src/commands/about.ts index 2060825..109c35c 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,14 +1,12 @@ import childProcess from "child_process"; import {promisify} from "util"; -import {SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, ApplicationIntegrationType, InteractionContextType} from "discord.js"; +import {SlashCommandBuilder, EmbedBuilder, ChannelType, ChatInputCommandInteraction, ApplicationIntegrationType, InteractionContextType} from "discord.js"; import type {CommandStats} from "../types"; import {statsDB} from "../db"; import {humanReadableUptime} from "../util/time"; const exec = promisify(childProcess.exec); -const inviteLink = `https://discord.com/oauth2/authorize?client_id=${process.env.BOT_CLIENT_ID}&permissions=${process.env.BOT_PERMISSIONS || "0"}&scope=bot%20applications.commands`; -const userInviteLink = `https://discord.com/oauth2/authorize?client_id=${process.env.BOT_CLIENT_ID}&integration_type=1&scope=applications.commands`; export default { data: new SlashCommandBuilder() @@ -23,7 +21,7 @@ export default { aboutEmbed.setColor("Blue"); aboutEmbed.setAuthor({name: interaction.client.user.username, iconURL: interaction.client.user.displayAvatarURL()}); - //aboutEmbed.setDescription("**๐Ÿ†• Now user-installable!** Add to your account for DM access and cross-server profiles."); + // aboutEmbed.setDescription("**๐Ÿ†• Now user-installable!** Add to your account for DM access and cross-server profiles."); const owner = await interaction.client.users.fetch(process.env.BOT_OWNER_ID!); if (owner) aboutEmbed.setFooter({text: `Created by @${owner.username}`, iconURL: owner.displayAvatarURL()}); @@ -107,14 +105,8 @@ export default { addField(`Commands Run`, commandsRun, true); addField(`Uptime`, humanReadableUptime(now - interaction.client.readyAt.valueOf()), true); - await interaction.editReply({ - embeds: [aboutEmbed], - /*components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder().setLabel(`Invite ${interaction.client.user.username}`).setStyle(ButtonStyle.Link).setURL(inviteLink).setEmoji("๐Ÿ”—"), - new ButtonBuilder().setLabel("Add to Account").setStyle(ButtonStyle.Link).setURL(userInviteLink).setEmoji("๐Ÿ“ฑ") - ) - ]*/ - }); + // The invite / "Add to Account" link buttons were parked in 335cf56 along + // with their OAuth URL constants; restore them from there if wanted. + await interaction.editReply({embeds: [aboutEmbed]}); }, }; diff --git a/src/commands/selfroles.ts b/src/commands/selfroles.ts index c7e7139..02b07f3 100644 --- a/src/commands/selfroles.ts +++ b/src/commands/selfroles.ts @@ -3,7 +3,7 @@ import { MessageFlags, PermissionFlagsBits, SelectMenuDefaultValueType, type InteractionReplyOptions, type InteractionUpdateOptions, type MessageActionRowComponentData } from "discord.js"; -import {defineCommand, defineComponent, OneOf, row} from "../framework"; +import {defineCommand, defineComponent, oneOf, row} from "../framework"; import {selfrolesDB} from "../db"; import Messages from "../util/messages"; import Colors from "../util/colors"; @@ -38,7 +38,7 @@ const openPicker = defineComponent({ id: "selfroles.open", kind: "button", guildOnly: true, - params: {mode: OneOf("user", "admin")}, + params: {mode: oneOf("user", "admin")}, async run(interaction, {mode}) { const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; @@ -72,9 +72,9 @@ const openPicker = defineComponent({ minValues: 0, maxValues: assignable.length, options: assignable.map(id => ({ - label: interaction.guild.roles.cache.get(id)?.name ?? id, - value: id, - default: interaction.member.roles.cache.has(id) + "label": interaction.guild.roles.cache.get(id)?.name ?? id, + "value": id, + "default": interaction.member.roles.cache.has(id) })) })] })); diff --git a/src/framework/README.md b/src/framework/README.md index 036c05d..f7c0dbb 100644 --- a/src/framework/README.md +++ b/src/framework/README.md @@ -45,7 +45,7 @@ const picker = defineComponent({ id: "selfroles.open", // unique namespace, prefix of every id it mints kind: "button", // fixes the interaction type guildOnly: true, - params: {mode: OneOf("user", "admin")}, + params: {mode: oneOf("user", "admin")}, async run(interaction, {mode}) { // ButtonInteraction<"cached">, mode: "user" | "admin" โ€ฆ @@ -56,7 +56,7 @@ const picker = defineComponent({ {type: ComponentType.Button, customId: picker.customId({mode: "admin"}), โ€ฆ} ``` -`OneOf` yields a literal union, so a `switch` over the param can be exhaustive. +`oneOf` yields a literal union, so a `switch` over the param can be exhaustive. A missing param, a wrong type, or a typo in a literal is a compile error. Custom ids are capped at Discord's 100 characters; `customId()` throws if you diff --git a/src/framework/dispatch.ts b/src/framework/dispatch.ts index 233ddba..d77f349 100644 --- a/src/framework/dispatch.ts +++ b/src/framework/dispatch.ts @@ -17,8 +17,7 @@ import type {Command, Component, ComponentKind} from "./registry"; import {isSessionId} from "./session"; -type CommandHandler = (interaction: never) => Promise; -type ComponentHandler = (interaction: never, params: never) => Promise; +type LegacyHandler = (interaction: never) => Promise; const KIND_GUARD: {[K in ComponentKind]: (interaction: Interaction) => boolean} = { button: interaction => interaction.isButton(), @@ -38,7 +37,7 @@ export type LegacyKind = "execute" | "autocomplete" | "button" | "modal" | "sele export interface LegacyEntry { name: string; ownerOnly: boolean; - handlers: Partial>; + handlers: Partial>; } @@ -112,7 +111,7 @@ export class Dispatcher { // Guarded above: `guildOnly` was checked, so the `<"cached">` the handler // declares is actually true by this point. - await (command!.execute as CommandHandler)(interaction as never); + await command!.execute(interaction); } @@ -126,7 +125,7 @@ export class Dispatcher { if (!command?.autocomplete) return await interaction.respond([]); if (command.guildOnly && !interaction.inCachedGuild()) return await interaction.respond([]); - await (command.autocomplete as CommandHandler)(interaction as never); + await command.autocomplete(interaction); } @@ -159,7 +158,7 @@ export class Dispatcher { throw error; } - await (component.run as ComponentHandler)(interaction as never, params as never); + await component.run(interaction, params); } diff --git a/src/framework/ids.ts b/src/framework/ids.ts index cf383e5..b9fdd99 100644 --- a/src/framework/ids.ts +++ b/src/framework/ids.ts @@ -51,11 +51,11 @@ export const Id: ParamCodec = { }; /** Produces a literal union, so a switch over the param can be exhaustive. */ -export function OneOf(...allowed: T): ParamCodec { +export function oneOf(...allowed: T): ParamCodec { return { parse(raw) { if (!allowed.includes(raw)) throw new IdError(`expected one of ${allowed.join("|")}, got ${JSON.stringify(raw)}`); - return raw as T[number]; + return raw; }, format: value => value }; diff --git a/src/framework/loader.ts b/src/framework/loader.ts index b5fa250..ccf81de 100644 --- a/src/framework/loader.ts +++ b/src/framework/loader.ts @@ -70,7 +70,7 @@ export async function loadCommands(directory: string): Promise // `export const components = [...]`. if (isRecord(module.command)) { const command = module.command as unknown as Command; - if (!isFn(command.execute)) throw new Error(`${path.basename(file)}: exported command has no execute()`); + if (typeof command.execute !== "function") throw new Error(`${path.basename(file)}: exported command has no execute()`); const components = Array.isArray(module.components) ? module.components as Component[] : []; const data = commandData(command.data, file); @@ -96,7 +96,7 @@ export async function loadCommands(directory: string): Promise const handlers: LegacyEntry["handlers"] = {}; for (const kind of LEGACY_KINDS) { const handler = legacyModule[kind]; - if (isFn(handler)) handlers[kind] = handler.bind(legacyModule) as LegacyEntry["handlers"][LegacyKind]; + if (isFn(handler)) handlers[kind] = handler.bind(legacyModule); } const entry: LegacyEntry = {name: data.name, ownerOnly: legacyModule.owner === true, handlers}; diff --git a/src/framework/session.ts b/src/framework/session.ts index d931408..9d85475 100644 --- a/src/framework/session.ts +++ b/src/framework/session.ts @@ -34,12 +34,12 @@ export interface SessionOptions { interaction: RepliableInteraction; initial: S; /** Pure: state in, message out. Called again after every accepted action. */ - render(state: S, options: {ended: boolean;}): InteractionEditReplyOptions; + render: (state: S, options: {ended: boolean;}) => InteractionEditReplyOptions; /** * Return the next state, or `undefined` to acknowledge without re-rendering. * `action` is whatever was passed to `sessionId()`. */ - reduce(action: string, state: S, interaction: MessageComponentInteraction): S | undefined | Promise; + reduce: (action: string, state: S, interaction: MessageComponentInteraction) => S | undefined | Promise; timeout?: number; /** Who may use the controls. Defaults to whoever ran the command. */ audience?: "invoker" | "anyone"; diff --git a/src/paginator.ts b/src/paginator.ts index 00a5e51..5dd3d7a 100644 --- a/src/paginator.ts +++ b/src/paginator.ts @@ -21,7 +21,7 @@ export interface PaginateOptions { interaction: RepliableInteraction; items: T[]; /** Top-level components for one page. Controls are appended automatically. */ - renderPage(items: T[], page: number, pages: number): readonly PageComponent[]; + renderPage: (items: T[], page: number, pages: number) => readonly PageComponent[]; perPage?: number; timeout?: number; audience?: "invoker" | "anyone"; From 08aa6f0e3a1b00280c6259870fb4d3433719a405 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:12:14 +0000 Subject: [PATCH 04/22] Add plain-object status notices to replace the djsx message widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three commits retiring djsx. src/util/notices.ts provides success/info/warn/error/danger as plain Components V2 container data, replacing the /// JSX widgets in djsx/widgets/Messages.tsx. Built on the existing framework/ui helpers, with accent colours derived from util/colors.ts so that stays the single source of truth. The returned `Notice` type was checked against reply(), editReply(), followUp() and update() โ€” it satisfies all four, which the djsx widgets did not: they were typed as `InteractionReplyOptions & InteractionEditReplyOptions` and needed an `as MessageOptions` cast at every call site. Verified the payloads are identical to the widgets they replace: all five kinds plus the ephemeral variants produce byte-identical JSON to the djsx output, compared while both implementations still exist. Nothing uses this yet; the next commit moves the tags command onto it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/util/notices.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/util/notices.ts diff --git a/src/util/notices.ts b/src/util/notices.ts new file mode 100644 index 0000000..d2301f0 --- /dev/null +++ b/src/util/notices.ts @@ -0,0 +1,61 @@ +/** + * Short status messages, as Components V2 containers. + * + * These replace the `` / `` / `` / `` JSX widgets + * from djsx and produce the same payload. + * + * NOTE: this is one of two message layers in the codebase right now. The other + * is the embed-based `Messages` class in `./messages.ts`, which the unmigrated + * commands still use. Collapsing them onto this one is the next pass. + */ + +import {MessageFlags, type ContainerComponentData} from "discord.js"; +import {container, text} from "../framework/ui"; +import Colors from "./colors"; + + +export type NoticeKind = "success" | "info" | "warn" | "error" | "danger"; + +export interface NoticeOptions { + ephemeral?: boolean; + /** Extra message flags to merge in. */ + flags?: number; +} + +/** Discord wants an integer for a container accent; Colors are authored as hex. */ +const accent = (hex: string): number => parseInt(hex.replace(/^#/, ""), 16); + +const ACCENTS: Record = { + success: accent(Colors.Success), + info: accent(Colors.Info), + warn: accent(Colors.Warn), + error: accent(Colors.Error), + danger: accent(Colors.Danger) +}; + +const ICONS: Record = { + success: ":white_check_mark:", + info: ":information_source:", + warn: ":warning:", + error: ":no_entry:", + danger: ":no_entry:" +}; + + +export interface Notice { + flags: number; + components: ContainerComponentData[]; +} + +export function notice(kind: NoticeKind, content: string, options: NoticeOptions = {}): Notice { + return { + flags: MessageFlags.IsComponentsV2 | (options.ephemeral ? MessageFlags.Ephemeral : 0) | (options.flags ?? 0), + components: [container([text(`${ICONS[kind]} ${content}`)], {accentColor: ACCENTS[kind]})] + }; +} + +export const success = (content: string, options?: NoticeOptions): Notice => notice("success", content, options); +export const info = (content: string, options?: NoticeOptions): Notice => notice("info", content, options); +export const warn = (content: string, options?: NoticeOptions): Notice => notice("warn", content, options); +export const error = (content: string, options?: NoticeOptions): Notice => notice("error", content, options); +export const danger = (content: string, options?: NoticeOptions): Notice => notice("danger", content, options); From 2ea409a9c2643e39dc1993c9f5bec7857428fa5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:14:56 +0000 Subject: [PATCH 05/22] Convert the tags command and component from JSX to plain objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of three commits retiring djsx. Nothing imports @djsx after this. src/commands/tags.tsx -> tags.ts, src/components/tags.tsx -> tags.ts. - Command metadata is now a plain RESTPostAPIChatInputApplicationCommandsJSONBody literal instead of //. - Message widgets move to util/notices.ts (added last commit). - wrappers become explicit {flags: MessageFlags.IsComponentsV2, components: [...]}. - and become ordinary function calls, tagContainer(tag) and updateTagModal(tag). Casts in these two files: 18 -> 1. Every JSX expression was typed as BaseComponentData (the single global JSX.Element type), so each one needed an `as` to recover its real type. Plain objects are inferred, so the casts go away and the fields are checked. The one remaining cast is in the modal `field` helper. discord.js still marks `label` required on TextInputComponentData even though the label now lives on the wrapping Label component. djsx omitted it via `Omit` plus a cast in ModalLabel, and that is what currently ships, so the payload is kept identical rather than adding an untested field. This was the one place the JSX layer was earning something, and it now costs six lines instead of a runtime. Verified against the pre-conversion files from 08aa6f0, comparing rendered payloads with key order normalised (the API does not care about key order): the deployed /tag command metadata, the tag container in all four title/thumbnail combinations, and the modal in both create and update states are byte-identical, 7/7. Two of those started out different and both were worth chasing: the command metadata differed only in key order, but the modal genuinely differed โ€” the first draft included `label` on the inner text input, which is the change described above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/{tags.tsx => tags.ts} | 104 ++++++++++++++++------------- src/components/tags.ts | 57 ++++++++++++++++ src/components/tags.tsx | 55 --------------- 3 files changed, 116 insertions(+), 100 deletions(-) rename src/commands/{tags.tsx => tags.ts} (54%) create mode 100644 src/components/tags.ts delete mode 100644 src/components/tags.tsx diff --git a/src/commands/tags.tsx b/src/commands/tags.ts similarity index 54% rename from src/commands/tags.tsx rename to src/commands/tags.ts index f0c1ce9..0eba6df 100644 --- a/src/commands/tags.tsx +++ b/src/commands/tags.ts @@ -1,29 +1,41 @@ -import {AutocompleteInteraction, ChatInputCommandInteraction, MessageFlags, type ModalComponentData} from "discord.js"; +import { + ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, + AutocompleteInteraction, ChatInputCommandInteraction, ComponentType, InteractionContextType, + MessageFlags, type RESTPostAPIChatInputApplicationCommandsJSONBody +} from "discord.js"; import type {AtLeast, Tag} from "../types"; import {tagsDB} from "../db"; import {msInMinute} from "../util/time"; -import {Tag as TagComponent, UpdateTagModal} from "../components/tags"; -import {ComponentMessage, Container, TextDisplay, type MessageOptions} from "@djsx"; -import {Error, Info, Success} from "@djsx/widgets/Messages"; -import {SlashCommand, StringOption, Subcommand} from "@djsx/commands/Command"; +import {tagContainer, updateTagModal} from "../components/tags"; +import {error, info, success} from "../util/notices"; + + +const nameOption = (description: string, autocomplete: boolean) => ({ + type: ApplicationCommandOptionType.String as const, + name: "name", + description, + required: true, + autocomplete +}); + +const data: RESTPostAPIChatInputApplicationCommandsJSONBody = { + type: ApplicationCommandType.ChatInput, + name: "tag", + description: "Saving and recalling custom tags.", + contexts: [InteractionContextType.Guild], + integration_types: [ApplicationIntegrationType.GuildInstall], + options: [ + {type: ApplicationCommandOptionType.Subcommand, name: "list", description: "List all tags in this server"}, + {type: ApplicationCommandOptionType.Subcommand, name: "view", description: "View a tag", options: [nameOption("Name of the tag to view", true)]}, + {type: ApplicationCommandOptionType.Subcommand, name: "update", description: "Update a tag", options: [nameOption("Name of the tag to update", true)]}, + {type: ApplicationCommandOptionType.Subcommand, name: "delete", description: "Delete a tag", options: [nameOption("Name of the tag to delete", true)]}, + {type: ApplicationCommandOptionType.Subcommand, name: "create", description: "Create a new tag", options: [nameOption("Name of the tag to create", false)]} + ] +}; export default { - data: - - - - - - - - - - - - - - , + data, /** * Main function for tag command @@ -36,7 +48,7 @@ export default { if (command === "delete") return await this.delete(interaction); if (command === "list") return await this.list(interaction); - return await interaction.reply(This command is not yet implemented. as MessageOptions); + return await interaction.reply(error("This command is not yet implemented.", {ephemeral: true})); }, async view(interaction: ChatInputCommandInteraction<"cached">) { @@ -45,48 +57,47 @@ export default { const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tag = guildTags[tagName]; if (!tag) { - return await interaction.editReply(Tag with name `{tagName}` does not exist. as MessageOptions); + return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); } - return await interaction.editReply( - ( - - ) as MessageOptions - ); + return await interaction.editReply({ + flags: MessageFlags.IsComponentsV2, + components: [tagContainer(tag)] + }); }, async create(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(You do not have permission to create tags. as MessageOptions); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to create tags.", {ephemeral: true})); const tagName = interaction.options.getString("name", true); const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tag = guildTags[tagName]; - if (tag) return await interaction.reply(Tag with name `{tagName}` already exists. as MessageOptions); + if (tag) return await interaction.reply(error(`Tag with name \`${tagName}\` already exists.`, {ephemeral: true})); return await this.showTagModal(interaction, {name: tagName}); }, async update(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(You do not have permission to update tags. as MessageOptions); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to update tags.", {ephemeral: true})); const tagName = interaction.options.getString("name", true); const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tag = guildTags[tagName]; - if (!tag) return await interaction.reply(Tag with name `{tagName}` does not exist. as MessageOptions); + if (!tag) return await interaction.reply(error(`Tag with name \`${tagName}\` does not exist.`, {ephemeral: true})); return await this.showTagModal(interaction, tag); }, async delete(interaction: ChatInputCommandInteraction<"cached">) { await interaction.deferReply({flags: MessageFlags.Ephemeral}); - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to delete tags. as MessageOptions); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(error("You do not have permission to delete tags.")); const tagName = interaction.options.getString("name", true); const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tag = guildTags[tagName]; if (!tag) { - return await interaction.editReply(Tag with name `{tagName}` does not exist. as MessageOptions); + return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); } delete guildTags[tagName]; await tagsDB.set(interaction.guildId, guildTags); - return await interaction.editReply(Tag with name `{tagName}` has been deleted. as MessageOptions); + return await interaction.editReply(success(`Tag with name \`${tagName}\` has been deleted.`)); }, async list(interaction: ChatInputCommandInteraction<"cached">) { @@ -94,23 +105,26 @@ export default { const guildTags = await tagsDB.get(interaction.guildId) ?? {}; const tagNames = Object.keys(guildTags); if (tagNames.length === 0) { - return await interaction.editReply(There are no tags in this server yet. as MessageOptions); + return await interaction.editReply(info("There are no tags in this server yet.")); } - return await interaction.editReply( - - - {`**Tags in this server:**\n${tagNames.map(name => `- \`${name}\``).join("\n")}`} - - as MessageOptions - ); + return await interaction.editReply({ + flags: MessageFlags.IsComponentsV2, + components: [{ + type: ComponentType.Container, + components: [{ + type: ComponentType.TextDisplay, + content: `**Tags in this server:**\n${tagNames.map(name => `- \`${name}\``).join("\n")}` + }] + }] + }); }, async showTagModal(interaction: ChatInputCommandInteraction<"cached">, tag: AtLeast) { const isUpdating = !!tag.content; - await interaction.showModal( as ModalComponentData); + await interaction.showModal(updateTagModal(tag)); try { const modalInteraction = await interaction.awaitModalSubmit({time: msInMinute * 5}); @@ -127,10 +141,10 @@ export default { }; await tagsDB.set(interaction.guildId, guildTags); - await modalInteraction.reply(Tag `{tag.name}` has been {isUpdating ? "updated" : "created"} successfully! as MessageOptions); + await modalInteraction.reply(success(`Tag \`${tag.name}\` has been ${isUpdating ? "updated" : "created"} successfully!`)); } catch { - await interaction.followUp(Modal submission timed out! as MessageOptions); + await interaction.followUp(error("Modal submission timed out!")); } }, diff --git a/src/components/tags.ts b/src/components/tags.ts new file mode 100644 index 0000000..a3ddad7 --- /dev/null +++ b/src/components/tags.ts @@ -0,0 +1,57 @@ +import { + ComponentType, TextInputStyle, + type ComponentInContainerData, type ContainerComponentData, type LabelComponentData, + type ModalComponentData, type TextDisplayComponentData +} from "discord.js"; +import type {AtLeast, Tag} from "../types"; + + +/** A tag rendered as a container, with an optional thumbnail alongside the text. */ +export function tagContainer(tag: Tag): ContainerComponentData { + const body: TextDisplayComponentData[] = []; + if (tag.title) body.push({type: ComponentType.TextDisplay, content: `# ${tag.title}`}); + body.push({type: ComponentType.TextDisplay, content: tag.content}); + + const components: ComponentInContainerData[] = tag.thumbnailUrl + ? [{ + type: ComponentType.Section, + components: body, + accessory: {type: ComponentType.Thumbnail, media: {url: tag.thumbnailUrl}} + }] + : body; + + return {type: ComponentType.Container, components}; +} + + +/** + * discord.js still marks `label` required on TextInputComponentData, even though + * the label now lives on the wrapping Label component. The djsx version omitted + * it (via `Omit` plus a cast in ModalLabel) and + * that is what currently ships, so we keep the payload identical rather than + * introduce an untested field. + * + * This is the one cast left in the tags code, down from eighteen, and it is + * confined to this helper. + */ +function field(customId: string, label: string, style: TextInputStyle, required: boolean, maxLength: number, value: string): LabelComponentData { + return { + type: ComponentType.Label, + label, + component: {type: ComponentType.TextInput, customId, style, required, maxLength, value} as LabelComponentData["component"] + }; +} + + +export function updateTagModal(tag: AtLeast): ModalComponentData { + const isUpdating = !!tag.content; + return { + customId: "tagmodal", + title: `${isUpdating ? "Update" : "Create"} Tag: ${tag.name}`, + components: [ + field("title", "Tag Title", TextInputStyle.Short, false, 100, tag.title || ""), + field("content", "Tag Content", TextInputStyle.Paragraph, true, 2000, tag.content || ""), + field("thumbnail", "Tag Thumbnail URL", TextInputStyle.Short, false, 2000, tag.thumbnailUrl || "") + ] + }; +} diff --git a/src/components/tags.tsx b/src/components/tags.tsx deleted file mode 100644 index c84c6bb..0000000 --- a/src/components/tags.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import {Container, TextDisplay, Section, Thumbnail, Modal, ModalLabel, TextInput, TextInputStyle} from "@djsx"; -import {type ContainerComponentData, type ModalComponentData} from "discord.js"; -import type {AtLeast, Tag} from "../types"; - - -export function Tag(tag: Tag) { - const text = <> - {tag.title && {`# ${tag.title}`}} - {tag.content} - ; - - const container = - {tag.thumbnailUrl - ?
}> - {text} -
- : text - } -
; - - return container as ContainerComponentData; -} - -export function UpdateTagModal(tag: AtLeast) { - const isUpdating = !!tag.content; - return ( - - - - - - - - - - ) as ModalComponentData; -} \ No newline at end of file From 8082dd3d381817ce39a8f00e6f006c19be9991df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:16:27 +0000 Subject: [PATCH 06/22] Delete djsx and its toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of three commits retiring djsx. Nothing has imported it since the previous commit. Removed: - djsx/ โ€” 29 files, 736 lines. Eleven of its twenty-four components were `(props) => ({type: X, ...props})`; MediaItem and StringOption were identity functions. - tsconfig.bun.json โ€” existed only to override `jsx` because Bun would not read "react-jsx" from tsconfig, with a comment noting bunfig.toml "doesn't seem to work". - bunfig.toml โ€” contained nothing but the four jsx keys. - The `"react": "./djsx/index.ts"` override in package.json, which made every `bun install` print "Bun currently does not support nested overrides". - `--tsconfig-override tsconfig.bun.json` from all four npm scripts. - `jsx`, `jsxImportSource`, the @djsx path aliases, and the djsx include globs from tsconfig.json. - The `**/*.tsx` block in eslint.config.js, which disabled no-unsafe-assignment and no-unsafe-argument. Those rules now apply everywhere, and the codebase passes with them on. Why, in one line: TypeScript has a single global JSX.Element type, declared here as BaseComponentData, so every JSX expression needed an `as` to recover its real type. The readability win cost the type checker at exactly the boundary where mistakes are most expensive. Note for local checkouts: .gitignore lists src/commands/debug.tsx, and the deleted djsx/commands/Command.tsx had a matching `debug()` helper, so a local debug command probably imports @djsx and will need converting. The loader still scans for .tsx as well as .ts, so such a file fails loudly at startup rather than silently disappearing. Verified from a clean node_modules: bun install --frozen-lockfile succeeds with no warnings, bun run typecheck and bun run lint both exit 0, no .tsx files remain, and the loader still registers 10 commands, 3 components and 11 event listeners running under the plain `bun run --bun` invocation the scripts now use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- bunfig.toml | 4 - djsx/ActionRow.tsx | 13 -- djsx/Button.tsx | 16 --- djsx/ChannelSelect.tsx | 11 -- djsx/ComponentMessage.tsx | 14 -- djsx/Container.tsx | 13 -- djsx/File.tsx | 14 -- djsx/FileUpload.tsx | 11 -- djsx/JSX.d.ts | 14 -- djsx/Label.tsx | 14 -- djsx/MediaGallery.tsx | 13 -- djsx/MediaGalleryItem.tsx | 13 -- djsx/MediaItem.tsx | 8 -- djsx/MentionableSelect.tsx | 11 -- djsx/Modal.tsx | 12 -- djsx/RoleSelect.tsx | 11 -- djsx/Section.tsx | 13 -- djsx/Separator.tsx | 11 -- djsx/StringSelect.tsx | 18 --- djsx/TextDisplay.tsx | 20 --- djsx/TextInput.tsx | 12 -- djsx/Thumbnail.tsx | 12 -- djsx/UserSelect.tsx | 11 -- djsx/commands/Command.tsx | 274 ------------------------------------- djsx/index.ts | 30 ---- djsx/jsx-dev-runtime.ts | 42 ------ djsx/jsx-runtime.ts | 42 ------ djsx/utils.ts | 42 ------ djsx/widgets/Messages.tsx | 48 ------- eslint.config.js | 7 - package.json | 11 +- tsconfig.bun.json | 8 -- tsconfig.json | 7 - 33 files changed, 5 insertions(+), 795 deletions(-) delete mode 100644 bunfig.toml delete mode 100644 djsx/ActionRow.tsx delete mode 100644 djsx/Button.tsx delete mode 100644 djsx/ChannelSelect.tsx delete mode 100644 djsx/ComponentMessage.tsx delete mode 100644 djsx/Container.tsx delete mode 100644 djsx/File.tsx delete mode 100644 djsx/FileUpload.tsx delete mode 100644 djsx/JSX.d.ts delete mode 100644 djsx/Label.tsx delete mode 100644 djsx/MediaGallery.tsx delete mode 100644 djsx/MediaGalleryItem.tsx delete mode 100644 djsx/MediaItem.tsx delete mode 100644 djsx/MentionableSelect.tsx delete mode 100644 djsx/Modal.tsx delete mode 100644 djsx/RoleSelect.tsx delete mode 100644 djsx/Section.tsx delete mode 100644 djsx/Separator.tsx delete mode 100644 djsx/StringSelect.tsx delete mode 100644 djsx/TextDisplay.tsx delete mode 100644 djsx/TextInput.tsx delete mode 100644 djsx/Thumbnail.tsx delete mode 100644 djsx/UserSelect.tsx delete mode 100644 djsx/commands/Command.tsx delete mode 100644 djsx/index.ts delete mode 100644 djsx/jsx-dev-runtime.ts delete mode 100644 djsx/jsx-runtime.ts delete mode 100644 djsx/utils.ts delete mode 100644 djsx/widgets/Messages.tsx delete mode 100644 tsconfig.bun.json diff --git a/bunfig.toml b/bunfig.toml deleted file mode 100644 index 831356b..0000000 --- a/bunfig.toml +++ /dev/null @@ -1,4 +0,0 @@ -jsx = "react-jsx" -jsxFactory = "createElement" -jsxFragment = "Fragment" -jsxImportSource = "@djsx" \ No newline at end of file diff --git a/djsx/ActionRow.tsx b/djsx/ActionRow.tsx deleted file mode 100644 index feb19a6..0000000 --- a/djsx/ActionRow.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {ComponentType, type ActionRowComponentData, type ActionRowData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type ActionRowProps = Omit, "type" | "components"> & {children: ActionRowComponentData | ActionRowComponentData[];}; - -export function ActionRow({children, ...props}: ActionRowProps): ActionRowData { - return { - type: ComponentType.ActionRow, - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/Button.tsx b/djsx/Button.tsx deleted file mode 100644 index f0c748c..0000000 --- a/djsx/Button.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {ComponentType, type ButtonComponentData, type InteractionButtonComponentData, type LinkButtonComponentData} from "discord.js"; -import {childrenToString} from "./utils"; - - -export {ButtonStyle} from "discord.js"; - -type Button = Omit | Omit; -export type ButtonProps = Button & {children: string;}; - -export function Button({children, ...props}: ButtonProps): ButtonComponentData { - return { - type: ComponentType.Button, - label: childrenToString("Button", children) ?? undefined, - ...props - }; -} \ No newline at end of file diff --git a/djsx/ChannelSelect.tsx b/djsx/ChannelSelect.tsx deleted file mode 100644 index 3b2571b..0000000 --- a/djsx/ChannelSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type ChannelSelectMenuComponentData} from "discord.js"; - - -export type ChannelSelectProps = Omit; - -export function ChannelSelect(props: ChannelSelectProps): ChannelSelectMenuComponentData { - return { - type: ComponentType.ChannelSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/ComponentMessage.tsx b/djsx/ComponentMessage.tsx deleted file mode 100644 index afa50e2..0000000 --- a/djsx/ComponentMessage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {MessageFlags, type BaseMessageOptions, type InteractionEditReplyOptions, type InteractionReplyOptions} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type MessageOptions = InteractionReplyOptions & InteractionEditReplyOptions; -export type ComponentMessageProps = Omit & {children: Required["components"]; flags?: number;}; - -export function ComponentMessage({children, flags, ...props}: ComponentMessageProps): MessageOptions { - return { - flags: MessageFlags.IsComponentsV2 | (flags ?? 0), - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/Container.tsx b/djsx/Container.tsx deleted file mode 100644 index 64cfa26..0000000 --- a/djsx/Container.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {ComponentType, type ContainerComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type ContainerProps = Omit & {children: ContainerComponentData["components"][0];}; - -export function Container({children, ...props}: ContainerProps): ContainerComponentData { - return { - type: ComponentType.Container, - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/File.tsx b/djsx/File.tsx deleted file mode 100644 index b6d8fcb..0000000 --- a/djsx/File.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {ComponentType, type FileComponentData, type UnfurledMediaItemData} from "discord.js"; -import {MediaItem} from "./MediaItem"; - - -export type FileProps = Omit & {filename: string;}; - -export function File({filename, id, spoiler}: FileProps): FileComponentData { - return { - type: ComponentType.File, - id, - spoiler, - file: () as UnfurledMediaItemData, - }; -} \ No newline at end of file diff --git a/djsx/FileUpload.tsx b/djsx/FileUpload.tsx deleted file mode 100644 index 97929c3..0000000 --- a/djsx/FileUpload.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type FileUploadModalData} from "discord.js"; - - -export type FileUploadProps = Omit; - -export function FileUpload({...props}: FileUploadProps): FileUploadModalData { - return { - type: ComponentType.FileUpload, - ...props - }; -} \ No newline at end of file diff --git a/djsx/JSX.d.ts b/djsx/JSX.d.ts deleted file mode 100644 index 9c41a31..0000000 --- a/djsx/JSX.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {BaseComponentData} from "discord.js"; - -declare namespace JSX { - interface ElementChildrenAttribute { - children: unknown; - } - - // type Element = any; - - type Element = - | BaseComponentData; - // | ReturnType; - // | ReturnType; -} \ No newline at end of file diff --git a/djsx/Label.tsx b/djsx/Label.tsx deleted file mode 100644 index d8a3141..0000000 --- a/djsx/Label.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {ComponentType, type LabelComponentData} from "discord.js"; -import {singleChild} from "./utils"; - - -export type LabelProps = Omit & {children: LabelComponentData["component"];}; - -export function ModalLabel({children, ...restProps}: LabelProps): LabelComponentData { - // console.log("ModalLabel called with children:", children); - return { - type: ComponentType.Label, - component: singleChild("ModalLabel", children), - ...restProps - }; -} \ No newline at end of file diff --git a/djsx/MediaGallery.tsx b/djsx/MediaGallery.tsx deleted file mode 100644 index 23699da..0000000 --- a/djsx/MediaGallery.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {ComponentType, type MediaGalleryComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type MediaGalleryProps = Omit & {children: MediaGalleryComponentData["items"];}; - -export function MediaGallery({children, ...props}: MediaGalleryProps): MediaGalleryComponentData { - return { - type: ComponentType.MediaGallery, - items: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/MediaGalleryItem.tsx b/djsx/MediaGalleryItem.tsx deleted file mode 100644 index d84c087..0000000 --- a/djsx/MediaGalleryItem.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type {MediaGalleryItemData, UnfurledMediaItemData} from "discord.js"; -import {MediaItem} from "./MediaItem"; - - -export type MediaGalleryItemProps = Omit & {url: string;}; - -export function MediaGalleryItem({url, description, spoiler}: MediaGalleryItemProps): MediaGalleryItemData { - return { - media: as UnfurledMediaItemData, - description, - spoiler, - }; -} \ No newline at end of file diff --git a/djsx/MediaItem.tsx b/djsx/MediaItem.tsx deleted file mode 100644 index ff3c04b..0000000 --- a/djsx/MediaItem.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {UnfurledMediaItemData} from "discord.js"; - - -export type MediaItemProps = UnfurledMediaItemData; - -export function MediaItem(props: MediaItemProps): UnfurledMediaItemData { - return props; -} \ No newline at end of file diff --git a/djsx/MentionableSelect.tsx b/djsx/MentionableSelect.tsx deleted file mode 100644 index 9642bdc..0000000 --- a/djsx/MentionableSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type MentionableSelectMenuComponentData} from "discord.js"; - - -export type MentionableSelectProps = Omit; - -export function MentionableSelect(props: MentionableSelectProps): MentionableSelectMenuComponentData { - return { - type: ComponentType.MentionableSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/Modal.tsx b/djsx/Modal.tsx deleted file mode 100644 index 61cf138..0000000 --- a/djsx/Modal.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {type ModalComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type ModalProps = Omit & {children: ModalComponentData["components"];}; - -export function Modal({children, ...props}: ModalProps): ModalComponentData { - return { - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/RoleSelect.tsx b/djsx/RoleSelect.tsx deleted file mode 100644 index c72d3a9..0000000 --- a/djsx/RoleSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type RoleSelectMenuComponentData} from "discord.js"; - - -export type RoleSelectProps = Omit; - -export function RoleSelect(props: RoleSelectProps): RoleSelectMenuComponentData { - return { - type: ComponentType.RoleSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/Section.tsx b/djsx/Section.tsx deleted file mode 100644 index 0fd0f53..0000000 --- a/djsx/Section.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {childrenToArray} from "./utils"; -import {ComponentType, type SectionComponentData} from "discord.js"; - - -export type SectionProps = Omit & {children: SectionComponentData["components"][0];}; - -export function Section({children, ...props}: SectionProps): SectionComponentData { - return { - type: ComponentType.Section, - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/Separator.tsx b/djsx/Separator.tsx deleted file mode 100644 index b7e9475..0000000 --- a/djsx/Separator.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type SeparatorComponentData} from "discord.js"; - - -export type SeparatorProps = Omit; - -export function Separator(props: SeparatorProps): SeparatorComponentData { - return { - type: ComponentType.Separator, - ...props - }; -} \ No newline at end of file diff --git a/djsx/StringSelect.tsx b/djsx/StringSelect.tsx deleted file mode 100644 index 7a1b49e..0000000 --- a/djsx/StringSelect.tsx +++ /dev/null @@ -1,18 +0,0 @@ - - -import {ComponentType, type SelectMenuComponentOptionData, type StringSelectMenuComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type StringSelectProps = Omit & {children: StringSelectMenuComponentData["options"];}; -export function StringSelect({children, ...props}: StringSelectProps): StringSelectMenuComponentData { - return { - type: ComponentType.StringSelect, - options: childrenToArray(children), - ...props - }; -} - -export function StringOption(props: SelectMenuComponentOptionData): SelectMenuComponentOptionData { - return props; -} \ No newline at end of file diff --git a/djsx/TextDisplay.tsx b/djsx/TextDisplay.tsx deleted file mode 100644 index 8eea3ed..0000000 --- a/djsx/TextDisplay.tsx +++ /dev/null @@ -1,20 +0,0 @@ - - -import {ComponentType, type TextDisplayComponentData} from "discord.js"; -import {childrenToString} from "./utils"; - - -export type TextDisplayProps = Omit & {children: string | string[];}; - -export function TextDisplay({children, id}: TextDisplayProps): TextDisplayComponentData { - const content = childrenToString("TextDisplay", children)!; - if (!content) { - throw new Error("TextDisplay requires at least one child"); - } - - return { - type: ComponentType.TextDisplay, - content, - id, - }; -} \ No newline at end of file diff --git a/djsx/TextInput.tsx b/djsx/TextInput.tsx deleted file mode 100644 index dbbfb8e..0000000 --- a/djsx/TextInput.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {ComponentType, type TextInputComponentData} from "discord.js"; - - -export {TextInputStyle} from "discord.js"; -export type TextInputProps = Omit; - -export function TextInput(props: TextInputProps): Omit { - return { - type: ComponentType.TextInput, - ...props - }; -} \ No newline at end of file diff --git a/djsx/Thumbnail.tsx b/djsx/Thumbnail.tsx deleted file mode 100644 index 23ca191..0000000 --- a/djsx/Thumbnail.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {ComponentType, type ThumbnailComponentData} from "discord.js"; - - -export type ThumbnailProps = Omit & {url: string;}; - -export function Thumbnail({url, ...props}: ThumbnailProps): ThumbnailComponentData { - return { - type: ComponentType.Thumbnail, - media: {url}, - ...props - }; -} \ No newline at end of file diff --git a/djsx/UserSelect.tsx b/djsx/UserSelect.tsx deleted file mode 100644 index 1e68f1d..0000000 --- a/djsx/UserSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type UserSelectMenuComponentData} from "discord.js"; - - -export type UserSelectProps = Omit; - -export function UserSelect(props: UserSelectProps): UserSelectMenuComponentData { - return { - type: ComponentType.UserSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/commands/Command.tsx b/djsx/commands/Command.tsx deleted file mode 100644 index 9230bba..0000000 --- a/djsx/commands/Command.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import {ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, InteractionContextType, type APIApplicationCommandAttachmentOption, type APIApplicationCommandBasicOption, type APIApplicationCommandBooleanOption, type APIApplicationCommandChannelOption, type APIApplicationCommandIntegerOption, type APIApplicationCommandMentionableOption, type APIApplicationCommandNumberOption, type APIApplicationCommandOption, type APIApplicationCommandRoleOption, type APIApplicationCommandStringOption, type APIApplicationCommandSubcommandGroupOption, type APIApplicationCommandSubcommandOption, type APIApplicationCommandUserOption, type RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; - - -interface SlashCommandShortcuts { - guildContext?: boolean; - botDMContext?: boolean; - privateContext?: boolean; - guildInstall?: boolean; - userInstall?: boolean; -} - -export type SlashCommandProps = RESTPostAPIChatInputApplicationCommandsJSONBody & {children?: APIApplicationCommandOption | APIApplicationCommandOption[];} & SlashCommandShortcuts; - -export function SlashCommand({children, ...props}: SlashCommandProps): RESTPostAPIChatInputApplicationCommandsJSONBody { - const data = props; - - // Add options if any children were provided - if (Array.isArray(children)) data.options = children; - else if (children) data.options = [children]; - - // console.log(children); - - // Use shortcuts to set contexts - const contexts = []; - if (props.guildContext) contexts.push(InteractionContextType.Guild); - if (props.botDMContext) contexts.push(InteractionContextType.BotDM); - if (props.privateContext) contexts.push(InteractionContextType.PrivateChannel); - - // Use shortcuts to set integration_types - const integration_types = []; - if (props.guildInstall) integration_types.push(ApplicationIntegrationType.GuildInstall); - if (props.userInstall) integration_types.push(ApplicationIntegrationType.UserInstall); - - // Clean up the shortcut properties - delete data.guildContext; - delete data.botDMContext; - delete data.privateContext; - delete data.guildInstall; - delete data.userInstall; - - // Apply contexts and integration_types if any were set - if (contexts.length) data.contexts = contexts; - if (integration_types.length) data.integration_types = integration_types; - - return { - type: ApplicationCommandType.ChatInput, - ...data, - }; -} - - -export type SubcommandGroupProps = Omit & {children?: APIApplicationCommandSubcommandOption | APIApplicationCommandSubcommandOption[];}; - -export function SubcommandGroup({children, ...props}: SubcommandGroupProps): APIApplicationCommandSubcommandGroupOption { - const data = props; - - // Add options if any children were provided - if (Array.isArray(children)) data.options = children; - else if (children) data.options = [children]; - - return { - type: ApplicationCommandOptionType.SubcommandGroup, - ...data, - }; -} - - -export type SubcommandProps = Omit & {children?: APIApplicationCommandBasicOption | APIApplicationCommandBasicOption[];}; - -export function Subcommand({children, ...props}: SubcommandProps): APIApplicationCommandSubcommandOption { - const data = props; - - // Add options if any children were provided - if (Array.isArray(children)) data.options = children; - else if (children) data.options = [children]; - - return { - type: ApplicationCommandOptionType.Subcommand, - ...data, - }; -} - -/** - * Still left to implement: - * APIApplicationCommandAttachmentOption - * APIApplicationCommandBooleanOption - * APIApplicationCommandChannelOption - * APIApplicationCommandIntegerOption - * APIApplicationCommandMentionableOption - * APIApplicationCommandNumberOption - * APIApplicationCommandRoleOption - * APIApplicationCommandStringOption - * APIApplicationCommandUserOption - */ - - -export type AttachmentOptionProps = Omit; -export function AttachmentOption(props: AttachmentOptionProps): APIApplicationCommandAttachmentOption { - return { - type: ApplicationCommandOptionType.Attachment, - ...props, - }; -} - -export type BooleanOptionProps = Omit; -export function BooleanOption(props: BooleanOptionProps): APIApplicationCommandBooleanOption { - return { - type: ApplicationCommandOptionType.Boolean, - ...props, - }; -} - -export type ChannelOptionProps = Omit; -export function ChannelOption(props: ChannelOptionProps): APIApplicationCommandChannelOption { - return { - type: ApplicationCommandOptionType.Channel, - ...props, - }; -} - -export type IntegerOptionProps = Omit; -export function IntegerOption(props: IntegerOptionProps): APIApplicationCommandIntegerOption { - if (props.choices && props.choices.length) { - return { - type: ApplicationCommandOptionType.Integer, - ...props, - autocomplete: false - }; - } - - return { - type: ApplicationCommandOptionType.Integer, - ...props, - choices: undefined, - autocomplete: props.autocomplete - }; -} - -export type MentionableOptionProps = Omit; -export function MentionableOption(props: MentionableOptionProps): APIApplicationCommandMentionableOption { - return { - type: ApplicationCommandOptionType.Mentionable, - ...props, - }; -} - -export type NumberOptionProps = Omit; -export function NumberOption(props: NumberOptionProps): APIApplicationCommandNumberOption { - if (props.choices && props.choices.length) { - return { - type: ApplicationCommandOptionType.Number, - ...props, - autocomplete: false - }; - } - - return { - type: ApplicationCommandOptionType.Number, - ...props, - choices: undefined, - autocomplete: props.autocomplete - }; -} - -export type RoleOptionProps = Omit; -export function RoleOption(props: RoleOptionProps): APIApplicationCommandRoleOption { - return { - type: ApplicationCommandOptionType.Role, - ...props, - }; -} - -export type StringOptionProps = Omit; -export function StringOption(props: StringOptionProps): APIApplicationCommandStringOption { - if (props.choices && props.choices.length) { - return { - type: ApplicationCommandOptionType.String, - ...props, - autocomplete: false - }; - } - - return { - type: ApplicationCommandOptionType.String, - ...props, - choices: undefined, - autocomplete: props.autocomplete - }; -} - -export type UserOptionProps = Omit; -export function UserOption(props: UserOptionProps): APIApplicationCommandUserOption { - return { - type: ApplicationCommandOptionType.User, - ...props, - }; -} - - - - - - - - - - - - - - - - - - - - -export function test() { - return - - - - - - - - - - - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - - -export function test2() { - return - - - - - - - - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - -export function test3() { - return - - - - - - - - - - - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - - -export function debug() { - return - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - - -// const result = debug(); - -// console.log(JSON.stringify(result, null, 4)); \ No newline at end of file diff --git a/djsx/index.ts b/djsx/index.ts deleted file mode 100644 index 268204c..0000000 --- a/djsx/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export * from "./jsx-runtime"; -export * from "./ComponentMessage"; - -export * from "./ActionRow"; -export * from "./Button"; - - -export * from "./Container"; -export * from "./Section"; -export * from "./Separator"; - -export * from "./TextDisplay"; -export * from "./Label.tsx"; -export * from "./Modal.tsx"; - -export * from "./File"; -export * from "./FileUpload"; - -export * from "./MediaGallery"; -export * from "./MediaGalleryItem"; -export * from "./MediaItem"; -export * from "./Thumbnail.tsx"; - - -export * from "./ChannelSelect"; -export * from "./MentionableSelect"; -export * from "./RoleSelect"; -export * from "./StringSelect"; -export * from "./TextInput"; -export * from "./UserSelect"; diff --git a/djsx/jsx-dev-runtime.ts b/djsx/jsx-dev-runtime.ts deleted file mode 100644 index 9a56ded..0000000 --- a/djsx/jsx-dev-runtime.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * This system provides a minimal JSX runtime for creating component structures. - * It supports basic elements like line breaks and fragments, as well as function components. - * - * It was adapted from Venbot with permission https://github.com/Vencord/venbot/blob/main/LICENSE - */ - -export const Fragment = Symbol("ComponentsJsx.Fragment"); - -type FunctionComponent = (props: P) => R; - -export function createElement

(type: "br" | typeof Fragment | FunctionComponent, props: P, ...children: Array): R { - - // Normalize props and children - props ??= {} as P; - if (children.length > 0) props.children = children; - - switch (type) { - case "br": - return "\n" as R; - case Fragment: - return props.children as R; - } - - return type(props); -} - -export const jsx = createElement; -export const jsxs = createElement; -export const jsxDEV = createElement; - -// function logAndReturn(name: string) { -// return (type, props, ...children) => { -// console.log(name, "called"); -// console.log("createElement called with type:", type, "props:", props, "children:", children); -// return createElement(type, props, ...children); -// }; -// } - -// export const jsx = logAndReturn("jsx"); -// export const jsxs = logAndReturn("jsxs"); -// export const jsxDEV = logAndReturn("jsxDEV"); \ No newline at end of file diff --git a/djsx/jsx-runtime.ts b/djsx/jsx-runtime.ts deleted file mode 100644 index 9a56ded..0000000 --- a/djsx/jsx-runtime.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * This system provides a minimal JSX runtime for creating component structures. - * It supports basic elements like line breaks and fragments, as well as function components. - * - * It was adapted from Venbot with permission https://github.com/Vencord/venbot/blob/main/LICENSE - */ - -export const Fragment = Symbol("ComponentsJsx.Fragment"); - -type FunctionComponent = (props: P) => R; - -export function createElement

(type: "br" | typeof Fragment | FunctionComponent, props: P, ...children: Array): R { - - // Normalize props and children - props ??= {} as P; - if (children.length > 0) props.children = children; - - switch (type) { - case "br": - return "\n" as R; - case Fragment: - return props.children as R; - } - - return type(props); -} - -export const jsx = createElement; -export const jsxs = createElement; -export const jsxDEV = createElement; - -// function logAndReturn(name: string) { -// return (type, props, ...children) => { -// console.log(name, "called"); -// console.log("createElement called with type:", type, "props:", props, "children:", children); -// return createElement(type, props, ...children); -// }; -// } - -// export const jsx = logAndReturn("jsx"); -// export const jsxs = logAndReturn("jsxs"); -// export const jsxDEV = logAndReturn("jsxDEV"); \ No newline at end of file diff --git a/djsx/utils.ts b/djsx/utils.ts deleted file mode 100644 index 53c5955..0000000 --- a/djsx/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -const isFalseOrNullish = (value: unknown) => value === false || value == null; -const isNotFalseOrNullish = (value: unknown) => !isFalseOrNullish(value); - -export function transformChildrenArray(children: ReadonlyArray): T[] { - return children.flat(Infinity).filter(isNotFalseOrNullish) as T[]; -} - -export function childrenToString(name: string, children: string | string[] | null): string | null { - if (Array.isArray(children)) { - return transformChildrenArray(children).join(""); - } - if (typeof children === "string") { - return children; - } - if (isFalseOrNullish(children)) { - return null; - } - throw new Error(`${name} children must be a string or an array of strings`); -} - -export function childrenToArray(children: T | readonly T[]): T[] { - if (Array.isArray(children)) { - return transformChildrenArray(children as ReadonlyArray); - } - if (isFalseOrNullish(children)) { - return []; - } - return [children as T]; -} - -export function singleChild(name: string, children: T | T[]): T { - if (!Array.isArray(children)) return children; - if (Array.isArray(children) && children.length !== 1) { - throw new Error(`${name} must have exactly one child`); - } - - return children[0]; -} - -export function hexToDecimal(hex: string): number { - return parseInt(hex.replace(/^#/, ""), 16); -} \ No newline at end of file diff --git a/djsx/widgets/Messages.tsx b/djsx/widgets/Messages.tsx deleted file mode 100644 index 07c6b3a..0000000 --- a/djsx/widgets/Messages.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {MessageFlags, type ContainerComponentData, type InteractionEditReplyOptions, type InteractionReplyOptions} from "discord.js"; -import {Container} from "../Container"; -import {TextDisplay} from "../TextDisplay"; -import {hexToDecimal} from "../utils"; - - -export type MessageOptions = InteractionReplyOptions & InteractionEditReplyOptions; -export interface BasicMessageProps { - children: string | string[]; - flags?: number; - ephemeral?: boolean; - color?: string | number; -} - -export function Basic({children, flags, ephemeral, color}: BasicMessageProps): MessageOptions { - flags ??= 0; - if (ephemeral) flags |= MessageFlags.Ephemeral; - if (typeof color === "string") color = hexToDecimal(color); - - return { - flags: MessageFlags.IsComponentsV2 | flags, - components: [ - - {children} - as ContainerComponentData] - }; -} - - -export function Success(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Info(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Warn(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Error(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Danger(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 903913d..d2e3948 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,12 +15,5 @@ export default defineConfig( }, { ignores: ["**/debug/**", "**/node_modules/**"] - }, - { - files: ["**/*.tsx"], - rules: { - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off" - } } ); \ No newline at end of file diff --git a/package.json b/package.json index f8926cf..f8ff645 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "type": "module", "scripts": { "test": "echo \"No test suite yet\" && exit 0", - "start": "bun run --tsconfig-override tsconfig.bun.json --bun src/index.ts", - "deploy": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/deploy-commands.ts", - "clear": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/deploy-commands.ts --clear", - "validate": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/validate-env.ts", + "start": "bun run --bun src/index.ts", + "deploy": "bun run --bun scripts/deploy-commands.ts", + "clear": "bun run --bun scripts/deploy-commands.ts --clear", + "validate": "bun run --bun scripts/validate-env.ts", "typecheck": "tsc --noEmit", "lint": "eslint ." }, @@ -37,7 +37,6 @@ "overrides": { "sqlite3": { "prebuild-install": "7.1.3" - }, - "react": "./djsx/index.ts" + } } } diff --git a/tsconfig.bun.json b/tsconfig.bun.json deleted file mode 100644 index da663aa..0000000 --- a/tsconfig.bun.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - // Override the jsx setting for Bun, which doesn't support the "react-jsx" setting - // But when doing this in bunfig.toml, it doesn't seem to work, so we have to do it here instead - "jsx": "preserve" - } -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 9a757d3..f93fd39 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,6 @@ "target": "ESNext", "module": "ESNext", "moduleDetection": "force", - // "jsx": "preserve", - "jsx": "react-jsx", - "jsxImportSource": "@djsx", "allowJs": false, // Bundler mode @@ -35,16 +32,12 @@ "paths": { "@": ["./src/index.ts"], "@/*": ["./src/*"], - "@djsx": ["./djsx/index.ts"], - "@djsx/*": ["./djsx/*"], } }, "include": [ "src/**/*", "scripts/*", "tests/**/*", - "djsx/**/*", - "djsx/*", // "debug/test.tsx", // "debug/test2.tsx" ], From 62c46d3e41824b6f0511d1b82983249cfb79f43f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:21:37 +0000 Subject: [PATCH 07/22] Move the straightforward message call sites onto notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three commits collapsing the message layers. The embed-based `Messages` class and the Components V2 `notices` module were two ways to send the same status message; this moves everything that can move cleanly. Migrated: joinleave, spam, addons, botadmin, developer, moderation, voicetext โ€” 42 call sites. The transformation is one token per site because the option shapes already matched: a survey of every call showed only `{ephemeral: true}` and `{components: [...]}` were ever passed. notices gained a `components` option, which renders action rows inside the container rather than beside the embed, since that is where V2 puts them. Imported as a namespace (`import * as notices`) rather than named imports. `error` as a bare identifier would shadow, or be shadowed by, the `error` binding in nearby catch blocks; `notices.error(...)` also keeps the diff to a single token per line against the old `Messages.error(...)`. selfroles and cleanname are deliberately left for the next commit. Each one starts a message in one mode and updates it in the other โ€” cleanname replies with Messages.info and later updates the same message with a raw embed; selfroles replies with an embed panel and updates it with Messages.info. Discord rejects switching a message between embed and Components V2 mode after creation, so those two have to convert wholesale or not at all. Incidentally consistent now: addons rendered its addon pages as V2 containers but its "no addons found" message as an embed, on the same interaction. Verified: tsc and eslint both clean, and the Notice type satisfies send() as well as reply/editReply/followUp/update, which joinleave needs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/addons.ts | 8 ++++---- src/commands/botadmin.ts | 14 +++++++------- src/commands/developer.ts | 26 +++++++++++++------------- src/commands/moderation.ts | 14 +++++++------- src/commands/spam.ts | 8 ++++---- src/commands/voicetext.ts | 22 +++++++++++----------- src/events/joinleave.ts | 6 +++--- src/util/notices.ts | 13 +++++++++++-- 8 files changed, 60 insertions(+), 51 deletions(-) diff --git a/src/commands/addons.ts b/src/commands/addons.ts index 16ee6bc..53291e1 100644 --- a/src/commands/addons.ts +++ b/src/commands/addons.ts @@ -1,5 +1,5 @@ import {ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, InteractionContextType, MessageFlags, SlashCommandBuilder, type AutocompleteFocusedOption} from "discord.js"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; import type {BdWebAddon, BdWebTag} from "../types"; import Similarity from "string-similarity"; import Web from "../util/web"; @@ -62,7 +62,7 @@ export default { if (command === "random") return await this.random(interaction); if (command === "info") return await this.info(interaction); - return await interaction.editReply(Messages.error("This command is not yet implemented.")); + return await interaction.editReply(notices.error("This command is not yet implemented.")); }, /** @@ -81,7 +81,7 @@ export default { }); // No need to continue if there are no results - if (filteredAddons.length === 0) return await interaction.editReply(Messages.error("No addons found with the specified criteria.")); + if (filteredAddons.length === 0) return await interaction.editReply(notices.error("No addons found with the specified criteria.")); sortAddons(filteredAddons, sort as "likes" | "downloads" | "initial_release_date" | "latest_release_date"); @@ -133,7 +133,7 @@ export default { async info(interaction: ChatInputCommandInteraction<"cached">) { const name = interaction.options.getString("name", true).toLowerCase(); const addon = Array.from(cache).find(a => a.name.toLowerCase() === name); - if (!addon) return await interaction.editReply(Messages.error("No addon found with that name.")); + if (!addon) return await interaction.editReply(notices.error("No addon found with that name.")); return await interaction.editReply({components: [createAddonComponent(addon)], flags: MessageFlags.IsComponentsV2}); }, diff --git a/src/commands/botadmin.ts b/src/commands/botadmin.ts index 8b86fcb..ebe1796 100644 --- a/src/commands/botadmin.ts +++ b/src/commands/botadmin.ts @@ -1,5 +1,5 @@ import {ActionRowBuilder, ChannelType, ChatInputCommandInteraction, ModalBuilder, SlashCommandBuilder, TextChannel, TextInputBuilder, TextInputStyle, type PartialTextBasedChannelFields} from "discord.js"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; import {globalDB} from "../db"; @@ -35,7 +35,7 @@ export default { async execute(interaction: ChatInputCommandInteraction) { - if (interaction.user.id !== process.env.BOT_OWNER_ID) return await interaction.reply(Messages.error("Sorry this command is only usable by the owner!", {ephemeral: true})); + if (interaction.user.id !== process.env.BOT_OWNER_ID) return await interaction.reply(notices.error("Sorry this command is only usable by the owner!", {ephemeral: true})); const group = interaction.options.getSubcommandGroup(); const command = interaction.options.getSubcommand(); @@ -78,14 +78,14 @@ export default { const message = modalInteraction.fields.getTextInputValue("message"); try { await target.send(message); - await modalInteraction.reply(Messages.success("Message sent successfully!", {ephemeral: true})); + await modalInteraction.reply(notices.success("Message sent successfully!", {ephemeral: true})); } catch { - await modalInteraction.reply(Messages.error("Could not send message!", {ephemeral: true})); + await modalInteraction.reply(notices.error("Could not send message!", {ephemeral: true})); } } catch { - await interaction.followUp(Messages.error("Modal submission timed out!", {ephemeral: true})); + await interaction.followUp(notices.error("Modal submission timed out!", {ephemeral: true})); } }, @@ -99,12 +99,12 @@ export default { const targetUser = interaction.options.getUser("user"); if (targetUser) await globalDB.set("forwarding", targetUser.id); else await globalDB.delete("forwarding"); - await interaction.reply(Messages.success(targetUser ? `Now forwarding DMs to <@${targetUser.id}>!` : "No longer forwarding DMs!", {ephemeral: true})); + await interaction.reply(notices.success(targetUser ? `Now forwarding DMs to <@${targetUser.id}>!` : "No longer forwarding DMs!", {ephemeral: true})); }, async quit(interaction: ChatInputCommandInteraction) { - await interaction.reply(Messages.info("Bot shutting down...", {ephemeral: true})); + await interaction.reply(notices.info("Bot shutting down...", {ephemeral: true})); await interaction.client.destroy(); process.exit(0); }, diff --git a/src/commands/developer.ts b/src/commands/developer.ts index 3484953..aed879f 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -1,6 +1,6 @@ import {ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder, type GuildTextBasedChannel} from "discord.js"; import {guildDB} from "../db"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; @@ -49,7 +49,7 @@ export default { async channel(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.member.permissions.has("Administrator")) return await interaction.reply(Messages.error("You need to be an administrator to use this command!", {ephemeral: true})); + if (!interaction.member.permissions.has("Administrator")) return await interaction.reply(notices.error("You need to be an administrator to use this command!", {ephemeral: true})); const targetChannelId = interaction.options.getString("channel"); // const targetGuild = await interaction.client.guilds.fetch(targetGuildId); @@ -64,12 +64,12 @@ export default { delete current.inviteChannel; await guildDB.set(interaction.guild.id, current); } - await interaction.reply(Messages.success(targetChannel ? `Invite message channel set to <#${targetChannel.id}>!` : "Invite message channel has been unset!", {ephemeral: true})); + await interaction.reply(notices.success(targetChannel ? `Invite message channel set to <#${targetChannel.id}>!` : "Invite message channel has been unset!", {ephemeral: true})); }, async add(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.member.permissions.has("ManageRoles")) return await interaction.reply(Messages.error("You need the `Manage Roles` permission to use this command!", {ephemeral: true})); + if (!interaction.member.permissions.has("ManageRoles")) return await interaction.reply(notices.error("You need the `Manage Roles` permission to use this command!", {ephemeral: true})); await interaction.deferReply({ephemeral: true}); const targetUser = interaction.options.getUser("user", true); const roleName = interaction.options.getString("role", true); @@ -82,11 +82,11 @@ export default { await member.roles.add(bdRoleId, "Developer verified"); } catch { - await interaction.editReply(Messages.error("Could not add roles in main server!", {ephemeral: true})); + await interaction.editReply(notices.error("Could not add roles in main server!", {ephemeral: true})); } } catch { - await interaction.editReply(Messages.error("User is not in BetterDiscord server!", {ephemeral: true})); + await interaction.editReply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); } let messageToSend = message.replace("{{user}}", `<@!${targetUser.id}>`).replace("{{role}}", roleName); @@ -103,7 +103,7 @@ export default { await targetUser.send(messageToSend); } catch { - await interaction.editReply(Messages.error("Could not DM user!", {ephemeral: true})); + await interaction.editReply(notices.error("Could not DM user!", {ephemeral: true})); const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; if (guildSettings.inviteChannel) { @@ -114,15 +114,15 @@ export default { await inviteChannel?.send(messageToSend); } catch { - await interaction.editReply(Messages.error("Could not send a message in the invite channel!", {ephemeral: true})); + await interaction.editReply(notices.error("Could not send a message in the invite channel!", {ephemeral: true})); } } else { - await interaction.editReply(Messages.error("Could not DM user and no fallback channel exists!", {ephemeral: true})); + await interaction.editReply(notices.error("Could not DM user and no fallback channel exists!", {ephemeral: true})); } } - await interaction.editReply(Messages.success("Role has been added successfully!", {ephemeral: true})); + await interaction.editReply(notices.success("Role has been added successfully!", {ephemeral: true})); }, @@ -130,7 +130,7 @@ export default { const targetUser = interaction.options.getUser("user", true); const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); const bdMember = await bdGuild.members.fetch(targetUser); - if (!bdMember) return await interaction.reply(Messages.error("User is not in BetterDiscord server!", {ephemeral: true})); + if (!bdMember) return await interaction.reply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); const isPluginDev = bdMember.roles.cache.has("125166040689803264"); const isThemeDev = bdMember.roles.cache.has("165005972970930176"); const rolesToAdd = [isPluginDev ? "948627723830591568" : "", isThemeDev ? "948627648706392104" : ""].filter(r => r); @@ -141,10 +141,10 @@ export default { await communityMember.roles.add(rolesToAdd, "Syncing roles from main server"); } catch { - return await interaction.reply(Messages.error("Could not assign roles in this server!", {ephemeral: true})); + return await interaction.reply(notices.error("Could not assign roles in this server!", {ephemeral: true})); } - await interaction.reply(Messages.success("Roles have been synced!", {ephemeral: true})); + await interaction.reply(notices.success("Roles have been synced!", {ephemeral: true})); }, }; diff --git a/src/commands/moderation.ts b/src/commands/moderation.ts index f34a8f0..167f51b 100644 --- a/src/commands/moderation.ts +++ b/src/commands/moderation.ts @@ -1,6 +1,6 @@ import {ChannelType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; import {guildDB} from "../db"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; @@ -52,12 +52,12 @@ export default { async invitefilter(interaction: ChatInputCommandInteraction<"cached">) { const toEnable = interaction.options.getBoolean("enable"); const current = await guildDB.get(interaction.guild.id) ?? {}; - if (toEnable === null) return await interaction.reply(Messages.info(`This module is currently ${current.invitefilter ? "enabled" : "disabled"}.`, {ephemeral: true})); + if (toEnable === null) return await interaction.reply(notices.info(`This module is currently ${current.invitefilter ? "enabled" : "disabled"}.`, {ephemeral: true})); current.invitefilter = toEnable; await guildDB.set(interaction.guild.id, current); - await interaction.reply(Messages.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); + await interaction.reply(notices.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); }, @@ -65,12 +65,12 @@ export default { async detectspam(interaction: ChatInputCommandInteraction<"cached">) { const toEnable = interaction.options.getBoolean("enable"); const current = await guildDB.get(interaction.guild.id) ?? {}; - if (toEnable === null) return await interaction.reply(Messages.info(`This module is currently ${current.detectspam ? "enabled" : "disabled"}.`, {ephemeral: true})); + if (toEnable === null) return await interaction.reply(notices.info(`This module is currently ${current.detectspam ? "enabled" : "disabled"}.`, {ephemeral: true})); current.detectspam = toEnable; await guildDB.set(interaction.guild.id, current); - await interaction.reply(Messages.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); + await interaction.reply(notices.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); }, @@ -88,7 +88,7 @@ export default { delete current.modlog; await guildDB.set(interaction.guild.id, current); } - await interaction.reply(Messages.success(targetChannel ? `Modlog set to <#${targetChannel.id}>!` : "Modlog has been unset!", {ephemeral: true})); + await interaction.reply(notices.success(targetChannel ? `Modlog set to <#${targetChannel.id}>!` : "Modlog has been unset!", {ephemeral: true})); }, @@ -103,6 +103,6 @@ export default { delete current.joinleave; await guildDB.set(interaction.guild.id, current); } - await interaction.reply(Messages.success(targetChannel ? `Join/leave set to <#${targetChannel.id}>!` : "Join/leave has been unset!", {ephemeral: true})); + await interaction.reply(notices.success(targetChannel ? `Join/leave set to <#${targetChannel.id}>!` : "Join/leave has been unset!", {ephemeral: true})); }, }; diff --git a/src/commands/spam.ts b/src/commands/spam.ts index 8802eed..af5d966 100644 --- a/src/commands/spam.ts +++ b/src/commands/spam.ts @@ -1,5 +1,5 @@ import {ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; // TODO: move detectspam from moderation to here @@ -24,12 +24,12 @@ export default { async link(interaction: ChatInputCommandInteraction<"cached">) { const rule = await interaction.guild.autoModerationRules.fetch("1256935881168781332"); - if (!rule) return await interaction.reply(Messages.error("Spam link filter rule not found! Report this to Zerebos!", {ephemeral: true})); + if (!rule) return await interaction.reply(notices.error("Spam link filter rule not found! Report this to Zerebos!", {ephemeral: true})); const existing = rule.triggerMetadata?.keywordFilter ?? []; const link = interaction.options.getString("link", true); - if (existing.includes(link)) return await interaction.reply(Messages.info("This link is already in the spam filter!", {ephemeral: true})); + if (existing.includes(link)) return await interaction.reply(notices.info("This link is already in the spam filter!", {ephemeral: true})); await rule.edit({ triggerMetadata: { @@ -38,6 +38,6 @@ export default { }); // Don't make this ephemeral since it's useful to see who added what link - await interaction.reply(Messages.success("Link added to spam filter!")); + await interaction.reply(notices.success("Link added to spam filter!")); }, }; diff --git a/src/commands/voicetext.ts b/src/commands/voicetext.ts index 9f1b4a8..9b80e75 100644 --- a/src/commands/voicetext.ts +++ b/src/commands/voicetext.ts @@ -1,6 +1,6 @@ import {ChannelType, ChatInputCommandInteraction, GuildChannel, OverwriteType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; import {voicetextDB} from "../db"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; export default { @@ -50,11 +50,11 @@ export default { async bind(interaction: ChatInputCommandInteraction<"cached">) { const voice = interaction.options.getChannel("voice", true); const text = interaction.options.getChannel("text", true); - if (voice.type !== ChannelType.GuildVoice) return await interaction.reply(Messages.error("The voice channel must be a voice channel.", {ephemeral: true})); - if (text.type !== ChannelType.GuildText) return await interaction.reply(Messages.error("The text channel must be a text channel.", {ephemeral: true})); + if (voice.type !== ChannelType.GuildVoice) return await interaction.reply(notices.error("The voice channel must be a voice channel.", {ephemeral: true})); + if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); const partner = await voicetextDB.get(voice.id) ?? ""; - if (partner) return await interaction.reply(Messages.error(`<#${voice.id}> is already bound to <#${partner}>. Please unbind before continuing.`, {ephemeral: true})); + if (partner) return await interaction.reply(notices.error(`<#${voice.id}> is already bound to <#${partner}>. Please unbind before continuing.`, {ephemeral: true})); try { @@ -62,40 +62,40 @@ export default { } catch (err) { console.error(err); - return await interaction.reply(Messages.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); + return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); } await voicetextDB.set(voice.id, text.id); - await interaction.reply(Messages.success(`<#${voice.id}> is now bound to <#${text.id}>!`, {ephemeral: true})); + await interaction.reply(notices.success(`<#${voice.id}> is now bound to <#${text.id}>!`, {ephemeral: true})); }, async unbind(interaction: ChatInputCommandInteraction<"cached">) { const targetChannel = interaction.options.getChannel("channel", true); const partner = await voicetextDB.get(targetChannel.id) ?? ""; - if (!partner) return await interaction.reply(Messages.error(`<#${targetChannel.id}> is not bound.`, {ephemeral: true})); + if (!partner) return await interaction.reply(notices.error(`<#${targetChannel.id}> is not bound.`, {ephemeral: true})); /** * @type {import("discord.js").GuildChannel} */ const text = interaction.guild.channels.cache.get(partner) as GuildChannel; - if (text.type !== ChannelType.GuildText) return await interaction.reply(Messages.error("The text channel must be a text channel.", {ephemeral: true})); + if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); try { await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: null}, {reason: "Unbind text and voice channel", type: OverwriteType.Role}); } catch (err) { console.error(err); - return await interaction.reply(Messages.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); + return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); } await voicetextDB.delete(targetChannel.id); - await interaction.reply(Messages.success(`<#${targetChannel.id}> is now unbound!`, {ephemeral: true})); + await interaction.reply(notices.success(`<#${targetChannel.id}> is now unbound!`, {ephemeral: true})); }, async status(interaction: ChatInputCommandInteraction) { const targetChannel = interaction.options.getChannel("channel", true); const partner = await voicetextDB.get(targetChannel.id) ?? ""; - await interaction.reply(Messages.info(partner ? `<#${targetChannel.id}> is bound to <#${partner}>` : `This channel <#${targetChannel.id}> is not bound.`, {ephemeral: true})); + await interaction.reply(notices.info(partner ? `<#${targetChannel.id}> is bound to <#${partner}>` : `This channel <#${targetChannel.id}> is not bound.`, {ephemeral: true})); }, }; diff --git a/src/events/joinleave.ts b/src/events/joinleave.ts index 07596eb..6f58c73 100644 --- a/src/events/joinleave.ts +++ b/src/events/joinleave.ts @@ -1,6 +1,6 @@ import {Events, type GuildMember} from "discord.js"; import {guildDB} from "../db"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; @@ -14,7 +14,7 @@ export default [ if (!guildSettings || !guildSettings.joinleave) return; const channel = member.guild.channels.cache.get(guildSettings.joinleave); if (!channel || !channel.isTextBased()) return; - await channel.send(Messages.success(`<@!${member.user.id}> has joined the server!`)); + await channel.send(notices.success(`<@!${member.user.id}> has joined the server!`)); }, }, { @@ -25,7 +25,7 @@ export default [ if (!guildSettings || !guildSettings.joinleave) return; const channel = member.guild.channels.cache.get(guildSettings.joinleave); if (!channel || !channel.isTextBased()) return; - await channel.send(Messages.error(`**${member.user.tag} (${member.user.id})** has left the server!`)); + await channel.send(notices.error(`**${member.user.tag} (${member.user.id})** has left the server!`)); }, } ]; \ No newline at end of file diff --git a/src/util/notices.ts b/src/util/notices.ts index d2301f0..6d65c17 100644 --- a/src/util/notices.ts +++ b/src/util/notices.ts @@ -9,7 +9,11 @@ * commands still use. Collapsing them onto this one is the next pass. */ -import {MessageFlags, type ContainerComponentData} from "discord.js"; +import { + MessageFlags, + type ActionRowData, type ComponentInContainerData, type ContainerComponentData, + type MessageActionRowComponentData +} from "discord.js"; import {container, text} from "../framework/ui"; import Colors from "./colors"; @@ -20,6 +24,8 @@ export interface NoticeOptions { ephemeral?: boolean; /** Extra message flags to merge in. */ flags?: number; + /** Action rows rendered inside the container, below the text. */ + components?: Array>; } /** Discord wants an integer for a container accent; Colors are authored as hex. */ @@ -48,9 +54,12 @@ export interface Notice { } export function notice(kind: NoticeKind, content: string, options: NoticeOptions = {}): Notice { + const body: ComponentInContainerData[] = [text(`${ICONS[kind]} ${content}`)]; + if (options.components?.length) body.push(...options.components); + return { flags: MessageFlags.IsComponentsV2 | (options.ephemeral ? MessageFlags.Ephemeral : 0) | (options.flags ?? 0), - components: [container([text(`${ICONS[kind]} ${content}`)], {accentColor: ACCENTS[kind]})] + components: [container(body, {accentColor: ACCENTS[kind]})] }; } From f63d15f82012bfaf138d84275106be2a759f58d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:24:51 +0000 Subject: [PATCH 08/22] Convert selfroles and cleanname to V2 and delete the embed helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of three commits collapsing the message layers. src/util/messages.ts is gone; nothing sends an embed-based status message any more. These two could not be migrated piecemeal. Discord rejects switching a message between embed and Components V2 mode after creation, and both flows crossed that line: cleanname replies with a status message and later updates the same message with a raw embed, selfroles replies with an embed panel and updates it with a status message. selfroles: the panel is now a container with the listing and its buttons inside it, so the whole flow โ€” reply, both picker updates, and the return to the panel โ€” is one mode throughout. cleanname: the role-select control is plain component data, and the progress display is a container. Components V2 has no inline field grid, so the three counters (Members / Fixed / Failed) render on one line separated by em spaces instead of as three inline embed fields, and the embed timestamp becomes Discord's markup, which still localises per viewer. That is the one deliberate visual change in this commit. Also fixes a bug found while converting it: the completion path called interaction.update() a second time on an interaction that had already been acknowledged by the initial progress update. That throws InteractionAlreadyReplied, so the final Fixed/Failed counts never reached the user โ€” `/cleanname server` appeared to hang on "0 fixed, 0 failed" however many names it had actually corrected. It now uses editReply(). Supporting changes: - util/colors.ts gained `Accents`, the same palette as integers, derived from the hex values rather than duplicated. Container accents want an integer; notices had been computing that itself. - framework/ui.ts gained the `ComponentMessage` type. `flags` is deliberately `number` and not the MessageFlags enum: an unannotated `MessageFlags.IsComponentsV2` widens to the whole enum, which is not assignable to the narrower per-method flag unions discord.js declares, while `number` is assignable to a numeric enum and satisfies all of them. That widening is exactly what broke the first draft of cleanname's progress(). Verified: tsc and eslint clean; every notice variant carries IsComponentsV2, ephemeral notices carry both flags, and a notice with action rows nests the row inside the container rather than beside it. Remaining embeds are the moderation log entries, DM forwarding and /about, all handled in the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/cleanname.ts | 102 ++++++++++++++++++++++++-------------- src/commands/selfroles.ts | 37 ++++++++------ src/framework/ui.ts | 17 ++++++- src/util/colors.ts | 14 +++++- src/util/messages.ts | 36 -------------- src/util/notices.ts | 25 ++++------ 6 files changed, 123 insertions(+), 108 deletions(-) delete mode 100644 src/util/messages.ts diff --git a/src/commands/cleanname.ts b/src/commands/cleanname.ts index 359fbf6..8821c58 100644 --- a/src/commands/cleanname.ts +++ b/src/commands/cleanname.ts @@ -1,11 +1,39 @@ -import {ActionRowBuilder, ChatInputCommandInteraction, ComponentType, EmbedBuilder, PermissionFlagsBits, RoleSelectMenuBuilder, RoleSelectMenuInteraction, SlashCommandBuilder} from "discord.js"; +import {ChatInputCommandInteraction, ComponentType, MessageFlags, PermissionFlagsBits, RoleSelectMenuInteraction, SelectMenuDefaultValueType, SlashCommandBuilder} from "discord.js"; +import {container, row, text, type ComponentMessage} from "../framework"; import {humanReadableUptime} from "../util/time"; -import Colors from "../util/colors"; -import Messages from "../util/messages"; +import {Accents} from "../util/colors"; +import * as notices from "../util/notices"; import {guildDB} from "../db"; import {hasDisallowedChars} from "../util/names"; +interface CleanProgress { + members: number; + fixed: number; + failed: number; + blurb: string; + stamp: {label: string; at: number;}; + done: boolean; +} + +/** + * Replaces the progress embed. Components V2 has no inline field grid, so the + * three counters render as one line, and the embed timestamp becomes Discord's + * own markup so it still localises per viewer. + */ +function progress(state: CleanProgress): ComponentMessage { + return { + flags: MessageFlags.IsComponentsV2, + components: [container([ + text("## Fixing Display Names"), + text(state.blurb), + text(`**Members** ${state.members.toLocaleString()}\u2003**Fixed** ${state.fixed.toLocaleString()}\u2003**Failed** ${state.failed.toLocaleString()}`), + text(`-# ${state.stamp.label} `) + ], {accentColor: state.done ? Accents.Success : Accents.Info})] + }; +} + + export default { data: new SlashCommandBuilder() .setName("cleanname") @@ -36,10 +64,14 @@ export default { async server(interaction: ChatInputCommandInteraction<"cached">) { - const controls = new ActionRowBuilder().addComponents( - new RoleSelectMenuBuilder({type: ComponentType.RoleSelect}).setCustomId("cleanname").setMinValues(0).setMaxValues(25).setDefaultRoles(interaction.guild.roles.highest.id) - ); - await interaction.reply(Messages.info("Please select which roles should bypass this cleaning.", {components: [controls]})); + const controls = row({ + type: ComponentType.RoleSelect, + customId: "cleanname", + minValues: 0, + maxValues: 25, + defaultValues: [{id: interaction.guild.roles.highest.id, type: SelectMenuDefaultValueType.Role}] + }); + await interaction.reply(notices.info("Please select which roles should bypass this cleaning.", {components: [controls]})); }, @@ -48,19 +80,14 @@ export default { const start = Date.now(); - const infoEmbed = new EmbedBuilder(); - infoEmbed.setColor(Colors.Info); - infoEmbed.setTitle("Fixing Display Names"); - infoEmbed.setDescription(`This will take approximately ${humanReadableUptime(interaction.guild.memberCount * 10)}. Please be patient.`); - infoEmbed.setFooter({text: "Started at"}); - infoEmbed.setTimestamp(start); - infoEmbed.setFields( - {name: "Members", value: interaction.guild.memberCount.toString(), inline: true}, - {name: "Fixed", value: "0", inline: true}, - {name: "Failed", value: "0", inline: true}, - ); - - await interaction.update({embeds: [infoEmbed], components: []}); + await interaction.update(progress({ + members: interaction.guild.memberCount, + fixed: 0, + failed: 0, + blurb: `This will take approximately ${humanReadableUptime(interaction.guild.memberCount * 10)}. Please be patient.`, + stamp: {label: "Started", at: start}, + done: false + })); let changed = 0; let failed = 0; @@ -85,33 +112,32 @@ export default { const finish = Date.now(); - infoEmbed.setFields( - {name: "Members", value: members.size.toString(), inline: true}, - {name: "Fixed", value: changed.toString(), inline: true}, - {name: "Failed", value: failed.toString(), inline: true}, - ); - - infoEmbed.setDescription(`Operation took ${humanReadableUptime(finish - start)}. Thank you for waiting.`); - infoEmbed.setColor(Colors.Success); - infoEmbed.setFooter({text: "Completed at"}); - infoEmbed.setTimestamp(finish); - - await interaction.update({embeds: [infoEmbed]}); + // editReply, not update: the interaction was already acknowledged above, + // so a second update() throws InteractionAlreadyReplied and the final + // counts never reached the user. + await interaction.editReply(progress({ + members: members.size, + fixed: changed, + failed, + blurb: `Operation took ${humanReadableUptime(finish - start)}. Thank you for waiting.`, + stamp: {label: "Completed", at: finish}, + done: true + })); }, async user(interaction: ChatInputCommandInteraction<"cached">) { const targetUser = interaction.options.getUser("user", true); const member = interaction.guild.members.cache.get(targetUser.id); - if (!member) return await interaction.reply(Messages.error("This user is not in the server.", {ephemeral: true})); + if (!member) return await interaction.reply(notices.error("This user is not in the server.", {ephemeral: true})); const isClean = !hasDisallowedChars(member.displayName); - if (isClean) return await interaction.reply(Messages.info("This member's display name already conforms to the username standards.")); + if (isClean) return await interaction.reply(notices.info("This member's display name already conforms to the username standards.")); try { await member.setNickname(member.user.username); - await interaction.reply(Messages.success("Successfully cleaned this member's display name.")); + await interaction.reply(notices.success("Successfully cleaned this member's display name.")); } catch { - await interaction.reply(Messages.error("Could not clean this member's display name. Double check that I have permission to do so.")); + await interaction.reply(notices.error("Could not clean this member's display name. Double check that I have permission to do so.")); } }, @@ -120,9 +146,9 @@ export default { const toEnable = !!interaction.options.getBoolean("enabled"); const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; const current = guildSettings.cleanOnJoin; - if (current === toEnable) return await interaction.reply(Messages.info(`This setting was already ${current ? "enabled" : "disabled"}.`)); + if (current === toEnable) return await interaction.reply(notices.info(`This setting was already ${current ? "enabled" : "disabled"}.`)); guildSettings.cleanOnJoin = toEnable; await guildDB.set(interaction.guild.id, guildSettings); - await interaction.reply(Messages.success(`This setting is now ${toEnable ? "enabled" : "disabled"}.`)); + await interaction.reply(notices.success(`This setting is now ${toEnable ? "enabled" : "disabled"}.`)); }, }; diff --git a/src/commands/selfroles.ts b/src/commands/selfroles.ts index 02b07f3..cc7dcb2 100644 --- a/src/commands/selfroles.ts +++ b/src/commands/selfroles.ts @@ -1,12 +1,12 @@ import { - ApplicationCommandType, ButtonStyle, ComponentType, EmbedBuilder, InteractionContextType, + ApplicationCommandType, ButtonStyle, ComponentType, InteractionContextType, MessageFlags, PermissionFlagsBits, SelectMenuDefaultValueType, type InteractionReplyOptions, type InteractionUpdateOptions, type MessageActionRowComponentData } from "discord.js"; -import {defineCommand, defineComponent, oneOf, row} from "../framework"; +import {container, defineCommand, defineComponent, oneOf, row, text} from "../framework"; import {selfrolesDB} from "../db"; -import Messages from "../util/messages"; -import Colors from "../util/colors"; +import * as notices from "../util/notices"; +import {Accents} from "../util/colors"; const RETURN_TO_PANEL_DELAY = 3000; @@ -15,9 +15,6 @@ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); /** The listing plus its controls. Shared by the command and every component. */ function panel(roleIds: string[], canManage: boolean): InteractionReplyOptions & InteractionUpdateOptions { - const listing = new EmbedBuilder().setColor(Colors.Info).setTitle("Available Roles") - .setDescription(roleIds.length ? roleIds.map(id => `- <@&${id}>`).join("\n") : "No roles have been configured by the admins."); - const controls: MessageActionRowComponentData[] = [ {type: ComponentType.Button, customId: openPicker.customId({mode: "user"}), label: "Manage Your Roles", style: ButtonStyle.Success} ]; @@ -25,7 +22,14 @@ function panel(roleIds: string[], canManage: boolean): InteractionReplyOptions & controls.push({type: ComponentType.Button, customId: openPicker.customId({mode: "admin"}), label: "Set Assignable Roles", style: ButtonStyle.Primary}); } - return {embeds: [listing], components: [row(...controls)]}; + return { + flags: MessageFlags.IsComponentsV2, + components: [container([ + text("## Available Roles"), + text(roleIds.length ? roleIds.map(id => `- <@&${id}>`).join("\n") : "No roles have been configured by the admins."), + row(...controls) + ], {accentColor: Accents.Info})] + }; } @@ -45,10 +49,10 @@ const openPicker = defineComponent({ if (mode === "admin") { if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles)) { - return await interaction.reply(Messages.error("You need the `Manage Roles` permission to do that.", {ephemeral: true})); + return await interaction.reply(notices.error("You need the `Manage Roles` permission to do that.", {ephemeral: true})); } - return await interaction.update(Messages.info("Please select which roles should be self-assignable.", { + return await interaction.update(notices.info("Please select which roles should be self-assignable.", { components: [row({ type: ComponentType.RoleSelect, customId: setAssignable.customId({}), @@ -62,10 +66,10 @@ const openPicker = defineComponent({ // The previous version called setMaxValues(0) here, which Discord rejects, // so the first press on a server with no configured roles always failed. if (!assignable.length) { - return await interaction.reply(Messages.info("No self-assignable roles have been set up yet.", {ephemeral: true})); + return await interaction.reply(notices.info("No self-assignable roles have been set up yet.", {ephemeral: true})); } - return await interaction.update(Messages.info("Please select which roles you want.", { + return await interaction.update(notices.info("Please select which roles you want.", { components: [row({ type: ComponentType.StringSelect, customId: chooseRoles.customId({}), @@ -95,10 +99,10 @@ const chooseRoles = defineComponent({ const toRemove = assignable.filter(id => !interaction.values.includes(id)); if (toRemove.length) await interaction.member.roles.remove(toRemove, "Self-roles"); if (interaction.values.length) await interaction.member.roles.add(interaction.values, "Self-roles"); - await interaction.update(Messages.success("Successfully assigned your roles!", {components: []})); + await interaction.update(notices.success("Successfully assigned your roles!", {components: []})); } catch { - await interaction.update(Messages.error("Could not assign your roles. It may be a permission issue.", {components: []})); + await interaction.update(notices.error("Could not assign your roles. It may be a permission issue.", {components: []})); } await wait(RETURN_TO_PANEL_DELAY); @@ -116,7 +120,7 @@ const setAssignable = defineComponent({ async run(interaction) { const roleIds = [...interaction.roles.keys()]; await selfrolesDB.set(interaction.guild.id, roleIds); - await interaction.update(Messages.success("Self-assignable roles set successfully.", {components: []})); + await interaction.update(notices.success("Self-assignable roles set successfully.", {components: []})); await wait(RETURN_TO_PANEL_DELAY); await interaction.editReply(panel(roleIds, true)); @@ -136,7 +140,8 @@ export const command = defineCommand({ async execute(interaction) { const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; const canManage = interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles); - return await interaction.reply({...panel(assignable, canManage), flags: MessageFlags.Ephemeral}); + const message = panel(assignable, canManage); + return await interaction.reply({...message, flags: MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral}); } }); diff --git a/src/framework/ui.ts b/src/framework/ui.ts index 1f65235..df29f8c 100644 --- a/src/framework/ui.ts +++ b/src/framework/ui.ts @@ -12,10 +12,25 @@ import { ComponentType, type ActionRowData, type ComponentInContainerData, type ContainerComponentData, - type MessageActionRowComponentData + type MessageActionRowComponentData, type TopLevelComponentData } from "discord.js"; +/** + * A Components V2 message payload, accepted by reply / editReply / followUp / + * update / send alike. + * + * `flags` is deliberately `number` rather than the MessageFlags enum. An + * unannotated `MessageFlags.IsComponentsV2` widens to the whole enum, which is + * not assignable to the narrower per-method flag unions discord.js declares; + * `number` is assignable to a numeric enum and so satisfies all of them. + */ +export interface ComponentMessage { + flags: number; + components: TopLevelComponentData[]; +} + + export const row = (...components: MessageActionRowComponentData[]): ActionRowData => ({ type: ComponentType.ActionRow, components diff --git a/src/util/colors.ts b/src/util/colors.ts index 1bfc8e8..2c694c2 100644 --- a/src/util/colors.ts +++ b/src/util/colors.ts @@ -1,9 +1,21 @@ import type {HexColorString} from "discord.js"; +/** Authored as hex for embeds. */ export default class Colors { static Info: HexColorString = "#5a88ce"; static Warn: HexColorString = "#fbbf24"; static Success: HexColorString = "#3ac172"; static Danger: HexColorString = "#c13a3a"; static Error: HexColorString = "#c13a3a"; -} \ No newline at end of file +} + +const toInt = (hex: HexColorString): number => parseInt(hex.slice(1), 16); + +/** The same palette as integers, which Components V2 container accents want. */ +export const Accents = { + Info: toInt(Colors.Info), + Warn: toInt(Colors.Warn), + Success: toInt(Colors.Success), + Danger: toInt(Colors.Danger), + Error: toInt(Colors.Error) +} as const; diff --git a/src/util/messages.ts b/src/util/messages.ts deleted file mode 100644 index 9e93c4e..0000000 --- a/src/util/messages.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {EmbedBuilder, MessageFlags, type BaseMessageOptions, type ColorResolvable} from "discord.js"; -import Colors from "./colors"; - - -interface EmbedOptions { - description: string; - color: ColorResolvable; - ephemeral?: boolean; - components?: BaseMessageOptions["components"]; -} - -export default class Messages { - static embed({description, color, ephemeral, components}: EmbedOptions) { - // return new EmbedBuilder().setColor(color).setDescription(description); - const embed = new EmbedBuilder().setColor(color).setDescription(description); - const data: {embeds: EmbedBuilder[], components?: BaseMessageOptions["components"], flags?: number;} = {embeds: [embed], components}; - if (ephemeral) data.flags = MessageFlags.Ephemeral; - return data; - } - - static success(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Success, description, ephemeral, components}); - } - - static error(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Error, description, ephemeral, components}); - } - - static info(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Info, description, ephemeral, components}); - } - - static warn(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Warn, description, ephemeral, components}); - } -} \ No newline at end of file diff --git a/src/util/notices.ts b/src/util/notices.ts index 6d65c17..55f3c39 100644 --- a/src/util/notices.ts +++ b/src/util/notices.ts @@ -11,11 +11,10 @@ import { MessageFlags, - type ActionRowData, type ComponentInContainerData, type ContainerComponentData, - type MessageActionRowComponentData + type ActionRowData, type ComponentInContainerData, type MessageActionRowComponentData } from "discord.js"; -import {container, text} from "../framework/ui"; -import Colors from "./colors"; +import {container, text, type ComponentMessage} from "../framework/ui"; +import {Accents} from "./colors"; export type NoticeKind = "success" | "info" | "warn" | "error" | "danger"; @@ -28,15 +27,12 @@ export interface NoticeOptions { components?: Array>; } -/** Discord wants an integer for a container accent; Colors are authored as hex. */ -const accent = (hex: string): number => parseInt(hex.replace(/^#/, ""), 16); - const ACCENTS: Record = { - success: accent(Colors.Success), - info: accent(Colors.Info), - warn: accent(Colors.Warn), - error: accent(Colors.Error), - danger: accent(Colors.Danger) + success: Accents.Success, + info: Accents.Info, + warn: Accents.Warn, + error: Accents.Error, + danger: Accents.Danger }; const ICONS: Record = { @@ -48,10 +44,7 @@ const ICONS: Record = { }; -export interface Notice { - flags: number; - components: ContainerComponentData[]; -} +export type Notice = ComponentMessage; export function notice(kind: NoticeKind, content: string, options: NoticeOptions = {}): Notice { const body: ComponentInContainerData[] = [text(`${ICONS[kind]} ${content}`)]; From 7e681d6a3b92a6b780f23c082d20c18afe7b78f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:27:24 +0000 Subject: [PATCH 09/22] Consolidate moderation logs and DM forwarding onto Components V2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of three commits collapsing the message layers. src/util/modlog.ts replaces five hand-built embeds across three event files. detectspam, invitefilter and detectcryptoscam each constructed their own near-identical entry โ€” same colour, same author/description/Reason/footer shape โ€” and each repeated the same modlog-channel resolution: const modlogId = current.modlog; const modlogChannel = message.guild.channels.cache.get(modlogId!); if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; That lookup was also subtly wrong in two of them: it sat before the mute / timeout log and returned early, so a guild with no modlog configured would skip the rest of the handler rather than just skip logging. sendModLog no-ops instead, and the caller carries on. Rendering notes: the embed author icon becomes a Section thumbnail, which is the closest V2 equivalent; the footer timestamp becomes Discord's markup so it still localises per viewer. Entries without an avatar render as flat text with no Section. forwarding.ts: the DM forwarding embed becomes a container, with attachments as markdown links rather than embed fields. about.ts keeps its embed, deliberately, with a comment saying why: its stats are inline fields three to a row, and V2 has no field grid โ€” faking one with padded text does not survive different client widths. It is now the only EmbedBuilder in the codebase, and the comment tells the next person that. This closes step 4. The three ways of sending a message are down to one, plus one documented exception: before Messages.* embeds (54 sites) + raw EmbedBuilder (11) + djsx widgets (12) after notices.* / modlog / plain container data, and /about Verified: tsc and eslint clean; modlog entries render the heading, body, reason, thumbnail and timestamp correctly in both the with-avatar and without-avatar shapes; no event file references EmbedBuilder any more; the loader still registers 10 commands, 3 components and 11 event listeners. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/about.ts | 6 ++++ src/events/detectcryptoscam.ts | 23 +++++++------- src/events/detectspam.ts | 38 +++++++++++------------ src/events/forwarding.ts | 22 ++++++++------ src/events/invitefilter.ts | 39 ++++++++++++------------ src/util/modlog.ts | 55 ++++++++++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 62 deletions(-) create mode 100644 src/util/modlog.ts diff --git a/src/commands/about.ts b/src/commands/about.ts index 109c35c..b09aa4f 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -15,6 +15,12 @@ export default { .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel), + /** + * The one place that still uses an embed rather than a Components V2 + * container. The stats below are laid out as inline fields, three to a row; + * V2 has no field grid and faking one with padded text does not survive + * different client widths. Everything else in the bot sends V2. + */ async execute(interaction: ChatInputCommandInteraction) { await interaction.deferReply(); const aboutEmbed = new EmbedBuilder(); diff --git a/src/events/detectcryptoscam.ts b/src/events/detectcryptoscam.ts index 994d802..daf87d9 100644 --- a/src/events/detectcryptoscam.ts +++ b/src/events/detectcryptoscam.ts @@ -1,6 +1,6 @@ -import {EmbedBuilder, Events, Message, PermissionFlagsBits} from "discord.js"; +import {Events, Message, PermissionFlagsBits} from "discord.js"; import {guildDB} from "../db"; -import Colors from "../util/colors"; +import {sendModLog} from "../util/modlog"; const TIMEOUT_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds @@ -55,17 +55,14 @@ export default { if (didTimeout) { - const modlogId = current.modlog; - const modlogChannel = message.guild.channels.cache.get(modlogId!); - if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; // Can't log - - const mEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: "Member Timed Out", iconURL: message.author.displayAvatarURL()}) - .setDescription(`${message.author.displayName} ${message.author.tag}`) - .addFields({name: "Reason", value: "Detected Crypto Scam"}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - - await modlogChannel.send({embeds: [mEmbed]}); + await sendModLog(message.guild, current.modlog, { + heading: "Member Timed Out", + iconUrl: message.author.displayAvatarURL(), + body: `${message.author.displayName} ${message.author.tag}`, + reason: "Detected Crypto Scam", + userId: message.author.id, + at: message.createdTimestamp + }); } }, }; \ No newline at end of file diff --git a/src/events/detectspam.ts b/src/events/detectspam.ts index 1b60175..f263a95 100644 --- a/src/events/detectspam.ts +++ b/src/events/detectspam.ts @@ -1,6 +1,6 @@ -import {EmbedBuilder, Events, Message, PermissionFlagsBits} from "discord.js"; +import {Events, Message, PermissionFlagsBits} from "discord.js"; import {guildDB} from "../db"; -import Colors from "../util/colors"; +import {sendModLog} from "../util/modlog"; const fakeDiscordRegex = new RegExp(`([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\\.(com|net|app|gift|ru|uk)`, "ig"); @@ -64,26 +64,24 @@ export default { } } - const modlogId = current.modlog; - const modlogChannel = message.guild.channels.cache.get(modlogId!); - if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; // Can't log - - const dEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: message.author.username, iconURL: message.author.displayAvatarURL()}) - .setDescription(`Message sent by ${message.author.username} in ${message.channel.name}\n\n` + message.content) - .addFields({name: "Reason", value: reason}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - await modlogChannel.send({embeds: [dEmbed]}); - + await sendModLog(message.guild, current.modlog, { + heading: message.author.username, + iconUrl: message.author.displayAvatarURL(), + body: `Message sent by ${message.author.username} in ${message.channel.name}\n\n${message.content}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); if (didMute) { - const mEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: "Member Muted", iconURL: message.author.displayAvatarURL()}) - .setDescription(`${message.author.displayName} ${message.author.tag}`) - .addFields({name: "Reason", value: reason}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - - await modlogChannel.send({embeds: [mEmbed]}); + await sendModLog(message.guild, current.modlog, { + heading: "Member Muted", + iconUrl: message.author.displayAvatarURL(), + body: `${message.author.displayName} ${message.author.tag}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); } }, }; \ No newline at end of file diff --git a/src/events/forwarding.ts b/src/events/forwarding.ts index 2236b76..a6f4bf6 100644 --- a/src/events/forwarding.ts +++ b/src/events/forwarding.ts @@ -1,4 +1,6 @@ -import {EmbedBuilder, Events, type Message} from "discord.js"; +import {Events, MessageFlags, type Message} from "discord.js"; +import {container, text} from "../framework"; +import {Accents} from "../util/colors"; import {globalDB} from "../db"; @@ -18,16 +20,18 @@ export default { const user = message.client.users.cache.get(target); if (!user) return; - const embed = new EmbedBuilder() - .setAuthor({name: `${message.author.displayName} (${message.author.id})`, iconURL: message.author.displayAvatarURL()}) - .setDescription(message.content ?? "\u200B"); + const lines = [ + `### ${message.author.displayName} (${message.author.id})`, + message.content || "\u200B" + ]; - if (message.attachments.size) { - for (const [id, att] of message.attachments) { - embed.addFields({name: att.name, value: `[${id}](${att.url})`}); - } + for (const [id, attachment] of message.attachments) { + lines.push(`**${attachment.name}** โ€” [${id}](${attachment.url})`); } - await user.send({embeds: [embed]}); + await user.send({ + flags: MessageFlags.IsComponentsV2, + components: [container(lines.map(text), {accentColor: Accents.Info})] + }); }, }; \ No newline at end of file diff --git a/src/events/invitefilter.ts b/src/events/invitefilter.ts index 0b81074..b6f4ac4 100644 --- a/src/events/invitefilter.ts +++ b/src/events/invitefilter.ts @@ -1,6 +1,6 @@ -import {EmbedBuilder, Events, PermissionFlagsBits, type Message} from "discord.js"; +import {Events, PermissionFlagsBits, type Message} from "discord.js"; import {guildDB} from "../db"; -import Colors from "../util/colors"; +import {sendModLog} from "../util/modlog"; @@ -54,26 +54,25 @@ export default { } } - const modlogId = current.modlog; - const modlogChannel = message.guild.channels.cache.get(modlogId!); - if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; // Can't log - - const dEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: message.author.username, iconURL: message.author.displayAvatarURL()}) - .setDescription(`Message sent by ${message.author.username} in ${message.channel.name}\n\n` + message.content) - .addFields({name: "Reason", value: "Discord Invite"}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - await modlogChannel.send({embeds: [dEmbed]}); - + const reason = "Discord Invite"; + await sendModLog(message.guild, current.modlog, { + heading: message.author.username, + iconUrl: message.author.displayAvatarURL(), + body: `Message sent by ${message.author.username} in ${message.channel.name}\n\n${message.content}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); if (didMute) { - const mEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: "Member Muted", iconURL: message.author.displayAvatarURL()}) - .setDescription(`${message.author.displayName} ${message.author.tag}`) - .addFields({name: "Reason", value: "Discord Invite"}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - - await modlogChannel.send({embeds: [mEmbed]}); + await sendModLog(message.guild, current.modlog, { + heading: "Member Muted", + iconUrl: message.author.displayAvatarURL(), + body: `${message.author.displayName} ${message.author.tag}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); } }, }; \ No newline at end of file diff --git a/src/util/modlog.ts b/src/util/modlog.ts new file mode 100644 index 0000000..df4c382 --- /dev/null +++ b/src/util/modlog.ts @@ -0,0 +1,55 @@ +/** + * Moderation log entries. + * + * detectspam, invitefilter and detectcryptoscam each built their own + * near-identical embeds and each repeated the same channel-resolution dance. + * One helper now covers both entry shapes and the lookup. + */ + +import {ComponentType, MessageFlags, type ComponentInContainerData, type Guild, type TextDisplayComponentData} from "discord.js"; +import {container, text, type ComponentMessage} from "../framework"; +import {Accents} from "./colors"; + + +export interface ModLogEntry { + /** Heading line: the offending user, or the action taken. */ + heading: string; + /** Avatar shown alongside the entry, as the embed author icon used to be. */ + iconUrl?: string; + body: string; + reason: string; + userId: string; + /** Milliseconds; rendered as Discord's own per-viewer localised timestamp. */ + at: number; +} + + +export function modLogMessage(entry: ModLogEntry): ComponentMessage { + const lines: TextDisplayComponentData[] = [ + {type: ComponentType.TextDisplay, content: `### ${entry.heading}`}, + {type: ComponentType.TextDisplay, content: entry.body || "โ€‹"}, + {type: ComponentType.TextDisplay, content: `**Reason:** ${entry.reason}`} + ]; + + // A Section with a thumbnail is the closest V2 has to an embed author icon. + const body: ComponentInContainerData[] = entry.iconUrl + ? [{type: ComponentType.Section, components: lines, accessory: {type: ComponentType.Thumbnail, media: {url: entry.iconUrl}}}] + : [...lines]; + + body.push(text(`-# ID: ${entry.userId} โ€ข `)); + + return { + flags: MessageFlags.IsComponentsV2, + components: [container(body, {accentColor: Accents.Info})] + }; +} + + +/** Posts to the guild's configured modlog channel. Silently no-ops if unset. */ +export async function sendModLog(guild: Guild, channelId: string | undefined, entry: ModLogEntry): Promise { + if (!channelId) return; + const channel = guild.channels.cache.get(channelId); + if (!channel?.isTextBased()) return; + + await channel.send(modLogMessage(entry)); +} From 59b249d2fecfacfcb7f8b7816ae618e614cd2170 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:32:47 +0000 Subject: [PATCH 10/22] Migrate about, spam, moderation, voicetext and developer to defineCommand First of several commits taking the remaining commands off the legacy module shape, so the dispatcher's legacy path can be deleted. Each command becomes `export const command = defineCommand({...})` with plain RESTPostAPIChatInputApplicationCommandsJSONBody data instead of a SlashCommandBuilder chain, and declares `guildOnly` where it is guild-only so the dispatcher earns the <"cached"> narrowing rather than the file asserting it. Subcommand handlers become module-level functions. They had been methods called through `this`, which defineCommand cannot accept: the Command interface declares only execute and autocomplete, so extra properties fail the excess-property check. Standalone functions are what selfroles already uses. moderation also loses two duplicated pairs, both of which carried a TODO asking for exactly this: - invitefilter and detectspam were byte-identical apart from the settings key; they share toggleModule() now. - modlog and joinleave likewise; they share setChannel(). Verified by dumping every command's deployed payload before and after and diffing with keys normalised. spam, moderation and developer are byte identical. The other two differ in exactly the two ways intended: - about: `options: []` is now absent. The builder emitted an empty array; Discord treats absent and empty the same. - voicetext: `dm_permission: false` becomes `contexts: [Guild]`. setDMPermission is deprecated, and every other command in the codebase already declares contexts, so this removes the last inconsistency of that kind outside cleanname. Every command not touched by this commit is byte-identical, confirming the comparison itself is sound. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/about.ts | 21 +-- src/commands/developer.ts | 281 ++++++++++++++++++++----------------- src/commands/moderation.ts | 150 ++++++++------------ src/commands/spam.ts | 83 ++++++----- src/commands/voicetext.ts | 174 +++++++++++------------ 5 files changed, 356 insertions(+), 353 deletions(-) diff --git a/src/commands/about.ts b/src/commands/about.ts index b09aa4f..5ea422e 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,6 +1,7 @@ import childProcess from "child_process"; import {promisify} from "util"; -import {SlashCommandBuilder, EmbedBuilder, ChannelType, ChatInputCommandInteraction, ApplicationIntegrationType, InteractionContextType} from "discord.js"; +import {ApplicationCommandType, ApplicationIntegrationType, ChannelType, EmbedBuilder, InteractionContextType} from "discord.js"; +import {defineCommand} from "../framework"; import type {CommandStats} from "../types"; import {statsDB} from "../db"; import {humanReadableUptime} from "../util/time"; @@ -8,12 +9,14 @@ import {humanReadableUptime} from "../util/time"; const exec = promisify(childProcess.exec); -export default { - data: new SlashCommandBuilder() - .setName("about") - .setDescription("Gives some information about the bot") - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) - .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel), +export const command = defineCommand({ + data: { + type: ApplicationCommandType.ChatInput, + name: "about", + description: "Gives some information about the bot", + integration_types: [ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall], + contexts: [InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel] + }, /** * The one place that still uses an embed rather than a Components V2 @@ -21,7 +24,7 @@ export default { * V2 has no field grid and faking one with padded text does not survive * different client widths. Everything else in the bot sends V2. */ - async execute(interaction: ChatInputCommandInteraction) { + async execute(interaction) { await interaction.deferReply(); const aboutEmbed = new EmbedBuilder(); @@ -115,4 +118,4 @@ export default { // with their OAuth URL constants; restore them from there if wanted. await interaction.editReply({embeds: [aboutEmbed]}); }, -}; +}); diff --git a/src/commands/developer.ts b/src/commands/developer.ts index aed879f..229f021 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -1,4 +1,5 @@ -import {ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder, type GuildTextBasedChannel} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, InteractionContextType, type GuildTextBasedChannel} from "discord.js"; +import {defineCommand} from "../framework"; import {guildDB} from "../db"; import * as notices from "../util/notices"; @@ -8,143 +9,163 @@ const message = `Hi {{user}}, you have just been given the {{role}} role in the const dmMessage = `If you weren't already aware, we have a developer community server where developers can interact, help each other, and ask questions about creating plugins and themes. It's also the primary location for upcoming BetterDiscord news and announcements for developers. We'd love for you to join us if you haven't done so already: https://discord.gg/hC9wzzQeZv`; const channelMessage = `By the way, normally this would have been sent to your DMs, but it seems your privacy settings prevented that. As a heads up, a lot of the information and communication from the website comes through DMs, so I would recommend adjusting that privacy option at least for the developer community server!`; -export default { - data: new SlashCommandBuilder() - .setName("developer") - .setDescription("Manage roles for developers in the community.") - .setContexts(InteractionContextType.Guild) - .addSubcommand( - c => c.setName("add").setDescription("Adds a new developer or new role to an existing developer.") - .addUserOption(opt => - opt.setName("user").setDescription("Who is the developer in question?").setRequired(true) - ) - .addStringOption(opt => - opt.setName("role").setDescription("Role to add.").setRequired(true) - .addChoices({name: "Plugin Developer", value: "Plugin Developer"}, {name: "Theme Developer", value: "Theme Developer"}) - ) - ) - .addSubcommand( - c => c.setName("sync").setDescription("Syncs roles between severs.") - .addUserOption(opt => - opt.setName("user").setDescription("Which developer to resync?").setRequired(true) - ) - ) - .addSubcommand( - c => c.setName("channel").setDescription("Sets a channel to send invite messages.") - // .addStringOption(opt => - // opt.setName("guildId").setDescription("Which server to use as a base?").setRequired(false) - // ) - .addStringOption(opt => - opt.setName("channel").setDescription("Which channel ID to send invites?").setRequired(false) - ) - ), - - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "channel") return await this.channel(interaction); - if (command === "sync") return await this.sync(interaction); - if (command === "add") return await this.add(interaction); - }, - - - async channel(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.member.permissions.has("Administrator")) return await interaction.reply(notices.error("You need to be an administrator to use this command!", {ephemeral: true})); - const targetChannelId = interaction.options.getString("channel"); - - // const targetGuild = await interaction.client.guilds.fetch(targetGuildId); - const targetChannel = targetChannelId ? await interaction.client.channels.fetch(targetChannelId) : null; - - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (targetChannel) { - current.inviteChannel = targetChannel.id; - await guildDB.set(interaction.guild.id, current); +async function channel(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.member.permissions.has("Administrator")) return await interaction.reply(notices.error("You need to be an administrator to use this command!", {ephemeral: true})); + const targetChannelId = interaction.options.getString("channel"); + + // const targetGuild = await interaction.client.guilds.fetch(targetGuildId); + const targetChannel = targetChannelId ? await interaction.client.channels.fetch(targetChannelId) : null; + + const current = await guildDB.get(interaction.guild.id) ?? {}; + if (targetChannel) { + current.inviteChannel = targetChannel.id; + await guildDB.set(interaction.guild.id, current); + } + else { + delete current.inviteChannel; + await guildDB.set(interaction.guild.id, current); + } + await interaction.reply(notices.success(targetChannel ? `Invite message channel set to <#${targetChannel.id}>!` : "Invite message channel has been unset!", {ephemeral: true})); +} + + +async function add(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.member.permissions.has("ManageRoles")) return await interaction.reply(notices.error("You need the `Manage Roles` permission to use this command!", {ephemeral: true})); + await interaction.deferReply({ephemeral: true}); + const targetUser = interaction.options.getUser("user", true); + const roleName = interaction.options.getString("role", true); + + const bdRoleId = roleName.toLowerCase().includes("plugin") ? "125166040689803264" : "165005972970930176"; + const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); + try { + const member = await bdGuild.members.fetch(targetUser); + try { + await member.roles.add(bdRoleId, "Developer verified"); } - else { - delete current.inviteChannel; - await guildDB.set(interaction.guild.id, current); + catch { + await interaction.editReply(notices.error("Could not add roles in main server!", {ephemeral: true})); } - await interaction.reply(notices.success(targetChannel ? `Invite message channel set to <#${targetChannel.id}>!` : "Invite message channel has been unset!", {ephemeral: true})); - }, - - - async add(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.member.permissions.has("ManageRoles")) return await interaction.reply(notices.error("You need the `Manage Roles` permission to use this command!", {ephemeral: true})); - await interaction.deferReply({ephemeral: true}); - const targetUser = interaction.options.getUser("user", true); - const roleName = interaction.options.getString("role", true); - - const bdRoleId = roleName.toLowerCase().includes("plugin") ? "125166040689803264" : "165005972970930176"; - const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); - try { - const member = await bdGuild.members.fetch(targetUser); + } + catch { + await interaction.editReply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); + } + + let messageToSend = message.replace("{{user}}", `<@!${targetUser.id}>`).replace("{{role}}", roleName); + try { + const isMember = await interaction.guild.members.fetch(targetUser); + if (!isMember) messageToSend += "\n\n" + dmMessage; + } + catch { + messageToSend += "\n\n" + dmMessage; + } + + + try { + await targetUser.send(messageToSend); + } + catch { + await interaction.editReply(notices.error("Could not DM user!", {ephemeral: true})); + + const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; + if (guildSettings.inviteChannel) { + messageToSend += "\n\n" + channelMessage; + /** @type {import("discord.js").GuildTextBasedChannel} */ + const inviteChannel = await interaction.client.channels.fetch(guildSettings.inviteChannel) as GuildTextBasedChannel; try { - await member.roles.add(bdRoleId, "Developer verified"); + await inviteChannel?.send(messageToSend); } catch { - await interaction.editReply(notices.error("Could not add roles in main server!", {ephemeral: true})); + await interaction.editReply(notices.error("Could not send a message in the invite channel!", {ephemeral: true})); } } - catch { - await interaction.editReply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); - } - - let messageToSend = message.replace("{{user}}", `<@!${targetUser.id}>`).replace("{{role}}", roleName); - try { - const isMember = await interaction.guild.members.fetch(targetUser); - if (!isMember) messageToSend += "\n\n" + dmMessage; - } - catch { - messageToSend += "\n\n" + dmMessage; - } - - - try { - await targetUser.send(messageToSend); + else { + await interaction.editReply(notices.error("Could not DM user and no fallback channel exists!", {ephemeral: true})); } - catch { - await interaction.editReply(notices.error("Could not DM user!", {ephemeral: true})); - - const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; - if (guildSettings.inviteChannel) { - messageToSend += "\n\n" + channelMessage; - /** @type {import("discord.js").GuildTextBasedChannel} */ - const inviteChannel = await interaction.client.channels.fetch(guildSettings.inviteChannel) as GuildTextBasedChannel; - try { - await inviteChannel?.send(messageToSend); - } - catch { - await interaction.editReply(notices.error("Could not send a message in the invite channel!", {ephemeral: true})); - } - } - else { - await interaction.editReply(notices.error("Could not DM user and no fallback channel exists!", {ephemeral: true})); + } + + await interaction.editReply(notices.success("Role has been added successfully!", {ephemeral: true})); +} + + +async function sync(interaction: ChatInputCommandInteraction<"cached">) { + const targetUser = interaction.options.getUser("user", true); + const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); + const bdMember = await bdGuild.members.fetch(targetUser); + if (!bdMember) return await interaction.reply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); + const isPluginDev = bdMember.roles.cache.has("125166040689803264"); + const isThemeDev = bdMember.roles.cache.has("165005972970930176"); + const rolesToAdd = [isPluginDev ? "948627723830591568" : "", isThemeDev ? "948627648706392104" : ""].filter(r => r); + + const communityMember = await interaction.guild.members.fetch(targetUser); + + try { + await communityMember.roles.add(rolesToAdd, "Syncing roles from main server"); + } + catch { + return await interaction.reply(notices.error("Could not assign roles in this server!", {ephemeral: true})); + } + + await interaction.reply(notices.success("Roles have been synced!", {ephemeral: true})); +} + + +const userOption = (description: string) => ({ + type: ApplicationCommandOptionType.User as const, + name: "user", + description, + required: true +}); + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "developer", + description: "Manage roles for developers in the community.", + contexts: [InteractionContextType.Guild], + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: "add", + description: "Adds a new developer or new role to an existing developer.", + options: [ + userOption("Who is the developer in question?"), + { + type: ApplicationCommandOptionType.String, + name: "role", + description: "Role to add.", + required: true, + choices: [ + {name: "Plugin Developer", value: "Plugin Developer"}, + {name: "Theme Developer", value: "Theme Developer"} + ] + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "sync", + description: "Syncs roles between severs.", + options: [userOption("Which developer to resync?")] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "channel", + description: "Sets a channel to send invite messages.", + options: [{ + type: ApplicationCommandOptionType.String, + name: "channel", + description: "Which channel ID to send invites?", + required: false + }] } - } - - await interaction.editReply(notices.success("Role has been added successfully!", {ephemeral: true})); - }, - - - async sync(interaction: ChatInputCommandInteraction<"cached">) { - const targetUser = interaction.options.getUser("user", true); - const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); - const bdMember = await bdGuild.members.fetch(targetUser); - if (!bdMember) return await interaction.reply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); - const isPluginDev = bdMember.roles.cache.has("125166040689803264"); - const isThemeDev = bdMember.roles.cache.has("165005972970930176"); - const rolesToAdd = [isPluginDev ? "948627723830591568" : "", isThemeDev ? "948627648706392104" : ""].filter(r => r); - - const communityMember = await interaction.guild.members.fetch(targetUser); - - try { - await communityMember.roles.add(rolesToAdd, "Syncing roles from main server"); - } - catch { - return await interaction.reply(notices.error("Could not assign roles in this server!", {ephemeral: true})); - } - - await interaction.reply(notices.success("Roles have been synced!", {ephemeral: true})); + ] }, -}; + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "channel") return await channel(interaction); + if (subcommand === "sync") return await sync(interaction); + if (subcommand === "add") return await add(interaction); + } +}); diff --git a/src/commands/moderation.ts b/src/commands/moderation.ts index 167f51b..6c880b9 100644 --- a/src/commands/moderation.ts +++ b/src/commands/moderation.ts @@ -1,108 +1,76 @@ -import {ChannelType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChannelType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits} from "discord.js"; +import {defineCommand} from "../framework"; import {guildDB} from "../db"; import * as notices from "../util/notices"; +type ModuleKey = "invitefilter" | "detectspam"; +type ChannelKey = "modlog" | "joinleave"; -export default { - data: new SlashCommandBuilder() - .setName("moderation") - .setDescription("Commands for moderating the server.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setContexts(InteractionContextType.Guild) - .addSubcommand( - c => c.setName("invitefilter").setDescription("Toggles the invite filter module.") - .addBooleanOption(opt => - opt.setName("enable").setDescription("Enable or disable").setRequired(false) - ) - ) - .addSubcommand( - c => c.setName("detectspam").setDescription("Toggles the spam detection module.") - .addBooleanOption(opt => - opt.setName("enable").setDescription("Enable or disable").setRequired(false) - ) - ) - .addSubcommand( - c => c.setName("modlog").setDescription("Sets a channel to log bot moderation actions.") - .addChannelOption(opt => - opt.setName("channel").setDescription("Where to log my actions?").setRequired(false) - .addChannelTypes(ChannelType.GuildText) - ) - ) - .addSubcommand( - c => c.setName("joinleave").setDescription("Sets a channel to log join/leave messages.") - .addChannelOption(opt => - opt.setName("channel").setDescription("Where to log join/leave messages?").setRequired(false) - .addChannelTypes(ChannelType.GuildText) - ) - ), - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "invitefilter") return await this.invitefilter(interaction); - if (command === "detectspam") return await this.detectspam(interaction); - if (command === "modlog") return await this.modlog(interaction); - if (command === "joinleave") return await this.joinleave(interaction); - }, +/** Shared by invitefilter and detectspam, which were byte-identical apart from the key. */ +async function toggleModule(interaction: ChatInputCommandInteraction<"cached">, key: ModuleKey) { + const toEnable = interaction.options.getBoolean("enable"); + const current = await guildDB.get(interaction.guild.id) ?? {}; + if (toEnable === null) return await interaction.reply(notices.info(`This module is currently ${current[key] ? "enabled" : "disabled"}.`, {ephemeral: true})); - /** - * TODO: de-dup with detectspam - */ - async invitefilter(interaction: ChatInputCommandInteraction<"cached">) { - const toEnable = interaction.options.getBoolean("enable"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (toEnable === null) return await interaction.reply(notices.info(`This module is currently ${current.invitefilter ? "enabled" : "disabled"}.`, {ephemeral: true})); + current[key] = toEnable; + await guildDB.set(interaction.guild.id, current); - current.invitefilter = toEnable; - await guildDB.set(interaction.guild.id, current); + await interaction.reply(notices.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); +} - await interaction.reply(notices.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); - }, +/** Shared by modlog and joinleave, likewise. */ +async function setChannel(interaction: ChatInputCommandInteraction<"cached">, key: ChannelKey, label: string) { + const targetChannel = interaction.options.getChannel("channel"); + const current = await guildDB.get(interaction.guild.id) ?? {}; - // TODO: move this to spam.ts - async detectspam(interaction: ChatInputCommandInteraction<"cached">) { - const toEnable = interaction.options.getBoolean("enable"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (toEnable === null) return await interaction.reply(notices.info(`This module is currently ${current.detectspam ? "enabled" : "disabled"}.`, {ephemeral: true})); + if (targetChannel) current[key] = targetChannel.id; + else delete current[key]; + await guildDB.set(interaction.guild.id, current); - current.detectspam = toEnable; - await guildDB.set(interaction.guild.id, current); + await interaction.reply(notices.success(targetChannel ? `${label} set to <#${targetChannel.id}>!` : `${label} has been unset!`, {ephemeral: true})); +} - await interaction.reply(notices.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); - }, +const toggleOption = { + type: ApplicationCommandOptionType.Boolean as const, + name: "enable", + description: "Enable or disable", + required: false +}; - /** - * TODO: de-dup with joinleave - */ - async modlog(interaction: ChatInputCommandInteraction<"cached">) { - const targetChannel = interaction.options.getChannel("channel"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (targetChannel) { - current.modlog = targetChannel.id; - await guildDB.set(interaction.guild.id, current); - } - else { - delete current.modlog; - await guildDB.set(interaction.guild.id, current); - } - await interaction.reply(notices.success(targetChannel ? `Modlog set to <#${targetChannel.id}>!` : "Modlog has been unset!", {ephemeral: true})); +const channelOption = (description: string) => ({ + type: ApplicationCommandOptionType.Channel as const, + name: "channel", + description, + required: false, + channel_types: [ChannelType.GuildText as const] +}); + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "moderation", + description: "Commands for moderating the server.", + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + contexts: [InteractionContextType.Guild], + options: [ + {type: ApplicationCommandOptionType.Subcommand, name: "invitefilter", description: "Toggles the invite filter module.", options: [toggleOption]}, + {type: ApplicationCommandOptionType.Subcommand, name: "detectspam", description: "Toggles the spam detection module.", options: [toggleOption]}, + {type: ApplicationCommandOptionType.Subcommand, name: "modlog", description: "Sets a channel to log bot moderation actions.", options: [channelOption("Where to log my actions?")]}, + {type: ApplicationCommandOptionType.Subcommand, name: "joinleave", description: "Sets a channel to log join/leave messages.", options: [channelOption("Where to log join/leave messages?")]} + ] }, - - async joinleave(interaction: ChatInputCommandInteraction<"cached">) { - const targetChannel = interaction.options.getChannel("channel"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (targetChannel) { - current.joinleave = targetChannel.id; - await guildDB.set(interaction.guild.id, current); - } - else { - delete current.joinleave; - await guildDB.set(interaction.guild.id, current); - } - await interaction.reply(notices.success(targetChannel ? `Join/leave set to <#${targetChannel.id}>!` : "Join/leave has been unset!", {ephemeral: true})); - }, -}; + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "invitefilter") return await toggleModule(interaction, "invitefilter"); + if (subcommand === "detectspam") return await toggleModule(interaction, "detectspam"); + if (subcommand === "modlog") return await setChannel(interaction, "modlog", "Modlog"); + if (subcommand === "joinleave") return await setChannel(interaction, "joinleave", "Join/leave"); + } +}); diff --git a/src/commands/spam.ts b/src/commands/spam.ts index af5d966..d066e42 100644 --- a/src/commands/spam.ts +++ b/src/commands/spam.ts @@ -1,43 +1,52 @@ -import {ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits} from "discord.js"; +import {defineCommand} from "../framework"; import * as notices from "../util/notices"; // TODO: move detectspam from moderation to here -export default { - data: new SlashCommandBuilder() - .setName("spam") - .setDescription("Commands for dealing with spam.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .setContexts(InteractionContextType.Guild) - .addSubcommand( - c => c.setName("link").setDescription("Adds a link to the automod spam link filter") - .addStringOption(opt => - opt.setName("link").setDescription("Link to add to the filter").setRequired(true) - ) - ), - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "link") return await this.link(interaction); +async function addLink(interaction: ChatInputCommandInteraction<"cached">) { + const rule = await interaction.guild.autoModerationRules.fetch("1256935881168781332"); + if (!rule) return await interaction.reply(notices.error("Spam link filter rule not found! Report this to Zerebos!", {ephemeral: true})); + + const existing = rule.triggerMetadata?.keywordFilter ?? []; + const link = interaction.options.getString("link", true); + + if (existing.includes(link)) return await interaction.reply(notices.info("This link is already in the spam filter!", {ephemeral: true})); + + await rule.edit({ + triggerMetadata: { + keywordFilter: [...existing, link], + } + }); + + // Don't make this ephemeral since it's useful to see who added what link + await interaction.reply(notices.success("Link added to spam filter!")); +} + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "spam", + description: "Commands for dealing with spam.", + default_member_permissions: PermissionFlagsBits.ManageMessages.toString(), + contexts: [InteractionContextType.Guild], + options: [{ + type: ApplicationCommandOptionType.Subcommand, + name: "link", + description: "Adds a link to the automod spam link filter", + options: [{ + type: ApplicationCommandOptionType.String, + name: "link", + description: "Link to add to the filter", + required: true + }] + }] }, - - async link(interaction: ChatInputCommandInteraction<"cached">) { - const rule = await interaction.guild.autoModerationRules.fetch("1256935881168781332"); - if (!rule) return await interaction.reply(notices.error("Spam link filter rule not found! Report this to Zerebos!", {ephemeral: true})); - - const existing = rule.triggerMetadata?.keywordFilter ?? []; - const link = interaction.options.getString("link", true); - - if (existing.includes(link)) return await interaction.reply(notices.info("This link is already in the spam filter!", {ephemeral: true})); - - await rule.edit({ - triggerMetadata: { - keywordFilter: [...existing, link], - } - }); - - // Don't make this ephemeral since it's useful to see who added what link - await interaction.reply(notices.success("Link added to spam filter!")); - }, -}; + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "link") return await addLink(interaction); + } +}); diff --git a/src/commands/voicetext.ts b/src/commands/voicetext.ts index 9b80e75..468f7bf 100644 --- a/src/commands/voicetext.ts +++ b/src/commands/voicetext.ts @@ -1,101 +1,103 @@ -import {ChannelType, ChatInputCommandInteraction, GuildChannel, OverwriteType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChannelType, ChatInputCommandInteraction, GuildChannel, InteractionContextType, OverwriteType, PermissionFlagsBits} from "discord.js"; +import {defineCommand} from "../framework"; import {voicetextDB} from "../db"; import * as notices from "../util/notices"; -export default { - data: new SlashCommandBuilder() - .setName("voicetext") - .setDescription("Binds one voice and one text channel together.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand( - c => c.setName("status").setDescription("Checks the bound status of a voice channel.") - .addChannelOption(opt => - opt.setName("channel").setRequired(true) - .setDescription("Which voice channel to check?") - .addChannelTypes(ChannelType.GuildVoice) - ) - ) - .addSubcommand( - c => c.setName("unbind").setDescription("Unbinds a voice channel from it's partner.") - .addChannelOption(opt => - opt.setName("channel").setRequired(true) - .setDescription("Which voice channel to unbind?") - .addChannelTypes(ChannelType.GuildVoice) - ) - ) - .addSubcommand( - c => c.setName("bind").setDescription("Binds a voice and text channel together.") - .addChannelOption(opt => - opt.setName("voice").setRequired(true) - .setDescription("Which voice channel to bind?") - .addChannelTypes(ChannelType.GuildVoice) - ) - .addChannelOption(opt => - opt.setName("text").setRequired(true) - .setDescription("Which text channel to bind with?") - .addChannelTypes(ChannelType.GuildText) - ) - ), - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "bind") return await this.bind(interaction); - if (command === "unbind") return await this.unbind(interaction); - if (command === "status") return await this.status(interaction); - }, +const voiceOption = (description: string, name = "channel") => ({ + type: ApplicationCommandOptionType.Channel as const, + name, + description, + required: true, + channel_types: [ChannelType.GuildVoice as const] +}); +async function bind(interaction: ChatInputCommandInteraction<"cached">) { + const voice = interaction.options.getChannel("voice", true); + const text = interaction.options.getChannel("text", true); + if (voice.type !== ChannelType.GuildVoice) return await interaction.reply(notices.error("The voice channel must be a voice channel.", {ephemeral: true})); + if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); - async bind(interaction: ChatInputCommandInteraction<"cached">) { - const voice = interaction.options.getChannel("voice", true); - const text = interaction.options.getChannel("text", true); - if (voice.type !== ChannelType.GuildVoice) return await interaction.reply(notices.error("The voice channel must be a voice channel.", {ephemeral: true})); - if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); + const partner = await voicetextDB.get(voice.id) ?? ""; + if (partner) return await interaction.reply(notices.error(`<#${voice.id}> is already bound to <#${partner}>. Please unbind before continuing.`, {ephemeral: true})); - const partner = await voicetextDB.get(voice.id) ?? ""; - if (partner) return await interaction.reply(notices.error(`<#${voice.id}> is already bound to <#${partner}>. Please unbind before continuing.`, {ephemeral: true})); + try { + await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: false}, {reason: "Bind text and voice channel", type: OverwriteType.Role}); + } + catch (err) { + console.error(err); + return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); + } - try { - await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: false}, {reason: "Bind text and voice channel", type: OverwriteType.Role}); - } - catch (err) { - console.error(err); - return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); - } + await voicetextDB.set(voice.id, text.id); + await interaction.reply(notices.success(`<#${voice.id}> is now bound to <#${text.id}>!`, {ephemeral: true})); +} - await voicetextDB.set(voice.id, text.id); - await interaction.reply(notices.success(`<#${voice.id}> is now bound to <#${text.id}>!`, {ephemeral: true})); - }, +async function unbind(interaction: ChatInputCommandInteraction<"cached">) { + const targetChannel = interaction.options.getChannel("channel", true); + const partner = await voicetextDB.get(targetChannel.id) ?? ""; + if (!partner) return await interaction.reply(notices.error(`<#${targetChannel.id}> is not bound.`, {ephemeral: true})); - async unbind(interaction: ChatInputCommandInteraction<"cached">) { - const targetChannel = interaction.options.getChannel("channel", true); - const partner = await voicetextDB.get(targetChannel.id) ?? ""; - if (!partner) return await interaction.reply(notices.error(`<#${targetChannel.id}> is not bound.`, {ephemeral: true})); - - /** - * @type {import("discord.js").GuildChannel} - */ - const text = interaction.guild.channels.cache.get(partner) as GuildChannel; - if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); - try { - await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: null}, {reason: "Unbind text and voice channel", type: OverwriteType.Role}); - } - catch (err) { - console.error(err); - return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); - } - - await voicetextDB.delete(targetChannel.id); - await interaction.reply(notices.success(`<#${targetChannel.id}> is now unbound!`, {ephemeral: true})); - }, + /** + * @type {import("discord.js").GuildChannel} + */ + const text = interaction.guild.channels.cache.get(partner) as GuildChannel; + if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); + try { + await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: null}, {reason: "Unbind text and voice channel", type: OverwriteType.Role}); + } + catch (err) { + console.error(err); + return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); + } + + await voicetextDB.delete(targetChannel.id); + await interaction.reply(notices.success(`<#${targetChannel.id}> is now unbound!`, {ephemeral: true})); +} - async status(interaction: ChatInputCommandInteraction) { - const targetChannel = interaction.options.getChannel("channel", true); - const partner = await voicetextDB.get(targetChannel.id) ?? ""; - await interaction.reply(notices.info(partner ? `<#${targetChannel.id}> is bound to <#${partner}>` : `This channel <#${targetChannel.id}> is not bound.`, {ephemeral: true})); +async function status(interaction: ChatInputCommandInteraction) { + const targetChannel = interaction.options.getChannel("channel", true); + const partner = await voicetextDB.get(targetChannel.id) ?? ""; + await interaction.reply(notices.info(partner ? `<#${targetChannel.id}> is bound to <#${partner}>` : `This channel <#${targetChannel.id}> is not bound.`, {ephemeral: true})); +} + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "voicetext", + description: "Binds one voice and one text channel together.", + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + contexts: [InteractionContextType.Guild], + options: [ + {type: ApplicationCommandOptionType.Subcommand, name: "status", description: "Checks the bound status of a voice channel.", options: [voiceOption("Which voice channel to check?")]}, + {type: ApplicationCommandOptionType.Subcommand, name: "unbind", description: "Unbinds a voice channel from it's partner.", options: [voiceOption("Which voice channel to unbind?")]}, + { + type: ApplicationCommandOptionType.Subcommand, + name: "bind", + description: "Binds a voice and text channel together.", + options: [ + voiceOption("Which voice channel to bind?", "voice"), + { + type: ApplicationCommandOptionType.Channel as const, + name: "text", + description: "Which text channel to bind with?", + required: true, + channel_types: [ChannelType.GuildText as const] + } + ] + } + ] }, -}; + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "bind") return await bind(interaction); + if (subcommand === "unbind") return await unbind(interaction); + if (subcommand === "status") return await status(interaction); + } +}); From 4ba94b49998a2584cbdfe7a1f9a52bc30ce5d80c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:36:21 +0000 Subject: [PATCH 11/22] Migrate addons and tags to defineCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch. Both have autocomplete as well as execute, so they exercise the optional-handler half of the Command type that the old CommandModule made mandatory. Subcommand handlers become module-level functions, as in the previous commit. `delete` is renamed `remove` since it cannot be a bare function name in that position. addons also loses an annotation that was quietly wrong. Every handler was typed ChatInputCommandInteraction<"cached">, but the command declares contexts of Guild, BotDM and PrivateChannel, so it runs in DMs where there is no cached guild. Nothing in the command or in util/addons.ts ever touches interaction.guild โ€” the only `.guild` references are `addon.author.guild` from the BetterDiscord API โ€” so the annotation was pure assertion. It is now plain ChatInputCommandInteraction, and paginateAddonPages loosened to match. This is the class of bug the old signature invited: `execute` was declared `(interaction: T)`, so the caller chose T and no narrowing was ever checked. Verified against the payload baseline again, this time normalising the `options: []` the builder emitted for option-less subcommands (Discord treats absent and empty the same). With that normalised, nine of ten commands are byte-identical, including addons and tag. The tenth is voicetext, whose dm_permission -> contexts change was the intended one from the previous commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/addons.ts | 342 ++++++++++++++++++++++------------------- src/commands/tags.ts | 236 ++++++++++++++-------------- src/util/addons.ts | 4 +- 3 files changed, 302 insertions(+), 280 deletions(-) diff --git a/src/commands/addons.ts b/src/commands/addons.ts index 53291e1..38664bf 100644 --- a/src/commands/addons.ts +++ b/src/commands/addons.ts @@ -1,4 +1,5 @@ -import {ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, InteractionContextType, MessageFlags, SlashCommandBuilder, type AutocompleteFocusedOption} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, InteractionContextType, MessageFlags, type AutocompleteFocusedOption} from "discord.js"; +import {defineCommand} from "../framework"; import * as notices from "../util/notices"; import type {BdWebAddon, BdWebTag} from "../types"; import Similarity from "string-similarity"; @@ -9,173 +10,194 @@ import {cache, ensureCache, createAddonComponent, paginateAddonPages, sortAddons const TAG_CHOICES = [...Web.store.tags.plugin, ...Web.store.tags.theme]; -export default { - data: new SlashCommandBuilder() - .setName("addons") - .setDescription("Commands for addons.") - .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel) - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) - .addSubcommand(c => c.setName("updated").setDescription("Shows the most recently updated addons")) - .addSubcommand(c => c.setName("newest").setDescription("Shows the newest added addons")) - .addSubcommand(c => c.setName("top").setDescription("Shows the most liked addons")) - .addSubcommand(c => c.setName("popular").setDescription("Shows the most downloaded addons")) - .addSubcommand(c => c.setName("random").setDescription("Shows a random addon")) - .addSubcommand(c => c.setName("search").setDescription("Searches for an addon by name") - .addStringOption(opt => opt.setName("name").setDescription("Name of the addon to find").setRequired(true).setAutocomplete(true)) - ) - .addSubcommand(c => c.setName("info").setDescription("Gets information about an addon") - .addStringOption(opt => opt.setName("name").setDescription("Name of the addon to get info about").setRequired(true).setAutocomplete(true)) - ) - .addSubcommand(c => c.setName("browse").setDescription("Browse addons in an interactive way") - .addStringOption(opt => - opt.setName("tag").setDescription("tag to browse").setRequired(false).setAutocomplete(true) - ) - .addStringOption(opt => - opt.setName("type").setDescription("type to browse").setRequired(false).addChoices( - {name: "Plugin", value: "plugin"}, - {name: "Theme", value: "theme"}, - ) - ) - .addStringOption(opt => - opt.setName("sort").setDescription("sort method").setRequired(false).addChoices( - {name: "Newest", value: "initial_release_date"}, - {name: "Last Updated", value: "latest_release_date"}, - {name: "Most Liked", value: "likes"}, - {name: "Popular", value: "downloads"}, - ) - ) - ), - - /** +/** * Main function for addons command */ - async execute(interaction: ChatInputCommandInteraction<"cached">) { - await interaction.deferReply(); - await ensureCache(); - const command = interaction.options.getSubcommand(); - if (command === "search") return await this.search(interaction); - if (command === "browse") return await this.browse(interaction); - if (command === "updated") return await this.top10(interaction, "latest_release_date"); - if (command === "newest") return await this.top10(interaction, "initial_release_date"); - if (command === "top") return await this.top10(interaction, "likes"); - if (command === "popular") return await this.top10(interaction, "downloads"); - if (command === "random") return await this.random(interaction); - if (command === "info") return await this.info(interaction); - return await interaction.editReply(notices.error("This command is not yet implemented.")); - }, - /** - * Complex commands - */ - - async browse(interaction: ChatInputCommandInteraction<"cached">) { - const tag = interaction.options.getString("tag"); - const type = interaction.options.getString("type"); - const sort = interaction.options.getString("sort") || "downloads"; - - const filteredAddons = Array.from(cache).filter(addon => { - if (tag && !addon.tags.includes(tag as BdWebTag)) return false; - if (type && addon.type !== type) return false; - return true; - }); - - // No need to continue if there are no results - if (filteredAddons.length === 0) return await interaction.editReply(notices.error("No addons found with the specified criteria.")); - - sortAddons(filteredAddons, sort as "likes" | "downloads" | "initial_release_date" | "latest_release_date"); - - const title: string[] = []; - title.push(type ? type.charAt(0).toUpperCase() + type.slice(1) + "s" : "Addons"); - if (tag) title.push(`with tag \`${tag}\``); - title.push(`sorted by ${sort.replace(/_/g, " ")}`); - - await paginate({ - interaction, - items: filteredAddons, - perPage: 3, - renderPage: addons => createAddonList(title.join(" "), addons), - }); - }, - - async search(interaction: ChatInputCommandInteraction<"cached">) { - const name = interaction.options.getString("name", true).toLowerCase(); - let results: BdWebAddon[] = []; - for (const addon of cache) { - if (addon.name.toLowerCase().includes(name) || (addon.description?.toLowerCase().includes(name))) { - results.push(addon); - } +async function browse(interaction: ChatInputCommandInteraction) { + const tag = interaction.options.getString("tag"); + const type = interaction.options.getString("type"); + const sort = interaction.options.getString("sort") || "downloads"; + + const filteredAddons = Array.from(cache).filter(addon => { + if (tag && !addon.tags.includes(tag as BdWebTag)) return false; + if (type && addon.type !== type) return false; + return true; + }); + + // No need to continue if there are no results + if (filteredAddons.length === 0) return await interaction.editReply(notices.error("No addons found with the specified criteria.")); + + sortAddons(filteredAddons, sort as "likes" | "downloads" | "initial_release_date" | "latest_release_date"); + + const title: string[] = []; + title.push(type ? type.charAt(0).toUpperCase() + type.slice(1) + "s" : "Addons"); + if (tag) title.push(`with tag \`${tag}\``); + title.push(`sorted by ${sort.replace(/_/g, " ")}`); + + await paginate({ + interaction, + items: filteredAddons, + perPage: 3, + renderPage: addons => createAddonList(title.join(" "), addons), + }); +} + +async function search(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true).toLowerCase(); + let results: BdWebAddon[] = []; + for (const addon of cache) { + if (addon.name.toLowerCase().includes(name) || (addon.description?.toLowerCase().includes(name))) { + results.push(addon); } - - results = Similarity.findBestMatch(name, results.map(a => a.name)).ratings - .sort((a, b) => b.rating - a.rating) - .slice(0, 10) - .map(rating => results.find(a => a.name === rating.target)!) - .filter(a => !!a); - - await paginateAddonPages(interaction, results); - }, - - /** - * Simple commands - */ - - async top10(interaction: ChatInputCommandInteraction<"cached">, sortBy: "likes" | "downloads" | "initial_release_date" | "latest_release_date") { - await paginateAddonPages(interaction, sortAddons(Array.from(cache), sortBy).slice(0, 10)); - }, - - async random(interaction: ChatInputCommandInteraction<"cached">) { - const addonsArray = Array.from(cache); - const randomAddon = addonsArray[Math.floor(Math.random() * addonsArray.length)]; - return await interaction.editReply({components: [createAddonComponent(randomAddon)], flags: MessageFlags.IsComponentsV2}); - }, - - async info(interaction: ChatInputCommandInteraction<"cached">) { - const name = interaction.options.getString("name", true).toLowerCase(); - const addon = Array.from(cache).find(a => a.name.toLowerCase() === name); - if (!addon) return await interaction.editReply(notices.error("No addon found with that name.")); - return await interaction.editReply({components: [createAddonComponent(addon)], flags: MessageFlags.IsComponentsV2}); + } + + results = Similarity.findBestMatch(name, results.map(a => a.name)).ratings + .sort((a, b) => b.rating - a.rating) + .slice(0, 10) + .map(rating => results.find(a => a.name === rating.target)!) + .filter(a => !!a); + + await paginateAddonPages(interaction, results); +} + + +async function top10(interaction: ChatInputCommandInteraction, sortBy: "likes" | "downloads" | "initial_release_date" | "latest_release_date") { + await paginateAddonPages(interaction, sortAddons(Array.from(cache), sortBy).slice(0, 10)); +} + +async function random(interaction: ChatInputCommandInteraction) { + const addonsArray = Array.from(cache); + const randomAddon = addonsArray[Math.floor(Math.random() * addonsArray.length)]; + return await interaction.editReply({components: [createAddonComponent(randomAddon)], flags: MessageFlags.IsComponentsV2}); +} + +async function info(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true).toLowerCase(); + const addon = Array.from(cache).find(a => a.name.toLowerCase() === name); + if (!addon) return await interaction.editReply(notices.error("No addon found with that name.")); + return await interaction.editReply({components: [createAddonComponent(addon)], flags: MessageFlags.IsComponentsV2}); +} + + + +async function autocomplete(interaction: AutocompleteInteraction) { + await ensureCache(); + const focusedValue = interaction.options.getFocused(true); + if (focusedValue.name === "name") return await autocompleteName(interaction, focusedValue); + if (focusedValue.name === "tag") return await autocompleteTag(interaction, focusedValue); +} + +async function autocompleteName(interaction: AutocompleteInteraction, focused: AutocompleteFocusedOption) { + const names = Array.from(cache).map(addon => addon.name); + if (focused.value.length === 0) { + const results = names.slice(0, 25).map(name => ({name, value: name})); + return await interaction.respond(results); + } + + const results = Similarity.findBestMatch(focused.value, names).ratings + .sort((a, b) => b.rating - a.rating) + .slice(0, 25) + .map(rating => ({name: rating.target, value: rating.target})); + + await interaction.respond(results); +} + +async function autocompleteTag(interaction: AutocompleteInteraction, focused: AutocompleteFocusedOption) { + if (focused.value.length === 0) { + const results = TAG_CHOICES.slice(0, 25).map(name => ({name, value: name})); + return await interaction.respond(results); + } + + const results = Similarity.findBestMatch(focused.value, TAG_CHOICES).ratings + .sort((a, b) => b.rating - a.rating) + .slice(0, 25) + .map(rating => ({name: rating.target, value: rating.target})); + + await interaction.respond(results); +} + + +const nameOpt = (description: string) => ({ + type: ApplicationCommandOptionType.String as const, + name: "name", + description, + required: true, + autocomplete: true +}); + +const simple = (name: string, description: string) => ({ + type: ApplicationCommandOptionType.Subcommand as const, + name, + description +}); + + +export const command = defineCommand({ + data: { + type: ApplicationCommandType.ChatInput, + name: "addons", + description: "Commands for addons.", + contexts: [InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel], + integration_types: [ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall], + options: [ + simple("updated", "Shows the most recently updated addons"), + simple("newest", "Shows the newest added addons"), + simple("top", "Shows the most liked addons"), + simple("popular", "Shows the most downloaded addons"), + simple("random", "Shows a random addon"), + {...simple("search", "Searches for an addon by name"), options: [nameOpt("Name of the addon to find")]}, + {...simple("info", "Gets information about an addon"), options: [nameOpt("Name of the addon to get info about")]}, + { + ...simple("browse", "Browse addons in an interactive way"), + options: [ + { + type: ApplicationCommandOptionType.String as const, + name: "tag", + description: "tag to browse", + required: false, + autocomplete: true + }, + { + type: ApplicationCommandOptionType.String as const, + name: "type", + description: "type to browse", + required: false, + choices: [{name: "Plugin", value: "plugin"}, {name: "Theme", value: "theme"}] + }, + { + type: ApplicationCommandOptionType.String as const, + name: "sort", + description: "sort method", + required: false, + choices: [ + {name: "Newest", value: "initial_release_date"}, + {name: "Last Updated", value: "latest_release_date"}, + {name: "Most Liked", value: "likes"}, + {name: "Popular", value: "downloads"} + ] + } + ] + } + ] }, - - /** - * Autocomplete handlers for tags and addon names - */ - - async autocomplete(interaction: AutocompleteInteraction<"cached">) { + async execute(interaction) { + await interaction.deferReply(); await ensureCache(); - const focusedValue = interaction.options.getFocused(true); - if (focusedValue.name === "name") return await this.autocompleteName(interaction, focusedValue); - if (focusedValue.name === "tag") return await this.autocompleteTag(interaction, focusedValue); - }, + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "search") return await search(interaction); + if (subcommand === "browse") return await browse(interaction); + if (subcommand === "updated") return await top10(interaction, "latest_release_date"); + if (subcommand === "newest") return await top10(interaction, "initial_release_date"); + if (subcommand === "top") return await top10(interaction, "likes"); + if (subcommand === "popular") return await top10(interaction, "downloads"); + if (subcommand === "random") return await random(interaction); + if (subcommand === "info") return await info(interaction); - async autocompleteName(interaction: AutocompleteInteraction<"cached">, focused: AutocompleteFocusedOption) { - const names = Array.from(cache).map(addon => addon.name); - if (focused.value.length === 0) { - const results = names.slice(0, 25).map(name => ({name, value: name})); - return await interaction.respond(results); - } - - const results = Similarity.findBestMatch(focused.value, names).ratings - .sort((a, b) => b.rating - a.rating) - .slice(0, 25) - .map(rating => ({name: rating.target, value: rating.target})); - - await interaction.respond(results); - }, - - async autocompleteTag(interaction: AutocompleteInteraction<"cached">, focused: AutocompleteFocusedOption) { - if (focused.value.length === 0) { - const results = TAG_CHOICES.slice(0, 25).map(name => ({name, value: name})); - return await interaction.respond(results); - } - - const results = Similarity.findBestMatch(focused.value, TAG_CHOICES).ratings - .sort((a, b) => b.rating - a.rating) - .slice(0, 25) - .map(rating => ({name: rating.target, value: rating.target})); - - await interaction.respond(results); + return await interaction.editReply(notices.error("This command is not yet implemented.")); }, -}; + autocomplete +}); diff --git a/src/commands/tags.ts b/src/commands/tags.ts index 0eba6df..1e40708 100644 --- a/src/commands/tags.ts +++ b/src/commands/tags.ts @@ -3,6 +3,7 @@ import { AutocompleteInteraction, ChatInputCommandInteraction, ComponentType, InteractionContextType, MessageFlags, type RESTPostAPIChatInputApplicationCommandsJSONBody } from "discord.js"; +import {defineCommand} from "../framework"; import type {AtLeast, Tag} from "../types"; import {tagsDB} from "../db"; import {msInMinute} from "../util/time"; @@ -10,12 +11,12 @@ import {tagContainer, updateTagModal} from "../components/tags"; import {error, info, success} from "../util/notices"; -const nameOption = (description: string, autocomplete: boolean) => ({ +const nameOption = (description: string, withAutocomplete: boolean) => ({ type: ApplicationCommandOptionType.String as const, name: "name", description, required: true, - autocomplete + autocomplete: withAutocomplete }); const data: RESTPostAPIChatInputApplicationCommandsJSONBody = { @@ -34,139 +35,138 @@ const data: RESTPostAPIChatInputApplicationCommandsJSONBody = { }; -export default { - data, +async function view(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply(); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (!tag) { + return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); + } + + return await interaction.editReply({ + flags: MessageFlags.IsComponentsV2, + components: [tagContainer(tag)] + }); +} + +async function create(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to create tags.", {ephemeral: true})); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (tag) return await interaction.reply(error(`Tag with name \`${tagName}\` already exists.`, {ephemeral: true})); + return await showTagModal(interaction, {name: tagName}); +} + +async function update(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to update tags.", {ephemeral: true})); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (!tag) return await interaction.reply(error(`Tag with name \`${tagName}\` does not exist.`, {ephemeral: true})); + return await showTagModal(interaction, tag); +} + +async function remove(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply({flags: MessageFlags.Ephemeral}); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(error("You do not have permission to delete tags.")); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (!tag) { + return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); + } + + delete guildTags[tagName]; + await tagsDB.set(interaction.guildId, guildTags); + + return await interaction.editReply(success(`Tag with name \`${tagName}\` has been deleted.`)); +} + +async function list(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply({flags: MessageFlags.Ephemeral}); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tagNames = Object.keys(guildTags); + if (tagNames.length === 0) { + return await interaction.editReply(info("There are no tags in this server yet.")); + } + + return await interaction.editReply({ + flags: MessageFlags.IsComponentsV2, + components: [{ + type: ComponentType.Container, + components: [{ + type: ComponentType.TextDisplay, + content: `**Tags in this server:**\n${tagNames.map(name => `- \`${name}\``).join("\n")}` + }] + }] + }); +} - /** - * Main function for tag command - */ - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "view") return await this.view(interaction); - if (command === "create") return await this.create(interaction); - if (command === "update") return await this.update(interaction); - if (command === "delete") return await this.delete(interaction); - if (command === "list") return await this.list(interaction); - return await interaction.reply(error("This command is not yet implemented.", {ephemeral: true})); - }, +async function showTagModal(interaction: ChatInputCommandInteraction<"cached">, tag: AtLeast) { + const isUpdating = !!tag.content; - async view(interaction: ChatInputCommandInteraction<"cached">) { - await interaction.deferReply(); - const tagName = interaction.options.getString("name", true); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (!tag) { - return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); - } - - return await interaction.editReply({ - flags: MessageFlags.IsComponentsV2, - components: [tagContainer(tag)] - }); - }, + await interaction.showModal(updateTagModal(tag)); - async create(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to create tags.", {ephemeral: true})); - const tagName = interaction.options.getString("name", true); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (tag) return await interaction.reply(error(`Tag with name \`${tagName}\` already exists.`, {ephemeral: true})); - return await this.showTagModal(interaction, {name: tagName}); - }, + try { + const modalInteraction = await interaction.awaitModalSubmit({time: msInMinute * 5}); + const title = modalInteraction.fields.getTextInputValue("title"); + const content = modalInteraction.fields.getTextInputValue("content"); + const thumbnailUrl = modalInteraction.fields.getTextInputValue("thumbnail"); - async update(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to update tags.", {ephemeral: true})); - const tagName = interaction.options.getString("name", true); const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (!tag) return await interaction.reply(error(`Tag with name \`${tagName}\` does not exist.`, {ephemeral: true})); - return await this.showTagModal(interaction, tag); - }, + guildTags[tag.name] = { + name: tag.name, + title: title || undefined, + content, + thumbnailUrl: thumbnailUrl || undefined, + }; + await tagsDB.set(interaction.guildId, guildTags); - async delete(interaction: ChatInputCommandInteraction<"cached">) { - await interaction.deferReply({flags: MessageFlags.Ephemeral}); - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(error("You do not have permission to delete tags.")); - const tagName = interaction.options.getString("name", true); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (!tag) { - return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); - } + await modalInteraction.reply(success(`Tag \`${tag.name}\` has been ${isUpdating ? "updated" : "created"} successfully!`)); + } + catch { + await interaction.followUp(error("Modal submission timed out!")); + } +} - delete guildTags[tagName]; - await tagsDB.set(interaction.guildId, guildTags); - return await interaction.editReply(success(`Tag with name \`${tagName}\` has been deleted.`)); - }, +/** Autocomplete for the tag-name option on view / update / delete. */ +async function autocomplete(interaction: AutocompleteInteraction<"cached">) { + const focusedValue = interaction.options.getFocused(); - async list(interaction: ChatInputCommandInteraction<"cached">) { - await interaction.deferReply({flags: MessageFlags.Ephemeral}); + if (interaction.options.getSubcommand() === "view" || interaction.options.getSubcommand() === "update" || interaction.options.getSubcommand() === "delete") { const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tagNames = Object.keys(guildTags); - if (tagNames.length === 0) { - return await interaction.editReply(info("There are no tags in this server yet.")); - } + const tags = Object.keys(guildTags); - return await interaction.editReply({ - flags: MessageFlags.IsComponentsV2, - components: [{ - type: ComponentType.Container, - components: [{ - type: ComponentType.TextDisplay, - content: `**Tags in this server:**\n${tagNames.map(name => `- \`${name}\``).join("\n")}` - }] - }] - }); - }, + const filtered = tags.filter(tag => tag.toLowerCase().startsWith(focusedValue.toLowerCase())); + const limited = filtered.slice(0, 25); + return await interaction.respond( + limited.map(tag => ({name: tag, value: tag})) + ); + } - async showTagModal(interaction: ChatInputCommandInteraction<"cached">, tag: AtLeast) { - const isUpdating = !!tag.content; + return await interaction.respond([]); +} - await interaction.showModal(updateTagModal(tag)); - try { - const modalInteraction = await interaction.awaitModalSubmit({time: msInMinute * 5}); - const title = modalInteraction.fields.getTextInputValue("title"); - const content = modalInteraction.fields.getTextInputValue("content"); - const thumbnailUrl = modalInteraction.fields.getTextInputValue("thumbnail"); +export const command = defineCommand({ + guildOnly: true, + data, - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - guildTags[tag.name] = { - name: tag.name, - title: title || undefined, - content, - thumbnailUrl: thumbnailUrl || undefined, - }; - await tagsDB.set(interaction.guildId, guildTags); + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "view") return await view(interaction); + if (subcommand === "create") return await create(interaction); + if (subcommand === "update") return await update(interaction); + if (subcommand === "delete") return await remove(interaction); + if (subcommand === "list") return await list(interaction); - await modalInteraction.reply(success(`Tag \`${tag.name}\` has been ${isUpdating ? "updated" : "created"} successfully!`)); - } - catch { - await interaction.followUp(error("Modal submission timed out!")); - } + return await interaction.reply(error("This command is not yet implemented.", {ephemeral: true})); }, - - /** - * Autocomplete handlers for tags - */ - async autocomplete(interaction: AutocompleteInteraction<"cached">) { - const focusedValue = interaction.options.getFocused(); - - if (interaction.options.getSubcommand() === "view" || interaction.options.getSubcommand() === "update" || interaction.options.getSubcommand() === "delete") { - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tags = Object.keys(guildTags); - - const filtered = tags.filter(tag => tag.toLowerCase().startsWith(focusedValue.toLowerCase())); - const limited = filtered.slice(0, 25); - - return await interaction.respond( - limited.map(tag => ({name: tag, value: tag})) - ); - } - - return await interaction.respond([]); - }, -}; + autocomplete +}); diff --git a/src/util/addons.ts b/src/util/addons.ts index 0626ea3..b53bc82 100644 --- a/src/util/addons.ts +++ b/src/util/addons.ts @@ -155,7 +155,7 @@ export function createNavigation(addons: BdWebAddon[], selectedIndex = 0, disabl } -export async function paginateAddonPages(interaction: ChatInputCommandInteraction<"cached">, addons: BdWebAddon[]) { +export async function paginateAddonPages(interaction: ChatInputCommandInteraction, addons: BdWebAddon[]) { const navigation = createNavigation(addons); const pages = addons.map(addon => createAddonComponent(addon)); @@ -163,7 +163,7 @@ export async function paginateAddonPages(interaction: ChatInputCommandInteractio const collector = msg.createMessageComponentCollector({time: 5 * msInMinute}); let selectedIndex = 0; - collector.on("collect", async (i: StringSelectMenuInteraction<"cached">) => { + collector.on("collect", async (i: StringSelectMenuInteraction) => { if (i.user.id !== interaction.user.id) return await i.reply({content: "You cannot interact with this menu.", flags: MessageFlags.Ephemeral}); const selectedAddonName = i.values[0]; From 3072d15616d8d9d68982f7352ad860b3b8763c2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:38:38 +0000 Subject: [PATCH 12/22] Migrate botadmin and cleanname; no legacy command modules remain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final batch. The loader now reports 10 migrated commands and 0 legacy. botadmin - `ownerOnly: true` replaces the hand-rolled first line of execute(), `if (interaction.user.id !== process.env.BOT_OWNER_ID) ...`. The dispatcher enforces it, and the same flag already routed the command to the private guild at deploy time. - The `async modal() {}` stub is gone. It existed only because the old CommandModule type made all five component handlers mandatory, and carried the comment "This is just here to satisfy the event requirement I imposed on myself". Nothing needs it: the modal is awaited inline, so the dispatcher correctly ignores the submission and lets awaitModal resolve it. - The modal itself is plain component data, and the show/await/read dance uses the framework's awaitModal, which returns null on timeout instead of making the caller distinguish it from a real failure inside a catch. - `getChannel("channel", true) as TextChannel` โ€” which carried an eslint-disable for an assertion the linter called unnecessary and the type checker required โ€” becomes `getChannel(...)`, the typed overload voicetext already used. No cast, no disable. cleanname The bypass-role picker is a registered component (`cleanname.bypass`) rather than a `role()` method routed by `customId.split("-")[0]`, so its handler receives a typed RoleSelectMenuInteraction<"cached"> earned by the dispatcher's guild check. Payload check across all ten commands, with the builder's empty `options: []` normalised away: eight are byte-identical to the pre-migration baseline. The two that differ are cleanname and voicetext, both `dm_permission: false` -> `contexts: [Guild]` โ€” the deprecated setDMPermission is now gone from the codebase entirely, and every command declares contexts the same way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/botadmin.ts | 219 ++++++++++++++++++++------------------ src/commands/cleanname.ts | 162 ++++++++++++++++------------ 2 files changed, 214 insertions(+), 167 deletions(-) diff --git a/src/commands/botadmin.ts b/src/commands/botadmin.ts index ebe1796..6670b3c 100644 --- a/src/commands/botadmin.ts +++ b/src/commands/botadmin.ts @@ -1,111 +1,128 @@ -import {ActionRowBuilder, ChannelType, ChatInputCommandInteraction, ModalBuilder, SlashCommandBuilder, TextChannel, TextInputBuilder, TextInputStyle, type PartialTextBasedChannelFields} from "discord.js"; -import * as notices from "../util/notices"; +import { + ApplicationCommandOptionType, ApplicationCommandType, ChannelType, ChatInputCommandInteraction, + ComponentType, TextInputStyle, + type ModalComponentData, type PartialTextBasedChannelFields +} from "discord.js"; +import {awaitModal, defineCommand} from "../framework"; import {globalDB} from "../db"; +import * as notices from "../util/notices"; - -export default { - owner: true, - data: new SlashCommandBuilder() - .setName("botadmin") - .setDescription("Global settings for the bot during runtime.") - .addSubcommandGroup(group => - group.setName("send").setDescription("Sends messages to different locations") - .addSubcommand(c => - c.setName("user").setDescription("Sends a DM to the specified user.") - .addUserOption(opt => - opt.setName("user").setDescription("User to DM.").setRequired(true) - ) - ) - .addSubcommand(c => - c.setName("channel").setDescription("Sends a message to the specified channel.") - .addChannelOption(opt => - opt.setName("channel").setDescription("Channel to send a message.").setRequired(true) - .addChannelTypes(ChannelType.GuildText) - ) - ) - ) - .addSubcommand( - c => c.setName("forwarding").setDescription("Sets up DM forwarding to a user.") - .addUserOption(opt => - opt.setName("user").setDescription("Who to forward DMs to?").setRequired(false) - ) - ) - .addSubcommand(c => c.setName("quit").setDescription("Exits the bot gracefully.")), - - - async execute(interaction: ChatInputCommandInteraction) { - if (interaction.user.id !== process.env.BOT_OWNER_ID) return await interaction.reply(notices.error("Sorry this command is only usable by the owner!", {ephemeral: true})); - - const group = interaction.options.getSubcommandGroup(); - const command = interaction.options.getSubcommand(); - if (group === "send") { - if (command === "channel") return await this.channel(interaction); - if (command === "user") return await this.user(interaction); +const sendModal: ModalComponentData = { + customId: "botadmin-send", + title: "Message To Send", + components: [{ + type: ComponentType.Label, + label: "Message", + component: { + type: ComponentType.TextInput, + customId: "message", + label: "Message", + style: TextInputStyle.Paragraph, + required: true, + maxLength: 2000, + value: "" } - if (command === "forwarding") return await this.forwarding(interaction); - if (command === "quit") return await this.quit(interaction); - }, - - - async channel(interaction: ChatInputCommandInteraction) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return await this.send(interaction, interaction.options.getChannel("channel", true) as TextChannel); - }, - - - async user(interaction: ChatInputCommandInteraction) { - return await this.send(interaction, interaction.options.getUser("user", true)); - }, - - - async send(interaction: ChatInputCommandInteraction, target: PartialTextBasedChannelFields) { - const modal = new ModalBuilder().setTitle("Message To Send").setCustomId("botadmin-send") - .addComponents( - new ActionRowBuilder() - .addComponents( - new TextInputBuilder().setCustomId("message").setLabel("Message") - .setStyle(TextInputStyle.Paragraph).setRequired(true) - .setMaxLength(2000).setValue("") - ) - ); - + }] +}; - await interaction.showModal(modal); - try { - const modalInteraction = await interaction.awaitModalSubmit({time: 60_000}); - const message = modalInteraction.fields.getTextInputValue("message"); - try { - await target.send(message); - await modalInteraction.reply(notices.success("Message sent successfully!", {ephemeral: true})); - } - catch { - await modalInteraction.reply(notices.error("Could not send message!", {ephemeral: true})); +async function send(interaction: ChatInputCommandInteraction, target: PartialTextBasedChannelFields) { + const submission = await awaitModal(interaction, sendModal, ["message"], {time: 60_000}); + if (!submission) return await interaction.followUp(notices.error("Modal submission timed out!", {ephemeral: true})); + + try { + await target.send(submission.values.message); + await submission.submission.reply(notices.success("Message sent successfully!", {ephemeral: true})); + } + catch { + await submission.submission.reply(notices.error("Could not send message!", {ephemeral: true})); + } +} + + +async function forwarding(interaction: ChatInputCommandInteraction) { + const targetUser = interaction.options.getUser("user"); + if (targetUser) await globalDB.set("forwarding", targetUser.id); + else await globalDB.delete("forwarding"); + await interaction.reply(notices.success(targetUser ? `Now forwarding DMs to <@${targetUser.id}>!` : "No longer forwarding DMs!", {ephemeral: true})); +} + + +async function quit(interaction: ChatInputCommandInteraction) { + await interaction.reply(notices.info("Bot shutting down...", {ephemeral: true})); + await interaction.client.destroy(); + process.exit(0); +} + + +export const command = defineCommand({ + ownerOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "botadmin", + description: "Global settings for the bot during runtime.", + options: [ + { + type: ApplicationCommandOptionType.SubcommandGroup, + name: "send", + description: "Sends messages to different locations", + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: "user", + description: "Sends a DM to the specified user.", + options: [{ + type: ApplicationCommandOptionType.User, + name: "user", + description: "User to DM.", + required: true + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "channel", + description: "Sends a message to the specified channel.", + options: [{ + type: ApplicationCommandOptionType.Channel, + name: "channel", + description: "Channel to send a message.", + required: true, + channel_types: [ChannelType.GuildText] + }] + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "forwarding", + description: "Sets up DM forwarding to a user.", + options: [{ + type: ApplicationCommandOptionType.User, + name: "user", + description: "Who to forward DMs to?", + required: false + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "quit", + description: "Exits the bot gracefully." } - } - catch { - await interaction.followUp(notices.error("Modal submission timed out!", {ephemeral: true})); - } - }, - - /** - * This is just here to satisfy the event requirement I imposed on myself - */ - async modal() {}, - - - async forwarding(interaction: ChatInputCommandInteraction) { - const targetUser = interaction.options.getUser("user"); - if (targetUser) await globalDB.set("forwarding", targetUser.id); - else await globalDB.delete("forwarding"); - await interaction.reply(notices.success(targetUser ? `Now forwarding DMs to <@${targetUser.id}>!` : "No longer forwarding DMs!", {ephemeral: true})); + ] }, + // The owner check is the dispatcher's job now; `ownerOnly` above also keeps + // this command deployed to the private guild rather than globally. + async execute(interaction) { + const group = interaction.options.getSubcommandGroup(); + const subcommand = interaction.options.getSubcommand(); - async quit(interaction: ChatInputCommandInteraction) { - await interaction.reply(notices.info("Bot shutting down...", {ephemeral: true})); - await interaction.client.destroy(); - process.exit(0); - }, -}; + if (group === "send") { + if (subcommand === "channel") return await send(interaction, interaction.options.getChannel("channel", true)); + if (subcommand === "user") return await send(interaction, interaction.options.getUser("user", true)); + } + if (subcommand === "forwarding") return await forwarding(interaction); + if (subcommand === "quit") return await quit(interaction); + } +}); diff --git a/src/commands/cleanname.ts b/src/commands/cleanname.ts index 8821c58..84b60f8 100644 --- a/src/commands/cleanname.ts +++ b/src/commands/cleanname.ts @@ -1,5 +1,5 @@ -import {ChatInputCommandInteraction, ComponentType, MessageFlags, PermissionFlagsBits, RoleSelectMenuInteraction, SelectMenuDefaultValueType, SlashCommandBuilder} from "discord.js"; -import {container, row, text, type ComponentMessage} from "../framework"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, ComponentType, InteractionContextType, MessageFlags, PermissionFlagsBits, SelectMenuDefaultValueType} from "discord.js"; +import {container, defineCommand, defineComponent, row, text, type ComponentMessage} from "../framework"; import {humanReadableUptime} from "../util/time"; import {Accents} from "../util/colors"; import * as notices from "../util/notices"; @@ -34,48 +34,55 @@ function progress(state: CleanProgress): ComponentMessage { } -export default { - data: new SlashCommandBuilder() - .setName("cleanname") - .setDescription("Cleans member display names to match Discord's username standards.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand( - c => c.setName("join").setDescription("Toggles automatically cleaning new members when they join.") - .addBooleanOption((/** @type {import("@discordjs/builders").SlashCommandBooleanOption} */ option) => - option.setName("enabled") - .setDescription("Whether members should have their display name cleaned upon joining.") - .setRequired(true))) - .addSubcommand( - c => c.setName("user").setDescription("Fixes a display name for a single user.") - .addUserOption((/** @type {import("@discordjs/builders").SlashCommandUserOption} */ option) => - option.setName("user") - .setDescription("Whose display name should be cleaned?") - .setRequired(true))) - .addSubcommand(c => c.setName("server").setDescription("Fixes all display names in the server.")), - - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "server") return await this.server(interaction); - if (command === "user") return await this.user(interaction); - if (command === "join") return await this.join(interaction); - }, +async function server(interaction: ChatInputCommandInteraction<"cached">) { + const controls = row({ + type: ComponentType.RoleSelect, + customId: chooseBypassRoles.customId({}), + minValues: 0, + maxValues: 25, + defaultValues: [{id: interaction.guild.roles.highest.id, type: SelectMenuDefaultValueType.Role}] + }); + await interaction.reply(notices.info("Please select which roles should bypass this cleaning.", {components: [controls]})); +} - async server(interaction: ChatInputCommandInteraction<"cached">) { - const controls = row({ - type: ComponentType.RoleSelect, - customId: "cleanname", - minValues: 0, - maxValues: 25, - defaultValues: [{id: interaction.guild.roles.highest.id, type: SelectMenuDefaultValueType.Role}] - }); - await interaction.reply(notices.info("Please select which roles should bypass this cleaning.", {components: [controls]})); - }, - async role(interaction: RoleSelectMenuInteraction<"cached">) { +async function user(interaction: ChatInputCommandInteraction<"cached">) { + const targetUser = interaction.options.getUser("user", true); + const member = interaction.guild.members.cache.get(targetUser.id); + if (!member) return await interaction.reply(notices.error("This user is not in the server.", {ephemeral: true})); + const isClean = !hasDisallowedChars(member.displayName); + if (isClean) return await interaction.reply(notices.info("This member's display name already conforms to the username standards.")); + try { + await member.setNickname(member.user.username); + await interaction.reply(notices.success("Successfully cleaned this member's display name.")); + } + catch { + await interaction.reply(notices.error("Could not clean this member's display name. Double check that I have permission to do so.")); + } +} + + +async function join(interaction: ChatInputCommandInteraction<"cached">) { + const toEnable = !!interaction.options.getBoolean("enabled"); + const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; + const current = guildSettings.cleanOnJoin; + if (current === toEnable) return await interaction.reply(notices.info(`This setting was already ${current ? "enabled" : "disabled"}.`)); + guildSettings.cleanOnJoin = toEnable; + await guildDB.set(interaction.guild.id, guildSettings); + await interaction.reply(notices.success(`This setting is now ${toEnable ? "enabled" : "disabled"}.`)); +} + + +/** The bypass-role picker shown by `/cleanname server`. */ +const chooseBypassRoles = defineComponent({ + id: "cleanname.bypass", + kind: "roleSelect", + guildOnly: true, + params: {}, + + async run(interaction) { const roleIds = [...interaction.roles.keys()]; const start = Date.now(); @@ -123,32 +130,55 @@ export default { stamp: {label: "Completed", at: finish}, done: true })); + } +}); + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "cleanname", + description: "Cleans member display names to match Discord's username standards.", + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + contexts: [InteractionContextType.Guild], + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: "join", + description: "Toggles automatically cleaning new members when they join.", + options: [{ + type: ApplicationCommandOptionType.Boolean, + name: "enabled", + description: "Whether members should have their display name cleaned upon joining.", + required: true + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "user", + description: "Fixes a display name for a single user.", + options: [{ + type: ApplicationCommandOptionType.User, + name: "user", + description: "Whose display name should be cleaned?", + required: true + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "server", + description: "Fixes all display names in the server." + } + ] }, + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "server") return await server(interaction); + if (subcommand === "user") return await user(interaction); + if (subcommand === "join") return await join(interaction); + } +}); - async user(interaction: ChatInputCommandInteraction<"cached">) { - const targetUser = interaction.options.getUser("user", true); - const member = interaction.guild.members.cache.get(targetUser.id); - if (!member) return await interaction.reply(notices.error("This user is not in the server.", {ephemeral: true})); - const isClean = !hasDisallowedChars(member.displayName); - if (isClean) return await interaction.reply(notices.info("This member's display name already conforms to the username standards.")); - try { - await member.setNickname(member.user.username); - await interaction.reply(notices.success("Successfully cleaned this member's display name.")); - } - catch { - await interaction.reply(notices.error("Could not clean this member's display name. Double check that I have permission to do so.")); - } - }, - - - async join(interaction: ChatInputCommandInteraction<"cached">) { - const toEnable = !!interaction.options.getBoolean("enabled"); - const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; - const current = guildSettings.cleanOnJoin; - if (current === toEnable) return await interaction.reply(notices.info(`This setting was already ${current ? "enabled" : "disabled"}.`)); - guildSettings.cleanOnJoin = toEnable; - await guildDB.set(interaction.guild.id, guildSettings); - await interaction.reply(notices.success(`This setting is now ${toEnable ? "enabled" : "disabled"}.`)); - }, -}; +export const components = [chooseBypassRoles]; From 82f1ec315d6372f853ae72d56e2963a061ae8966 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:40:37 +0000 Subject: [PATCH 13/22] Remove the dispatcher's legacy command path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing uses it since the previous commit, so the transitional half of the framework goes away. Deleted from dispatch.ts: LegacyKind, LegacyEntry, LegacyHandler, the `legacy` map, addLegacyCommand(), runLegacyComponent(), and the legacy branches inside runCommand() and runAutocomplete(). With them goes the last of the stringly-typed routing โ€” `this.legacy.get(customId.split("-")[0])`, followed by an if-chain mapping interaction kinds to method names. Deleted from loader.ts: the default-export branch, the builder-vs-plain-data normalisation in commandData(), and LEGACY_KINDS. A command file must now export `command`; anything else throws at startup naming the file, rather than being silently half-loaded. `Dispatcher.counts` loses its `legacy` key, and the startup log and deploy script lose their migrated/legacy annotations. framework/README.md's migration guide becomes a short "writing a command" section, since there is nothing left to migrate from. Verified: the dispatcher still routes commands, enforces guildOnly, decodes component params, ignores session ids and unknown namespaces, and reports stale ids โ€” nine checks against stub interactions. Two of those specifically confirm the legacy behaviour is gone: an unknown namespace no longer falls through to a split("-") lookup, and a `cleanname-whatever` custom id no longer routes anywhere. Command payloads are byte-identical to the previous commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- scripts/deploy-commands.ts | 2 +- src/framework/dispatch.ts | 67 ++++++-------------------------------- src/framework/loader.ts | 61 ++++++++-------------------------- src/index.ts | 4 +-- 4 files changed, 27 insertions(+), 107 deletions(-) diff --git a/scripts/deploy-commands.ts b/scripts/deploy-commands.ts index 1bd4331..f3ce0ad 100644 --- a/scripts/deploy-commands.ts +++ b/scripts/deploy-commands.ts @@ -60,7 +60,7 @@ if (!shouldClear) { } else { commands.push(command.data); - console.log(`๐ŸŒ Global command: ${command.name}${command.migrated ? "" : " (legacy module)"}`); + console.log(`๐ŸŒ Global command: ${command.name}`); if (command.data.integration_types?.includes(1)) console.log(` ๐Ÿ“ฑ User-installable`); } } diff --git a/src/framework/dispatch.ts b/src/framework/dispatch.ts index d77f349..2353573 100644 --- a/src/framework/dispatch.ts +++ b/src/framework/dispatch.ts @@ -5,8 +5,6 @@ * runtime check that justifies it. That is the point: not zero unsafety, but * unsafety that is located, guarded and auditable. * - * The `legacy` half supports command modules that have not been migrated to - * `defineCommand` yet, and should be deleted once they all have. */ import { @@ -17,8 +15,6 @@ import type {Command, Component, ComponentKind} from "./registry"; import {isSessionId} from "./session"; -type LegacyHandler = (interaction: never) => Promise; - const KIND_GUARD: {[K in ComponentKind]: (interaction: Interaction) => boolean} = { button: interaction => interaction.isButton(), stringSelect: interaction => interaction.isStringSelectMenu(), @@ -30,17 +26,6 @@ const KIND_GUARD: {[K in ComponentKind]: (interaction: Interaction) => boolean} }; -/** @deprecated Shape of a not-yet-migrated command module. */ -export type LegacyKind = "execute" | "autocomplete" | "button" | "modal" | "select" | "role"; - -/** @deprecated Remove once every command uses `defineCommand`. */ -export interface LegacyEntry { - name: string; - ownerOnly: boolean; - handlers: Partial>; -} - - export interface DispatcherOptions { ownerId: string; /** Called before a chat-input command runs. Used for command stats. */ @@ -51,7 +36,6 @@ export interface DispatcherOptions { export class Dispatcher { private commands = new Map(); private components = new Map(); - private legacy = new Map(); private options: DispatcherOptions; constructor(options: DispatcherOptions) { @@ -60,7 +44,7 @@ export class Dispatcher { addCommand(command: Command): void { const name = command.data.name; - if (this.commands.has(name) || this.legacy.has(name)) throw new Error(`duplicate command "${name}"`); + if (this.commands.has(name)) throw new Error(`duplicate command "${name}"`); this.commands.set(name, command); } @@ -69,14 +53,8 @@ export class Dispatcher { this.components.set(component.id, component); } - /** @deprecated */ - addLegacyCommand(entry: LegacyEntry): void { - if (this.commands.has(entry.name) || this.legacy.has(entry.name)) throw new Error(`duplicate command "${entry.name}"`); - this.legacy.set(entry.name, entry); - } - - get counts(): {commands: number; legacy: number; components: number;} { - return {commands: this.commands.size, legacy: this.legacy.size, components: this.components.size}; + get counts(): {commands: number; components: number;} { + return {commands: this.commands.size, components: this.components.size}; } @@ -94,33 +72,23 @@ export class Dispatcher { private async runCommand(interaction: ChatInputCommandInteraction): Promise { const command = this.commands.get(interaction.commandName); - const legacy = this.legacy.get(interaction.commandName); - if (!command && !legacy) { + if (!command) { console.error("unregistered command", interaction.commandName); return await this.reply(interaction, "That command isn't registered any more."); } await this.options.onCommandRun?.(interaction); - - if (legacy) { - if (legacy.ownerOnly && interaction.user.id !== this.options.ownerId) return await this.reply(interaction, "That command is owner-only."); - return void await legacy.handlers.execute?.(interaction as never); - } - - if (!this.permitted(command!, interaction)) return await this.reply(interaction, "You can't use that command here."); + if (!this.permitted(command, interaction)) return await this.reply(interaction, "You can't use that command here."); // Guarded above: `guildOnly` was checked, so the `<"cached">` the handler // declares is actually true by this point. - await command!.execute(interaction); + await command.execute(interaction); } private async runAutocomplete(interaction: Interaction): Promise { if (!interaction.isAutocomplete()) return; - const legacy = this.legacy.get(interaction.commandName); - if (legacy) return void await legacy.handlers.autocomplete?.(interaction as never); - const command = this.commands.get(interaction.commandName); if (!command?.autocomplete) return await interaction.respond([]); if (command.guildOnly && !interaction.inCachedGuild()) return await interaction.respond([]); @@ -136,8 +104,11 @@ export class Dispatcher { // lets registered components and sessions share one custom-id space. if (isSessionId(interaction.customId)) return; + // Unknown namespace: it belongs to a live session, whose own collector + // handles it. This silence is the contract that lets registered + // components and sessions share one custom-id space. const component = this.components.get(namespaceOf(interaction.customId)); - if (!component) return await this.runLegacyComponent(interaction); + if (!component) return; if (!KIND_GUARD[component.kind](interaction)) { console.warn(`component "${component.id}" is registered as ${component.kind} but received a ${interaction.isModalSubmit() ? "modal submit" : "component"} interaction`); @@ -162,24 +133,6 @@ export class Dispatcher { } - /** @deprecated Routing by `customId.split("-")[0]`, kept for unmigrated commands. */ - private async runLegacyComponent(interaction: Interaction): Promise { - if (!interaction.isMessageComponent() && !interaction.isModalSubmit()) return; - - const entry = this.legacy.get(interaction.customId.split("-")[0]); - if (!entry) return; - - let kind: LegacyKind | undefined; - if (interaction.isButton()) kind = "button"; - else if (interaction.isModalSubmit()) kind = "modal"; - else if (interaction.isStringSelectMenu()) kind = "select"; - else if (interaction.isRoleSelectMenu()) kind = "role"; - if (!kind) return; - - if (entry.ownerOnly && interaction.user.id !== this.options.ownerId) return; - await entry.handlers[kind]?.(interaction as never); - } - private permitted(definition: {guildOnly?: boolean; ownerOnly?: boolean;}, interaction: Interaction): boolean { if (definition.guildOnly && !interaction.inCachedGuild()) return false; diff --git a/src/framework/loader.ts b/src/framework/loader.ts index ccf81de..5258565 100644 --- a/src/framework/loader.ts +++ b/src/framework/loader.ts @@ -12,22 +12,18 @@ import fs from "node:fs"; import path from "node:path"; import {pathToFileURL} from "node:url"; import type {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; -import type {Dispatcher, LegacyEntry, LegacyKind} from "./dispatch"; +import type {Dispatcher} from "./dispatch"; import type {Command, Component, EventDef} from "./registry"; -const LEGACY_KINDS: LegacyKind[] = ["execute", "autocomplete", "button", "modal", "select", "role"]; - const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; const isFn = (value: unknown): value is (...args: never[]) => Promise => typeof value === "function"; export interface LoadedCommand { name: string; - /** Ready to send to the API, whichever style the module was written in. */ data: RESTPostAPIChatInputApplicationCommandsJSONBody; ownerOnly: boolean; - migrated: boolean; register(dispatcher: Dispatcher): void; } @@ -45,18 +41,11 @@ async function importModule(file: string): Promise> { } -/** Reads `data` off either style, normalising a builder to plain JSON. */ function commandData(source: unknown, file: string): RESTPostAPIChatInputApplicationCommandsJSONBody { - if (!isRecord(source)) throw new Error(`${path.basename(file)}: command has no data`); - - const data: unknown = "toJSON" in source && typeof source.toJSON === "function" - ? (source as {toJSON(): unknown;}).toJSON() - : source; - - if (!isRecord(data) || typeof data.name !== "string") { + if (!isRecord(source) || typeof source.name !== "string") { throw new Error(`${path.basename(file)}: command data has no name`); } - return data as unknown as RESTPostAPIChatInputApplicationCommandsJSONBody; + return source as unknown as RESTPostAPIChatInputApplicationCommandsJSONBody; } @@ -66,46 +55,24 @@ export async function loadCommands(directory: string): Promise for (const file of sourceFiles(directory)) { const module = await importModule(file); - // Migrated: `export const command = defineCommand(...)`, plus an optional - // `export const components = [...]`. - if (isRecord(module.command)) { - const command = module.command as unknown as Command; - if (typeof command.execute !== "function") throw new Error(`${path.basename(file)}: exported command has no execute()`); - - const components = Array.isArray(module.components) ? module.components as Component[] : []; - const data = commandData(command.data, file); - - loaded.push({ - name: data.name, - data, - ownerOnly: command.ownerOnly === true, - migrated: true, - register(dispatcher) { - dispatcher.addCommand(command); - for (const component of components) dispatcher.addComponent(component); - } - }); - continue; + if (!isRecord(module.command)) { + throw new Error(`${path.basename(file)}: no exported command (expected \`export const command = defineCommand({...})\`)`); } - // Not yet migrated: `export default {data, execute, button, ...}`. - const legacyModule = isRecord(module.default) ? module.default : module; - if (!isFn(legacyModule.execute)) throw new Error(`${path.basename(file)}: no exported command (expected \`export const command\` or a default export with execute())`); + const command = module.command as unknown as Command; + if (typeof command.execute !== "function") throw new Error(`${path.basename(file)}: exported command has no execute()`); - const data = commandData(legacyModule.data, file); - const handlers: LegacyEntry["handlers"] = {}; - for (const kind of LEGACY_KINDS) { - const handler = legacyModule[kind]; - if (isFn(handler)) handlers[kind] = handler.bind(legacyModule); - } + const components = Array.isArray(module.components) ? module.components as Component[] : []; + const data = commandData(command.data, file); - const entry: LegacyEntry = {name: data.name, ownerOnly: legacyModule.owner === true, handlers}; loaded.push({ name: data.name, data, - ownerOnly: entry.ownerOnly, - migrated: false, - register(dispatcher) {dispatcher.addLegacyCommand(entry);} + ownerOnly: command.ownerOnly === true, + register(dispatcher) { + dispatcher.addCommand(command); + for (const component of components) dispatcher.addComponent(component); + } }); } diff --git a/src/index.ts b/src/index.ts index 9c9e0df..a6aa271 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,8 +34,8 @@ const dispatcher = new Dispatcher({ const commands = await loadCommands(path.join(here, "commands")); for (const command of commands) command.register(dispatcher); -const {commands: migrated, legacy, components} = dispatcher.counts; -console.log(`Loaded ${migrated + legacy} commands (${migrated} migrated, ${legacy} legacy) and ${components} components.`); +const {commands: commandCount, components: componentCount} = dispatcher.counts; +console.log(`Loaded ${commandCount} commands and ${componentCount} components.`); client.dispatcher = dispatcher; From b049b112c0f777a8e3c1d714fff5748e2fe0b1fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:41:59 +0000 Subject: [PATCH 14/22] Move hardcoded snowflakes into src/config.ts The BetterDiscord guild id appeared four times across three files, the developer role ids five times across two, and the account-issues channel and AutoMod rule ids were inline constants in the files that used them. Changing any of them meant grepping for a number. src/config.ts collects all seven, each overridable by an environment variable so the bot can be pointed at a test server without editing source: BD_GUILD_ID, BD_ROLE_PLUGIN_DEV, BD_ROLE_THEME_DEV, COMMUNITY_ROLE_PLUGIN_DEV, COMMUNITY_ROLE_THEME_DEV, BD_CHANNEL_ACCOUNT_ISSUES, BD_AUTOMOD_SPAM_LINK_RULE Defaults are the values that were already inline, so behaviour is unchanged with no environment set. Verified: all seven defaults match the literals they replaced, and setting BD_GUILD_ID overrides that one while leaving the rest alone. src/util/web.ts keeps its release-channel ids. They are BetterDiscord website data copied from the client repository rather than deployment configuration, and the file documents its upstream source. No inline snowflakes remain in src/ outside config.ts and web.ts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/developer.ts | 13 ++++++------ src/commands/spam.ts | 3 ++- src/config.ts | 39 ++++++++++++++++++++++++++++++++++ src/events/detectcryptoscam.ts | 7 +++--- src/events/developer.ts | 9 ++++---- 5 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 src/config.ts diff --git a/src/commands/developer.ts b/src/commands/developer.ts index 229f021..3f48e87 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -1,5 +1,6 @@ import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, InteractionContextType, type GuildTextBasedChannel} from "discord.js"; import {defineCommand} from "../framework"; +import config from "../config"; import {guildDB} from "../db"; import * as notices from "../util/notices"; @@ -35,8 +36,8 @@ async function add(interaction: ChatInputCommandInteraction<"cached">) { const targetUser = interaction.options.getUser("user", true); const roleName = interaction.options.getString("role", true); - const bdRoleId = roleName.toLowerCase().includes("plugin") ? "125166040689803264" : "165005972970930176"; - const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); + const bdRoleId = roleName.toLowerCase().includes("plugin") ? config.roles.pluginDeveloper : config.roles.themeDeveloper; + const bdGuild = await interaction.client.guilds.fetch(config.guilds.betterDiscord); try { const member = await bdGuild.members.fetch(targetUser); try { @@ -89,12 +90,12 @@ async function add(interaction: ChatInputCommandInteraction<"cached">) { async function sync(interaction: ChatInputCommandInteraction<"cached">) { const targetUser = interaction.options.getUser("user", true); - const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); + const bdGuild = await interaction.client.guilds.fetch(config.guilds.betterDiscord); const bdMember = await bdGuild.members.fetch(targetUser); if (!bdMember) return await interaction.reply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); - const isPluginDev = bdMember.roles.cache.has("125166040689803264"); - const isThemeDev = bdMember.roles.cache.has("165005972970930176"); - const rolesToAdd = [isPluginDev ? "948627723830591568" : "", isThemeDev ? "948627648706392104" : ""].filter(r => r); + const isPluginDev = bdMember.roles.cache.has(config.roles.pluginDeveloper); + const isThemeDev = bdMember.roles.cache.has(config.roles.themeDeveloper); + const rolesToAdd = [isPluginDev ? config.roles.communityPluginDeveloper : "", isThemeDev ? config.roles.communityThemeDeveloper : ""].filter(r => r); const communityMember = await interaction.guild.members.fetch(targetUser); diff --git a/src/commands/spam.ts b/src/commands/spam.ts index d066e42..47cb392 100644 --- a/src/commands/spam.ts +++ b/src/commands/spam.ts @@ -1,11 +1,12 @@ import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits} from "discord.js"; import {defineCommand} from "../framework"; +import config from "../config"; import * as notices from "../util/notices"; // TODO: move detectspam from moderation to here async function addLink(interaction: ChatInputCommandInteraction<"cached">) { - const rule = await interaction.guild.autoModerationRules.fetch("1256935881168781332"); + const rule = await interaction.guild.autoModerationRules.fetch(config.automod.spamLinkRule); if (!rule) return await interaction.reply(notices.error("Spam link filter rule not found! Report this to Zerebos!", {ephemeral: true})); const existing = rule.triggerMetadata?.keywordFilter ?? []; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6a66bb9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,39 @@ +/** + * Every Discord snowflake and community-specific constant the bot depends on. + * + * These were inline literals scattered across six files โ€” the BetterDiscord + * guild id appeared four times, the developer role ids five. Each entry may be + * overridden by an environment variable so the bot can be pointed at a test + * server without editing source. + */ + +const id = (key: string, fallback: string): string => process.env[key] || fallback; + +export const config = { + guilds: { + /** The main BetterDiscord server. */ + betterDiscord: id("BD_GUILD_ID", "86004744966914048") + }, + + roles: { + /** Roles in the main server that mark someone as a verified developer. */ + pluginDeveloper: id("BD_ROLE_PLUGIN_DEV", "125166040689803264"), + themeDeveloper: id("BD_ROLE_THEME_DEV", "165005972970930176"), + + /** The equivalents in the developer community server, kept in sync. */ + communityPluginDeveloper: id("COMMUNITY_ROLE_PLUGIN_DEV", "948627723830591568"), + communityThemeDeveloper: id("COMMUNITY_ROLE_THEME_DEV", "948627648706392104") + }, + + channels: { + /** Where compromised-account warnings are posted. */ + accountIssues: id("BD_CHANNEL_ACCOUNT_ISSUES", "1465301762821853204") + }, + + automod: { + /** The AutoMod rule whose keyword list `/spam link` appends to. */ + spamLinkRule: id("BD_AUTOMOD_SPAM_LINK_RULE", "1256935881168781332") + } +} as const; + +export default config; diff --git a/src/events/detectcryptoscam.ts b/src/events/detectcryptoscam.ts index daf87d9..d455f4f 100644 --- a/src/events/detectcryptoscam.ts +++ b/src/events/detectcryptoscam.ts @@ -1,11 +1,10 @@ import {Events, Message, PermissionFlagsBits} from "discord.js"; +import config from "../config"; import {guildDB} from "../db"; import {sendModLog} from "../util/modlog"; const TIMEOUT_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds -const TARGET_GUILD_ID = "86004744966914048"; -const ACCOUNT_ISSUES_CHANNEL_ID = "1465301762821853204"; const sketchyImageRegex = /https:\/\/(?:cdn|media)\.(?:discord|discordapp)\.(?:com|net)\/attachments\/\d+\/\d+\/(?:[1234]|image)\.(?:jpg|png|webp)(?:\?.*?)?(?:\s+|$)/; // TODO: consider de-duping with invitefilter event @@ -16,7 +15,7 @@ export default { // Ignore DM messages and owner messages and people with manage messages perms if (!message.inGuild() || message.author.id === process.env.BOT_OWNER_ID) return; if (message.author.id === message.client.user.id) return; - if (message.guild.id !== TARGET_GUILD_ID) return; + if (message.guild.id !== config.guilds.betterDiscord) return; if (message.channel.permissionsFor(message.author)?.has(PermissionFlagsBits.ManageMessages)) return; // Obviously if this is disabled we don't need to do this stuff either @@ -48,7 +47,7 @@ export default { console.error("Could not timeout member. Likely permissions."); } - const accountIssuesChannel = message.guild.channels.cache.get(ACCOUNT_ISSUES_CHANNEL_ID); + const accountIssuesChannel = message.guild.channels.cache.get(config.channels.accountIssues); if (accountIssuesChannel && accountIssuesChannel.isTextBased()) { await accountIssuesChannel.send({content: `${message.author.toString()} (${message.author.id}) your account may be compromised! Change your password and remove any unfamiliar account connections and authorized apps.`}); } diff --git a/src/events/developer.ts b/src/events/developer.ts index 6579da4..ddf8700 100644 --- a/src/events/developer.ts +++ b/src/events/developer.ts @@ -1,17 +1,18 @@ import {Events, GuildMember} from "discord.js"; +import config from "../config"; export default { name: Events.GuildMemberAdd, async execute(member: GuildMember) { - const bdGuild = await member.client.guilds.fetch("86004744966914048"); + const bdGuild = await member.client.guilds.fetch(config.guilds.betterDiscord); const bdMember = await bdGuild.members.fetch(member); if (!bdMember) return; - const isPluginDev = bdMember.roles.cache.has("125166040689803264"); - const isThemeDev = bdMember.roles.cache.has("165005972970930176"); - const rolesToAdd = [isPluginDev ? "948627723830591568" : "", isThemeDev ? "948627648706392104" : ""].filter(r => r); + const isPluginDev = bdMember.roles.cache.has(config.roles.pluginDeveloper); + const isThemeDev = bdMember.roles.cache.has(config.roles.themeDeveloper); + const rolesToAdd = [isPluginDev ? config.roles.communityPluginDeveloper : "", isThemeDev ? config.roles.communityThemeDeveloper : ""].filter(r => r); try { await member.roles.add(rolesToAdd, "Syncing roles from main server"); From 63a0d17994e2a1d2fe27845f5b8f95b87970b6b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:43:46 +0000 Subject: [PATCH 15/22] Only deploy commands when they have actually changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container's CMD is `validate && deploy && start`, so every restart bulk overwrote every global command โ€” needless API traffic, and a rate-limit risk during a crash loop. Global command propagation is also not instant, so redeploying identical payloads on every boot bought nothing. deploy-commands.ts now fingerprints what it is about to send (the global payload, the owner-guild payload, the client id and the guild id) and stores the hash in the existing Keyv/SQLite store. If the fingerprint matches the last successful deploy it skips entirely. `--force` / `-f` overrides, and `--clear` drops the stored fingerprint so the next deploy runs. setCommands() now reports whether the calls succeeded, and the fingerprint is recorded only if they did. It previously caught and logged errors while returning normally, so without that the first failed deploy would have been remembered as successful and never retried. Verified end to end against a bogus token: 1. failed deploy -> nothing recorded, "the next run will retry" 2. run again -> retries, does not skip 3. fingerprint seeded as a success -> skips, zero API calls 4. --force -> deploys anyway 5. BOT_GUILD_ID changed -> fingerprint differs, deploys 6. a command description edited -> fingerprint differs, deploys 7. edit reverted -> skips again Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- Dockerfile | 3 ++- scripts/deploy-commands.ts | 39 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3f36d09..16ab583 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,5 +20,6 @@ COPY --link . /app # Setup some default files RUN touch settings.sqlite3 -# Refresh commands when starting the bot +# Validate config, then deploy commands only if they changed since the last +# start (see scripts/deploy-commands.ts), then run the bot CMD ["sh", "-c", "bun run validate && bun run deploy && bun run start"] \ No newline at end of file diff --git a/scripts/deploy-commands.ts b/scripts/deploy-commands.ts index f3ce0ad..cf9098f 100644 --- a/scripts/deploy-commands.ts +++ b/scripts/deploy-commands.ts @@ -1,13 +1,28 @@ +import {createHash} from "node:crypto"; import path from "node:path"; import {fileURLToPath} from "node:url"; import {REST, type RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; import {API} from "@discordjs/core"; import {loadCommands} from "../src/framework"; +import {globalDB} from "../src/db"; import "dotenv/config"; // Check CLI arguments for clear flag const shouldClear = process.argv.includes("--clear") || process.argv.includes("-c"); +const shouldForce = process.argv.includes("--force") || process.argv.includes("-f"); + +/** + * The container runs this on every start, so an unguarded deploy meant a bulk + * overwrite of every global command on every restart โ€” needless API traffic, + * and a rate-limit risk during a crash loop. The fingerprint covers what is + * sent and where, so a redeploy happens exactly when one of those changes. + */ +const FINGERPRINT_KEY = "deployedCommandsFingerprint"; + +const fingerprintOf = (global: unknown, guild: unknown) => createHash("sha256") + .update(JSON.stringify({global, guild, clientId: process.env.BOT_CLIENT_ID, guildId: process.env.BOT_GUILD_ID})) + .digest("hex"); // Setup file paths const __filename = fileURLToPath(import.meta.url); @@ -17,7 +32,10 @@ const __dirname = path.dirname(__filename); const rest = new REST().setToken(process.env.BOT_TOKEN!); const api = new API(rest); -async function setCommands(globalCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[], guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[]) { +/** Returns whether every part that was attempted succeeded. */ +async function setCommands(globalCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[], guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[]): Promise { + let ok = true; + // Deploy global commands try { console.log(`\n๐Ÿš€ Started ${shouldClear ? "clearing" : "registering"} global application commands...`); @@ -26,6 +44,7 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman } catch (error) { console.error(`โŒ Failed to ${shouldClear ? "clear" : "register"} global commands:`, error); + ok = false; } // Deploy guild commands (owner commands) @@ -37,6 +56,7 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman } catch (error) { console.error(`โŒ Failed to ${shouldClear ? "clear" : "register"} guild commands:`, error); + ok = false; } } else if (!process.env.BOT_GUILD_ID) { @@ -44,6 +64,7 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman } console.log(`\n๐ŸŽ‰ Command ${shouldClear ? "clearing" : "deployment"} complete!`); + return ok; } if (!shouldClear) { @@ -66,9 +87,23 @@ if (!shouldClear) { } console.log(`๐Ÿ“ Loaded ${commands.length} global commands and ${ownerCommands.length} owner commands`); - await setCommands(commands, ownerCommands); + + const fingerprint = fingerprintOf(commands, ownerCommands); + const deployed = await globalDB.get(FINGERPRINT_KEY); + + if (deployed === fingerprint && !shouldForce) { + console.log("\nโญ๏ธ Commands are unchanged since the last deploy - skipping. Use --force to deploy anyway."); + } + else { + // Only remember the fingerprint if everything actually landed, so a + // failed deploy retries on the next start instead of being skipped. + const ok = await setCommands(commands, ownerCommands); + if (ok) await globalDB.set(FINGERPRINT_KEY, fingerprint); + else console.log("โš ๏ธ Not recording the fingerprint; the next run will retry."); + } } else { console.log("๐Ÿ—‘๏ธ Clearing all commands..."); await setCommands([], []); + await globalDB.delete(FINGERPRINT_KEY); } From 23c9852dd77a29e11d19edb6da0871cc470bea14 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:13:27 +0000 Subject: [PATCH 16/22] Fix CI: stale override in bun.lock, and pin the Bun version CI was failing on `bun install --frozen-lockfile`: error: lockfile had changes, but lockfile is frozen note: overrides in package.json changed since bun.lock was saved My fault, from the djsx removal. That commit dropped `"overrides": {"react": "./djsx/index.ts"}` from package.json but bun.lock still recorded it. Bun 1.3.11, which I had locally, accepted the mismatch under --frozen-lockfile, so the clean-install check I ran passed. Bun 1.4.0, which CI resolved from `bun-version: latest`, rejects it. Regenerated bun.lock so its overrides block matches package.json. Reproduced the exact failure on 1.4.0 first, then confirmed a clean `bun install --frozen-lockfile` succeeds. Also pinned the workflow to bun-version "1.4.0" instead of `latest`. The lockfile format is version sensitive, and a Bun release landing upstream should not be able to break CI on an unrelated commit. Bumping it is now a deliberate change with its own diff. Verified on 1.4.0: frozen install, typecheck and lint all pass from an empty node_modules. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- .github/workflows/ci.yml | 5 ++++- bun.lock | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cda731..1b5244b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,10 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + # Pinned rather than `latest`: the lockfile format is version + # sensitive, and a new Bun landing upstream should not be able to + # break CI on an unrelated commit. Bump deliberately. + bun-version: "1.4.0" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/bun.lock b/bun.lock index 09555dd..95a8d19 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 3, "configVersion": 0, "workspaces": { "": { @@ -26,7 +26,9 @@ }, }, "overrides": { - "react": "./djsx/index.ts", + "sqlite3": { + "prebuild-install": "7.1.3", + }, }, "packages": { "@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="], From 1291e57b72719c7d353bdc10d0e11e64e2ec4e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:20:26 +0000 Subject: [PATCH 17/22] Add a test suite and run it in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 80 tests across 8 files, using `bun test`. This is the step 6 item I kept flagging: everything verified during the refactor was throwaway scripts, so none of it protected the next change. tests/ids.test.ts codec round-trips (separators, percent signs, non-ascii, empty values), the 100-character cap, snowflake and oneOf validation, and the two decode failures that mean "stale id" tests/dispatch.test.ts command, autocomplete and component routing; guildOnly and ownerOnly gating; duplicate registration; a throwing handler being reported tests/session.test.ts the session state machine and the ownership guard tests/paginator.test.ts navigation, clamping at both ends, empty lists, and the disable-on-end pass tests/messages.test.ts notice flags and structure, modlog entries in both avatar shapes, tag container and modal rendering tests/loader.test.ts the real command and event directories load; a file may export several listeners; a malformed module throws naming the file tests/commands.test.ts a snapshot of every deployed command payload tests/regressions.test.ts one test per bug fixed during the refactor The payload snapshot is the one I most wanted. Each refactor step was checked by dumping every command's deployed JSON before and after and diffing it by hand; tests/fixtures/command-payloads.json makes CI do that. Confirmed it works by changing one character of a command description and watching it fail. When a change is intended, `bun run tests/fixtures/regenerate-payloads.ts` rewrites the fixture and the diff becomes the review. The regression file names the failure each test guards: - the /g regex that made /cleanname server skip members, asserted stable across repeated calls - the config ids, asserted well-formed and distinct - notices being plain objects that need no cast, and interpolation happening in the template rather than in markup, which is what produced "$updated" Two helpers keep the tests free of gateway or network setup: tests/helpers/interactions.ts stubs the type guards and reply methods the dispatcher actually calls, and tests/helpers/session.ts stands in for the message collector. Both confine their casts to one file. The session harness waits for runSession to attach its collector before emitting, since a press issued immediately would otherwise land before the listener exists โ€” that cost three failing tests before I spotted it. silenceConsole() marks the tests that deliberately provoke a log so a real failure still stands out in the output. CI now runs test, typecheck and lint. Verified all three from an empty node_modules on the pinned Bun. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- .github/workflows/ci.yml | 5 +- bun.lock | 5 + package.json | 3 +- tests/commands.test.ts | 68 +++ tests/dispatch.test.ts | 196 +++++++++ tests/fixtures/broken/nocommand.ts | 2 + tests/fixtures/command-payloads.json | 580 ++++++++++++++++++++++++++ tests/fixtures/events/multiple.ts | 12 + tests/fixtures/events/single.ts | 7 + tests/fixtures/regenerate-payloads.ts | 26 ++ tests/helpers/interactions.ts | 91 ++++ tests/helpers/session.ts | 73 ++++ tests/ids.test.ts | 59 +++ tests/loader.test.ts | 61 +++ tests/messages.test.ts | 116 ++++++ tests/paginator.test.ts | 96 +++++ tests/regressions.test.ts | 77 ++++ tests/session.test.ts | 89 ++++ tsconfig.json | 1 + 19 files changed, 1565 insertions(+), 2 deletions(-) create mode 100644 tests/commands.test.ts create mode 100644 tests/dispatch.test.ts create mode 100644 tests/fixtures/broken/nocommand.ts create mode 100644 tests/fixtures/command-payloads.json create mode 100644 tests/fixtures/events/multiple.ts create mode 100644 tests/fixtures/events/single.ts create mode 100644 tests/fixtures/regenerate-payloads.ts create mode 100644 tests/helpers/interactions.ts create mode 100644 tests/helpers/session.ts create mode 100644 tests/ids.test.ts create mode 100644 tests/loader.test.ts create mode 100644 tests/messages.test.ts create mode 100644 tests/paginator.test.ts create mode 100644 tests/regressions.test.ts create mode 100644 tests/session.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b5244b..c8bedc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: check: - name: Typecheck and lint + name: Test, typecheck and lint runs-on: ubuntu-latest steps: @@ -23,6 +23,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Test + run: bun test + - name: Typecheck run: bun run typecheck diff --git a/bun.lock b/bun.lock index 95a8d19..e25b96c 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "undici": "^7.16.0", }, "devDependencies": { + "@types/bun": "^1.4.0", "@types/string-similarity": "^4.0.2", "@zerebos/eslint-config": "^1.0.3", "@zerebos/eslint-config-typescript": "^1.1.1", @@ -89,6 +90,8 @@ "@tootallnate/once": ["@tootallnate/once@1.1.2", "", {}, "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -161,6 +164,8 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "cacache": ["cacache@15.3.0", "", { "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "glob": "^7.1.4", "infer-owner": "^1.0.4", "lru-cache": "^6.0.0", "minipass": "^3.1.1", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.2", "mkdirp": "^1.0.3", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" } }, "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], diff --git a/package.json b/package.json index f8ff645..28574fa 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "src/index.ts", "type": "module", "scripts": { - "test": "echo \"No test suite yet\" && exit 0", + "test": "bun test", "start": "bun run --bun src/index.ts", "deploy": "bun run --bun scripts/deploy-commands.ts", "clear": "bun run --bun scripts/deploy-commands.ts --clear", @@ -16,6 +16,7 @@ "author": "Zerebos", "license": "MIT", "devDependencies": { + "@types/bun": "^1.4.0", "@types/string-similarity": "^4.0.2", "@zerebos/eslint-config": "^1.0.3", "@zerebos/eslint-config-typescript": "^1.1.1", diff --git a/tests/commands.test.ts b/tests/commands.test.ts new file mode 100644 index 0000000..f66a355 --- /dev/null +++ b/tests/commands.test.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import {describe, expect, test} from "bun:test"; +import {loadCommands} from "../src/framework"; +import expected from "./fixtures/command-payloads.json"; + + +/** + * A snapshot of exactly what gets deployed to Discord. + * + * Refactors are supposed to leave this untouched; when one legitimately + * changes a command, the diff here is the review. Regenerate with: + * + * bun run tests/fixtures/regenerate-payloads.ts + */ + +const sortKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + const record = value as Record; + return Object.fromEntries(Object.keys(record).sort().map(key => [key, sortKeys(record[key])])); + } + return value; +}; + +async function currentPayloads(): Promise> { + const payloads: Record = {}; + for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) { + const data: unknown = JSON.parse(JSON.stringify(command.data)); + payloads[command.name] = sortKeys({data, ownerOnly: command.ownerOnly}); + } + return payloads; +} + + +describe("deployed command payloads", () => { + test("match the committed snapshot", async () => { + expect(await currentPayloads()).toEqual(expected as Record); + }); + + test("the snapshot covers every command that loads", async () => { + expect(Object.keys(await currentPayloads()).sort()).toEqual(Object.keys(expected).sort()); + }); +}); + + +describe("payload invariants", () => { + test("owner-only commands are deployed to the guild, not globally", async () => { + const commands = await loadCommands(path.join(import.meta.dir, "..", "src", "commands")); + expect(commands.filter(command => command.ownerOnly).map(command => command.name)).toEqual(["botadmin"]); + }); + + test("no command still uses the deprecated dm_permission field", async () => { + for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) { + expect(command.data).not.toHaveProperty("dm_permission"); + } + }); + + test("subcommand options come last, as the API requires", async () => { + for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) { + for (const option of command.data.options ?? []) { + const nested = (option as {options?: Array<{required?: boolean}>}).options ?? []; + const firstOptional = nested.findIndex(child => child.required !== true); + if (firstOptional === -1) continue; + expect(nested.slice(firstOptional).every(child => child.required !== true)).toBe(true); + } + } + }); +}); diff --git a/tests/dispatch.test.ts b/tests/dispatch.test.ts new file mode 100644 index 0000000..ca14480 --- /dev/null +++ b/tests/dispatch.test.ts @@ -0,0 +1,196 @@ +import {beforeEach, describe, expect, test} from "bun:test"; +import {Dispatcher} from "../src/framework/dispatch"; +import {defineCommand, defineComponent} from "../src/framework/registry"; +import {Num, oneOf} from "../src/framework/ids"; +import {sessionId} from "../src/framework/session"; +import {lastReply, silenceConsole, stubInteraction} from "./helpers/interactions"; + + +const calls: string[] = []; + +const picker = defineComponent({ + id: "demo.pick", + kind: "button", + guildOnly: true, + params: {mode: oneOf("user", "admin"), page: Num}, + run: (_interaction, {mode, page}) => {calls.push(`pick:${mode}:${page}`); return Promise.resolve();} +}); + +const anywhere = defineComponent({ + id: "demo.any", + kind: "button", + params: {}, + run: () => {calls.push("any"); return Promise.resolve();} +}); + +const guildCommand = defineCommand({ + guildOnly: true, + data: {name: "demo", description: "d"}, + execute: () => {calls.push("demo"); return Promise.resolve();}, + autocomplete: () => {calls.push("demo:auto"); return Promise.resolve();} +}); + +const ownerCommand = defineCommand({ + ownerOnly: true, + data: {name: "secret", description: "d"}, + execute: () => {calls.push("secret"); return Promise.resolve();} +}); + + +function build() { + const dispatcher = new Dispatcher({ownerId: "owner-1"}); + dispatcher.addCommand(guildCommand); + dispatcher.addCommand(ownerCommand); + dispatcher.addComponent(picker); + dispatcher.addComponent(anywhere); + return dispatcher; +} + +beforeEach(() => {calls.length = 0;}); + + +describe("command routing", () => { + test("runs a registered command", async () => { + await build().dispatch(stubInteraction({kind: "chat", commandName: "demo"}).interaction); + expect(calls).toEqual(["demo"]); + }); + + test("guildOnly is enforced before the handler runs", async () => { + const stub = stubInteraction({kind: "chat", commandName: "demo", cached: false}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(lastReply(stub)).toContain("can't use that command here"); + }); + + test("ownerOnly is enforced before the handler runs", async () => { + const stub = stubInteraction({kind: "chat", commandName: "secret", userId: "someone-else"}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + + const owner = stubInteraction({kind: "chat", commandName: "secret", userId: "owner-1"}); + await build().dispatch(owner.interaction); + expect(calls).toEqual(["secret"]); + }); + + test("an unregistered command says so rather than failing silently", async () => { + const restore = silenceConsole(); + const stub = stubInteraction({kind: "chat", commandName: "ghost"}); + await build().dispatch(stub.interaction); + restore(); + expect(lastReply(stub)).toContain("isn't registered"); + }); + + test("onCommandRun fires for stats, once per command", async () => { + const seen: string[] = []; + const dispatcher = new Dispatcher({ownerId: "owner-1", onCommandRun: i => {seen.push(i.commandName); return Promise.resolve();}}); + dispatcher.addCommand(guildCommand); + await dispatcher.dispatch(stubInteraction({kind: "chat", commandName: "demo"}).interaction); + expect(seen).toEqual(["demo"]); + }); +}); + + +describe("autocomplete routing", () => { + test("reaches the command's autocomplete handler", async () => { + await build().dispatch(stubInteraction({kind: "autocomplete", commandName: "demo"}).interaction); + expect(calls).toEqual(["demo:auto"]); + }); + + test("responds empty rather than throwing when there is no handler", async () => { + const stub = stubInteraction({kind: "autocomplete", commandName: "secret"}); + await build().dispatch(stub.interaction); + expect(stub.autocompleteResponses).toEqual([[]]); + }); +}); + + +describe("component routing", () => { + test("decodes params and passes them typed", async () => { + await build().dispatch(stubInteraction({kind: "button", customId: picker.customId({mode: "admin", page: 7})}).interaction); + expect(calls).toEqual(["pick:admin:7"]); + }); + + test("guildOnly components are gated too", async () => { + const stub = stubInteraction({kind: "button", customId: picker.customId({mode: "user", page: 1}), cached: false}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(lastReply(stub)).toContain("can't use that"); + }); + + test("a component registered for one kind refuses another", async () => { + const restore = silenceConsole(); + await build().dispatch(stubInteraction({kind: "stringSelect", customId: anywhere.customId({})}).interaction); + restore(); + expect(calls).toEqual([]); + }); + + test("a stale id from before a deploy explains itself", async () => { + const restore = silenceConsole(); + const stub = stubInteraction({kind: "button", customId: "demo.pick:admin"}); + await build().dispatch(stub.interaction); + restore(); + expect(calls).toEqual([]); + expect(lastReply(stub)).toContain("out of date"); + }); + + test("a malformed param value explains itself the same way", async () => { + const restore = silenceConsole(); + const stub = stubInteraction({kind: "button", customId: "demo.pick:sudo:1"}); + await build().dispatch(stub.interaction); + restore(); + expect(lastReply(stub)).toContain("out of date"); + }); + + // This silence is the contract that lets sessions and registered + // components share one custom-id space. + test("session-owned ids are left to their own collector", async () => { + const stub = stubInteraction({kind: "button", customId: sessionId("next")}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(stub.replies).toEqual([]); + }); + + test("an unknown namespace is ignored, not treated as an error", async () => { + const stub = stubInteraction({kind: "button", customId: "nobody.knows:1"}); + await build().dispatch(stub.interaction); + expect(stub.replies).toEqual([]); + }); + + // The pre-framework router matched on customId.split("-")[0]. + test("hyphenated ids no longer route anywhere", async () => { + const stub = stubInteraction({kind: "roleSelect", customId: "cleanname-whatever"}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(stub.replies).toEqual([]); + }); +}); + + +describe("registration and failure", () => { + test("duplicate command names throw at startup", () => { + const dispatcher = build(); + expect(() => dispatcher.addCommand(guildCommand)).toThrow(/duplicate command/); + }); + + test("duplicate component namespaces throw at startup", () => { + const dispatcher = build(); + expect(() => dispatcher.addComponent(picker)).toThrow(/duplicate component/); + }); + + test("a throwing handler is reported to the user, not swallowed", async () => { + const restore = silenceConsole(); + const dispatcher = new Dispatcher({ownerId: "owner-1"}); + dispatcher.addCommand(defineCommand({ + data: {name: "boom", description: "d"}, + execute: () => {throw new Error("kaboom");} + })); + const stub = stubInteraction({kind: "chat", commandName: "boom"}); + await dispatcher.dispatch(stub.interaction); + restore(); + expect(lastReply(stub)).toContain("Something went wrong"); + }); + + test("counts report what is registered", () => { + expect(build().counts).toEqual({commands: 2, components: 2}); + }); +}); diff --git a/tests/fixtures/broken/nocommand.ts b/tests/fixtures/broken/nocommand.ts new file mode 100644 index 0000000..bb1dad6 --- /dev/null +++ b/tests/fixtures/broken/nocommand.ts @@ -0,0 +1,2 @@ +/** A module that exports nothing the loader can use. */ +export const somethingElse = 42; diff --git a/tests/fixtures/command-payloads.json b/tests/fixtures/command-payloads.json new file mode 100644 index 0000000..5cd8c18 --- /dev/null +++ b/tests/fixtures/command-payloads.json @@ -0,0 +1,580 @@ +{ + "about": { + "data": { + "contexts": [ + 0, + 1, + 2 + ], + "description": "Gives some information about the bot", + "integration_types": [ + 0, + 1 + ], + "name": "about", + "type": 1 + }, + "ownerOnly": false + }, + "addons": { + "data": { + "contexts": [ + 0, + 1, + 2 + ], + "description": "Commands for addons.", + "integration_types": [ + 0, + 1 + ], + "name": "addons", + "options": [ + { + "description": "Shows the most recently updated addons", + "name": "updated", + "type": 1 + }, + { + "description": "Shows the newest added addons", + "name": "newest", + "type": 1 + }, + { + "description": "Shows the most liked addons", + "name": "top", + "type": 1 + }, + { + "description": "Shows the most downloaded addons", + "name": "popular", + "type": 1 + }, + { + "description": "Shows a random addon", + "name": "random", + "type": 1 + }, + { + "description": "Searches for an addon by name", + "name": "search", + "options": [ + { + "autocomplete": true, + "description": "Name of the addon to find", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Gets information about an addon", + "name": "info", + "options": [ + { + "autocomplete": true, + "description": "Name of the addon to get info about", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Browse addons in an interactive way", + "name": "browse", + "options": [ + { + "autocomplete": true, + "description": "tag to browse", + "name": "tag", + "required": false, + "type": 3 + }, + { + "choices": [ + { + "name": "Plugin", + "value": "plugin" + }, + { + "name": "Theme", + "value": "theme" + } + ], + "description": "type to browse", + "name": "type", + "required": false, + "type": 3 + }, + { + "choices": [ + { + "name": "Newest", + "value": "initial_release_date" + }, + { + "name": "Last Updated", + "value": "latest_release_date" + }, + { + "name": "Most Liked", + "value": "likes" + }, + { + "name": "Popular", + "value": "downloads" + } + ], + "description": "sort method", + "name": "sort", + "required": false, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "botadmin": { + "data": { + "description": "Global settings for the bot during runtime.", + "name": "botadmin", + "options": [ + { + "description": "Sends messages to different locations", + "name": "send", + "options": [ + { + "description": "Sends a DM to the specified user.", + "name": "user", + "options": [ + { + "description": "User to DM.", + "name": "user", + "required": true, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Sends a message to the specified channel.", + "name": "channel", + "options": [ + { + "channel_types": [ + 0 + ], + "description": "Channel to send a message.", + "name": "channel", + "required": true, + "type": 7 + } + ], + "type": 1 + } + ], + "type": 2 + }, + { + "description": "Sets up DM forwarding to a user.", + "name": "forwarding", + "options": [ + { + "description": "Who to forward DMs to?", + "name": "user", + "required": false, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Exits the bot gracefully.", + "name": "quit", + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": true + }, + "cleanname": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "32", + "description": "Cleans member display names to match Discord's username standards.", + "name": "cleanname", + "options": [ + { + "description": "Toggles automatically cleaning new members when they join.", + "name": "join", + "options": [ + { + "description": "Whether members should have their display name cleaned upon joining.", + "name": "enabled", + "required": true, + "type": 5 + } + ], + "type": 1 + }, + { + "description": "Fixes a display name for a single user.", + "name": "user", + "options": [ + { + "description": "Whose display name should be cleaned?", + "name": "user", + "required": true, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Fixes all display names in the server.", + "name": "server", + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "developer": { + "data": { + "contexts": [ + 0 + ], + "description": "Manage roles for developers in the community.", + "name": "developer", + "options": [ + { + "description": "Adds a new developer or new role to an existing developer.", + "name": "add", + "options": [ + { + "description": "Who is the developer in question?", + "name": "user", + "required": true, + "type": 6 + }, + { + "choices": [ + { + "name": "Plugin Developer", + "value": "Plugin Developer" + }, + { + "name": "Theme Developer", + "value": "Theme Developer" + } + ], + "description": "Role to add.", + "name": "role", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Syncs roles between severs.", + "name": "sync", + "options": [ + { + "description": "Which developer to resync?", + "name": "user", + "required": true, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Sets a channel to send invite messages.", + "name": "channel", + "options": [ + { + "description": "Which channel ID to send invites?", + "name": "channel", + "required": false, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "moderation": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "32", + "description": "Commands for moderating the server.", + "name": "moderation", + "options": [ + { + "description": "Toggles the invite filter module.", + "name": "invitefilter", + "options": [ + { + "description": "Enable or disable", + "name": "enable", + "required": false, + "type": 5 + } + ], + "type": 1 + }, + { + "description": "Toggles the spam detection module.", + "name": "detectspam", + "options": [ + { + "description": "Enable or disable", + "name": "enable", + "required": false, + "type": 5 + } + ], + "type": 1 + }, + { + "description": "Sets a channel to log bot moderation actions.", + "name": "modlog", + "options": [ + { + "channel_types": [ + 0 + ], + "description": "Where to log my actions?", + "name": "channel", + "required": false, + "type": 7 + } + ], + "type": 1 + }, + { + "description": "Sets a channel to log join/leave messages.", + "name": "joinleave", + "options": [ + { + "channel_types": [ + 0 + ], + "description": "Where to log join/leave messages?", + "name": "channel", + "required": false, + "type": 7 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "selfroles": { + "data": { + "contexts": [ + 0 + ], + "description": "Allows users to self-assign roles.", + "name": "selfroles", + "type": 1 + }, + "ownerOnly": false + }, + "spam": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "8192", + "description": "Commands for dealing with spam.", + "name": "spam", + "options": [ + { + "description": "Adds a link to the automod spam link filter", + "name": "link", + "options": [ + { + "description": "Link to add to the filter", + "name": "link", + "required": true, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "tag": { + "data": { + "contexts": [ + 0 + ], + "description": "Saving and recalling custom tags.", + "integration_types": [ + 0 + ], + "name": "tag", + "options": [ + { + "description": "List all tags in this server", + "name": "list", + "type": 1 + }, + { + "description": "View a tag", + "name": "view", + "options": [ + { + "autocomplete": true, + "description": "Name of the tag to view", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Update a tag", + "name": "update", + "options": [ + { + "autocomplete": true, + "description": "Name of the tag to update", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Delete a tag", + "name": "delete", + "options": [ + { + "autocomplete": true, + "description": "Name of the tag to delete", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Create a new tag", + "name": "create", + "options": [ + { + "autocomplete": false, + "description": "Name of the tag to create", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "voicetext": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "32", + "description": "Binds one voice and one text channel together.", + "name": "voicetext", + "options": [ + { + "description": "Checks the bound status of a voice channel.", + "name": "status", + "options": [ + { + "channel_types": [ + 2 + ], + "description": "Which voice channel to check?", + "name": "channel", + "required": true, + "type": 7 + } + ], + "type": 1 + }, + { + "description": "Unbinds a voice channel from it's partner.", + "name": "unbind", + "options": [ + { + "channel_types": [ + 2 + ], + "description": "Which voice channel to unbind?", + "name": "channel", + "required": true, + "type": 7 + } + ], + "type": 1 + }, + { + "description": "Binds a voice and text channel together.", + "name": "bind", + "options": [ + { + "channel_types": [ + 2 + ], + "description": "Which voice channel to bind?", + "name": "voice", + "required": true, + "type": 7 + }, + { + "channel_types": [ + 0 + ], + "description": "Which text channel to bind with?", + "name": "text", + "required": true, + "type": 7 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + } +} diff --git a/tests/fixtures/events/multiple.ts b/tests/fixtures/events/multiple.ts new file mode 100644 index 0000000..9364400 --- /dev/null +++ b/tests/fixtures/events/multiple.ts @@ -0,0 +1,12 @@ +import {Events} from "discord.js"; +import {defineEvents} from "../../../src/framework"; + +/** + * The shape src/events/joinleave.ts uses. The pre-framework loader read `.name` + * off the array, registered `client.on(undefined, ...)`, and the listeners + * never fired. + */ +export default defineEvents( + {name: Events.GuildMemberAdd, execute: () => Promise.resolve()}, + {name: Events.GuildMemberRemove, execute: () => Promise.resolve()} +); diff --git a/tests/fixtures/events/single.ts b/tests/fixtures/events/single.ts new file mode 100644 index 0000000..8a312f4 --- /dev/null +++ b/tests/fixtures/events/single.ts @@ -0,0 +1,7 @@ +import {Events} from "discord.js"; +import {defineEvent} from "../../../src/framework"; + +export default defineEvent({ + name: Events.MessageCreate, + execute: () => Promise.resolve() +}); diff --git a/tests/fixtures/regenerate-payloads.ts b/tests/fixtures/regenerate-payloads.ts new file mode 100644 index 0000000..b1ab29e --- /dev/null +++ b/tests/fixtures/regenerate-payloads.ts @@ -0,0 +1,26 @@ +/** + * Rewrites tests/fixtures/command-payloads.json from the current source. + * Run this when a command change is intended, and review the resulting diff. + */ + +import path from "node:path"; +import {loadCommands} from "../../src/framework"; + +const sortKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + const record = value as Record; + return Object.fromEntries(Object.keys(record).sort().map(key => [key, sortKeys(record[key])])); + } + return value; +}; + +const payloads: Record = {}; +for (const command of await loadCommands(path.join(import.meta.dir, "..", "..", "src", "commands"))) { + const data: unknown = JSON.parse(JSON.stringify(command.data)); + payloads[command.name] = sortKeys({data, ownerOnly: command.ownerOnly}); +} + +const target = path.join(import.meta.dir, "command-payloads.json"); +await Bun.write(target, JSON.stringify(payloads, null, 2) + "\n"); +console.log(`Wrote ${Object.keys(payloads).length} command payloads to ${path.relative(process.cwd(), target)}`); diff --git a/tests/helpers/interactions.ts b/tests/helpers/interactions.ts new file mode 100644 index 0000000..a2a0baf --- /dev/null +++ b/tests/helpers/interactions.ts @@ -0,0 +1,91 @@ +/** + * Minimal stand-ins for discord.js interactions. + * + * The dispatcher only ever calls the type guards and a handful of reply + * methods, so a plain object is enough and keeps the tests free of network or + * gateway setup. The single cast is confined to this file. + */ + +import type {Interaction} from "discord.js"; + + +export type StubKind = "chat" | "autocomplete" | "button" | "stringSelect" | "roleSelect" | "modal"; + +export interface StubOptions { + kind: StubKind; + customId?: string; + commandName?: string; + userId?: string; + /** false simulates a DM or an uncached guild. */ + cached?: boolean; + values?: string[]; + deferred?: boolean; + replied?: boolean; +} + +export interface Stub { + interaction: Interaction; + /** Everything the code under test sent back, in order. */ + replies: Array>; + updates: Array>; + autocompleteResponses: unknown[][]; +} + + +export function stubInteraction(options: StubOptions): Stub { + const {kind, customId = "", commandName = "", userId = "user-1", cached = true, values = []} = options; + + const replies: Array> = []; + const updates: Array> = []; + const autocompleteResponses: unknown[][] = []; + + const interaction = { + customId, + commandName, + values, + user: {id: userId}, + deferred: options.deferred ?? false, + replied: options.replied ?? false, + + isChatInputCommand: () => kind === "chat", + isAutocomplete: () => kind === "autocomplete", + isMessageComponent: () => kind === "button" || kind === "stringSelect" || kind === "roleSelect", + isModalSubmit: () => kind === "modal", + isButton: () => kind === "button", + isStringSelectMenu: () => kind === "stringSelect", + isRoleSelectMenu: () => kind === "roleSelect", + isUserSelectMenu: () => false, + isChannelSelectMenu: () => false, + isMentionableSelectMenu: () => false, + isRepliable: () => true, + inCachedGuild: () => cached, + + reply: (payload: Record) => {replies.push(payload); return Promise.resolve();}, + followUp: (payload: Record) => {replies.push(payload); return Promise.resolve();}, + update: (payload: Record) => {updates.push(payload); return Promise.resolve();}, + deferUpdate: () => Promise.resolve(), + respond: (choices: unknown[]) => {autocompleteResponses.push(choices); return Promise.resolve();} + }; + + return {interaction: interaction as unknown as Interaction, replies, updates, autocompleteResponses}; +} + +/** Text of the last thing sent back, for terse assertions. */ +export function lastReply(stub: Stub): string { + const content = stub.replies.at(-1)?.content; + return typeof content === "string" ? content : ""; +} + + +/** + * Silences console output for one test. Several dispatcher paths log on + * purpose (a stale id, an unregistered command, a handler that threw); this + * keeps the suite output clean so a real failure stands out, and marks those + * tests as expecting the noise. + */ +export function silenceConsole(): () => void { + const {error, warn} = console; + console.error = () => {}; + console.warn = () => {}; + return () => {console.error = error; console.warn = warn;}; +} diff --git a/tests/helpers/session.ts b/tests/helpers/session.ts new file mode 100644 index 0000000..922a583 --- /dev/null +++ b/tests/helpers/session.ts @@ -0,0 +1,73 @@ +/** + * A stand-in for the message + component collector that runSession drives. + * `press()` delivers a click the way discord.js would. + */ + +import {EventEmitter} from "node:events"; +import type {RepliableInteraction} from "discord.js"; + + +export interface SessionHarness { + interaction: RepliableInteraction; + /** Every payload a viewer would have seen, from editReply or update. */ + shown: Array>; + press(action: string, userId?: string): Promise>>; + end(): Promise; +} + + +export function sessionHarness(ownerId = "owner"): SessionHarness { + const collector = new EventEmitter(); + const shown: Array> = []; + + const interaction = { + deferred: true, + replied: false, + user: {id: ownerId}, + deferReply: () => Promise.resolve(), + editReply: (payload: Record) => { + shown.push(payload); + return Promise.resolve({createMessageComponentCollector: () => collector}); + } + }; + + const settle = () => new Promise(resolve => setImmediate(resolve)); + + /** + * runSession attaches its collector after two awaits, so a press issued + * immediately after starting the session would otherwise be emitted into + * the void. + */ + async function whenListening() { + for (let attempt = 0; attempt < 100 && collector.listenerCount("collect") === 0; attempt++) await settle(); + if (collector.listenerCount("collect") === 0) throw new Error("session never attached a collector"); + } + + return { + interaction: interaction as unknown as RepliableInteraction, + shown, + + async press(action, userId = ownerId) { + await whenListening(); + const refusals: Array> = []; + const component = { + customId: `~${action}`, + user: {id: userId}, + replied: false, + deferred: false, + reply: (payload: Record) => {refusals.push(payload); return Promise.resolve();}, + update: (payload: Record) => {shown.push(payload); return Promise.resolve();}, + deferUpdate: () => Promise.resolve() + }; + collector.emit("collect", component); + await settle(); + return refusals; + }, + + async end() { + await whenListening(); + collector.emit("end"); + await settle(); + } + }; +} diff --git a/tests/ids.test.ts b/tests/ids.test.ts new file mode 100644 index 0000000..fa786a1 --- /dev/null +++ b/tests/ids.test.ts @@ -0,0 +1,59 @@ +import {describe, expect, test} from "bun:test"; +import {Bool, Id, IdError, MAX_CUSTOM_ID, Num, Str, decodeId, encodeId, namespaceOf, oneOf} from "../src/framework/ids"; + + +describe("custom id codec", () => { + const spec = {a: Str, b: Str, c: Num, d: Bool}; + + test.each([ + ["separator in a value", {a: "a:b", b: "plain", c: 1, d: true}], + ["percent and separator", {a: "100%:sure", b: "%3A", c: -2.5, d: false}], + ["empty and repeated separators", {a: "", b: "::::", c: 0, d: true}], + ["non-ascii", {a: "emoji ๐ŸŽญ ok", b: "a%b:c", c: 42, d: false}] + ])("round-trips %s", (_label, params) => { + expect(decodeId(spec, encodeId("ns", spec, params))).toEqual(params); + }); + + test("namespace is the prefix and survives escaping", () => { + expect(namespaceOf(encodeId("some.thing", spec, {a: "x:y", b: "", c: 1, d: false}))).toBe("some.thing"); + }); + + test("a spec with no params encodes to just the namespace", () => { + expect(encodeId("bare", {}, {})).toBe("bare"); + expect(decodeId({}, "bare")).toEqual({}); + }); +}); + + +describe("codec validation", () => { + test("rejects an id over Discord's 100-character limit", () => { + expect(() => encodeId("ns", {a: Str}, {a: "y".repeat(MAX_CUSTOM_ID)})).toThrow(IdError); + }); + + test("accepts an id exactly at the limit", () => { + const id = encodeId("ns", {a: Str}, {a: "y".repeat(MAX_CUSTOM_ID - "ns:".length)}); + expect(id).toHaveLength(MAX_CUSTOM_ID); + }); + + test("rejects a malformed snowflake in both directions", () => { + expect(() => Id.format("nope")).toThrow(IdError); + expect(() => Id.parse("12")).toThrow(IdError); + expect(Id.parse("123456789012345678")).toBe("123456789012345678"); + }); + + test("rejects a non-numeric value for a number param", () => { + expect(() => decodeId({n: Num}, "ns:banana")).toThrow(IdError); + }); + + test("rejects the wrong number of params, which is what a stale id looks like", () => { + const spec = {a: Str, b: Str}; + expect(() => decodeId(spec, "ns:only-one")).toThrow(IdError); + expect(() => decodeId(spec, "ns:a:b:c")).toThrow(IdError); + }); + + test("oneOf rejects a value outside the set", () => { + const mode = oneOf("user", "admin"); + expect(mode.parse("admin")).toBe("admin"); + expect(() => mode.parse("root")).toThrow(IdError); + }); +}); diff --git a/tests/loader.test.ts b/tests/loader.test.ts new file mode 100644 index 0000000..4ce327b --- /dev/null +++ b/tests/loader.test.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import {describe, expect, test} from "bun:test"; +import {Dispatcher, loadCommands, loadEvents} from "../src/framework"; + + +const root = path.join(import.meta.dir, ".."); +const fixtures = path.join(import.meta.dir, "fixtures"); + + +describe("loading the real bot", () => { + test("every command file exports a usable command", async () => { + const commands = await loadCommands(path.join(root, "src", "commands")); + expect(commands.length).toBeGreaterThan(0); + for (const command of commands) { + expect(typeof command.name).toBe("string"); + expect(command.data.name).toBe(command.name); + } + }); + + test("command names are unique and API-legal", async () => { + const commands = await loadCommands(path.join(root, "src", "commands")); + const names = commands.map(command => command.name); + expect(new Set(names).size).toBe(names.length); + for (const name of names) expect(name).toMatch(/^[-_'\p{L}\p{N}]{1,32}$/u); + }); + + test("descriptions stay inside Discord's limits", async () => { + for (const command of await loadCommands(path.join(root, "src", "commands"))) { + expect(command.data.description.length).toBeGreaterThan(0); + expect(command.data.description.length).toBeLessThanOrEqual(100); + } + }); + + test("everything registers without a duplicate name or namespace", async () => { + const dispatcher = new Dispatcher({ownerId: "owner"}); + const commands = await loadCommands(path.join(root, "src", "commands")); + for (const command of commands) command.register(dispatcher); + expect(dispatcher.counts.commands).toBe(commands.length); + }); + + test("every event file yields listeners with a name and an execute", async () => { + const events = await loadEvents(path.join(root, "src", "events")); + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(typeof event.name).toBe("string"); + expect(typeof event.execute).toBe("function"); + } + }); +}); + + +describe("loader contract", () => { + test("a file may export several listeners", async () => { + const events = await loadEvents(path.join(fixtures, "events")); + expect(events.map(event => event.name).sort()).toEqual(["guildMemberAdd", "guildMemberRemove", "messageCreate"]); + }); + + test("a command module with no exported command throws, naming the file", () => { + expect(loadCommands(path.join(fixtures, "broken"))).rejects.toThrow(/nocommand\.ts/); + }); +}); diff --git a/tests/messages.test.ts b/tests/messages.test.ts new file mode 100644 index 0000000..a6e88cf --- /dev/null +++ b/tests/messages.test.ts @@ -0,0 +1,116 @@ +import {describe, expect, test} from "bun:test"; +import {ButtonStyle, ComponentType, MessageFlags} from "discord.js"; +import {row} from "../src/framework"; +import * as notices from "../src/util/notices"; +import {modLogMessage} from "../src/util/modlog"; +import {Accents} from "../src/util/colors"; +import {tagContainer, updateTagModal} from "../src/components/tags"; + + +const CONTAINER = 17; +const SECTION = 9; +const TEXT_DISPLAY = 10; +const THUMBNAIL = 11; + +interface Container { + type: number; + accentColor?: number; + components: Array<{type: number; content?: string; components?: Array<{content: string}>; accessory?: unknown}>; +} +const containerOf = (message: {components: unknown[]}) => message.components[0] as Container; + + +describe("notices", () => { + test.each(["success", "info", "warn", "error", "danger"] as const)("%s renders one accented container", kind => { + const message = notices.notice(kind, "hello"); + const container = containerOf(message); + expect(container.type).toBe(CONTAINER); + expect(container.accentColor).toBe(Accents[`${kind[0].toUpperCase()}${kind.slice(1)}` as keyof typeof Accents]); + expect(container.components[0]?.content).toContain("hello"); + }); + + test("always sets IsComponentsV2", () => { + expect(notices.info("x").flags & MessageFlags.IsComponentsV2).toBe(MessageFlags.IsComponentsV2); + }); + + test("ephemeral adds the Ephemeral flag without dropping V2", () => { + const flags = notices.error("x", {ephemeral: true}).flags; + expect(flags & MessageFlags.Ephemeral).toBe(MessageFlags.Ephemeral); + expect(flags & MessageFlags.IsComponentsV2).toBe(MessageFlags.IsComponentsV2); + }); + + // V2 puts action rows inside the container, not alongside it. + test("action rows are nested inside the container", () => { + const actions = row({type: ComponentType.Button, customId: "a", label: "A", style: ButtonStyle.Primary}); + const container = containerOf(notices.info("pick", {components: [actions]})); + expect(container.components.map(c => c.type)).toEqual([TEXT_DISPLAY, 1]); + }); + + test("each kind carries its own icon", () => { + expect(String(containerOf(notices.success("x")).components[0]?.content)).toContain(":white_check_mark:"); + expect(String(containerOf(notices.error("x")).components[0]?.content)).toContain(":no_entry:"); + }); +}); + + +describe("moderation log entries", () => { + const entry = { + heading: "spammer", + body: "Message sent by spammer in #general", + reason: "Fake Discord Link", + userId: "123456789012345678", + at: 1_750_000_000_000 + }; + + test("with an avatar, the heading/body/reason sit in a thumbnailed section", () => { + const container = containerOf(modLogMessage({...entry, iconUrl: "https://cdn/avatar.png"})); + const section = container.components[0]; + expect(section?.type).toBe(SECTION); + expect(section?.accessory).toMatchObject({type: THUMBNAIL}); + expect(section?.components?.map(c => c.content)).toEqual([ + "### spammer", + "Message sent by spammer in #general", + "**Reason:** Fake Discord Link" + ]); + }); + + test("without an avatar, the lines are flat with no section", () => { + const container = containerOf(modLogMessage(entry)); + expect(container.components.every(c => c.type === TEXT_DISPLAY)).toBe(true); + }); + + test("the footer carries the user id and a Discord timestamp", () => { + const container = containerOf(modLogMessage(entry)); + expect(container.components.at(-1)?.content).toBe("-# ID: 123456789012345678 โ€ข "); + }); + + test("an empty body does not produce an empty text display", () => { + const container = containerOf(modLogMessage({...entry, body: ""})); + expect(container.components[1]?.content).not.toBe(""); + }); +}); + + +describe("tag rendering", () => { + test("title becomes a heading above the content", () => { + const container = tagContainer({name: "t", title: "Hello", content: "Body"}) as unknown as Container; + expect(container.components.map(c => c.content)).toEqual(["# Hello", "Body"]); + }); + + test("no title means no heading", () => { + const container = tagContainer({name: "t", content: "Body"}) as unknown as Container; + expect(container.components.map(c => c.content)).toEqual(["Body"]); + }); + + test("a thumbnail wraps the text in a section", () => { + const container = tagContainer({name: "t", content: "Body", thumbnailUrl: "https://x/y.png"}) as unknown as Container; + expect(container.components[0]?.type).toBe(SECTION); + expect(container.components[0]?.accessory).toMatchObject({type: THUMBNAIL}); + }); + + test("the modal has the three expected fields and titles itself by intent", () => { + expect(updateTagModal({name: "t"}).title).toBe("Create Tag: t"); + expect(updateTagModal({name: "t", content: "c"}).title).toBe("Update Tag: t"); + expect(updateTagModal({name: "t"}).components).toHaveLength(3); + }); +}); diff --git a/tests/paginator.test.ts b/tests/paginator.test.ts new file mode 100644 index 0000000..f29df2f --- /dev/null +++ b/tests/paginator.test.ts @@ -0,0 +1,96 @@ +import {describe, expect, test} from "bun:test"; +import {paginate} from "../src/paginator"; +import {sessionHarness} from "./helpers/session"; + + +const IS_COMPONENTS_V2 = 1 << 15; + +interface Rendered { + flags?: unknown; + components?: Array<{content?: string; components?: Array<{label: string; disabled?: boolean}>}>; +} + +function paginated(count: number, perPage = 10) { + const harness = sessionHarness(); + const items = Array.from({length: count}, (_, index) => index + 1); + const done = paginate({ + interaction: harness.interaction, + items, + perPage, + renderPage: (page, number, total) => [{type: 10, content: `[${page.join(",")}] ${number}/${total}`}] + }); + + const latest = () => harness.shown.at(-1) as Rendered; + return { + harness, + done, + label: () => String(latest().components?.[0]?.content), + buttons: () => latest().components?.[1]?.components ?? [], + flags: () => Number(latest().flags ?? 0) + }; +} + + +describe("paginate", () => { + test("starts on page one and disables the backward controls", async () => { + const p = paginated(23); + await p.harness.press("noop"); + expect(p.label()).toBe("[1,2,3,4,5,6,7,8,9,10] 1/3"); + expect(p.buttons()[0]?.disabled).toBe(true); + expect(p.buttons()[1]?.disabled).toBe(true); + await p.harness.end(); + await p.done; + }); + + test("walks forwards and backwards, and clamps at both ends", async () => { + const p = paginated(23); + await p.harness.press("next"); + expect(p.label()).toBe("[11,12,13,14,15,16,17,18,19,20] 2/3"); + await p.harness.press("next"); + expect(p.label()).toBe("[21,22,23] 3/3"); + await p.harness.press("next"); + expect(p.label()).toBe("[21,22,23] 3/3"); + await p.harness.press("first"); + expect(p.label()).toBe("[1,2,3,4,5,6,7,8,9,10] 1/3"); + await p.harness.press("previous"); + expect(p.label()).toBe("[1,2,3,4,5,6,7,8,9,10] 1/3"); + await p.harness.press("last"); + expect(p.label()).toBe("[21,22,23] 3/3"); + await p.harness.end(); + await p.done; + }); + + test("the page counter tracks the current page", async () => { + const p = paginated(23); + await p.harness.press("last"); + expect(p.buttons()[2]?.label).toBe("Page 3 of 3"); + await p.harness.end(); + await p.done; + }); + + test("an empty list is one page, not zero", async () => { + const p = paginated(0); + await p.harness.press("noop"); + expect(p.buttons()[2]?.label).toBe("Page 1 of 1"); + expect(p.buttons().every(button => button.disabled)).toBe(true); + await p.harness.end(); + await p.done; + }); + + /** The previous implementation dropped this flag on the final edit. */ + test("every render carries IsComponentsV2, including the last", async () => { + const p = paginated(23); + await p.harness.press("next"); + await p.harness.end(); + await p.done; + expect(p.harness.shown.every(shown => Number((shown as Rendered).flags) === IS_COMPONENTS_V2)).toBe(true); + }); + + test("all controls are disabled once the collector ends", async () => { + const p = paginated(23); + await p.harness.press("next"); + await p.harness.end(); + await p.done; + expect(p.buttons().every(button => button.disabled)).toBe(true); + }); +}); diff --git a/tests/regressions.test.ts b/tests/regressions.test.ts new file mode 100644 index 0000000..9120250 --- /dev/null +++ b/tests/regressions.test.ts @@ -0,0 +1,77 @@ +import {describe, expect, test} from "bun:test"; +import {hasDisallowedChars} from "../src/util/names"; +import config from "../src/config"; +import * as notices from "../src/util/notices"; + + +/** + * One test per bug fixed during the refactor, so none of them can come back + * quietly. Each names the failure it guards against. + */ + +describe("display-name checks are stateless (was: /cleanname server skipped members)", () => { + const dirty = ["๐“‘๐“ช๐“ญ๐“๐“ช๐“ถ๐“ฎ", "AlsoBadโ˜†", "Badโ™ฅThree", "Badโ™ฆFour", "ฮฉ", "naรฏve", "๐ŸŽญ๐ŸŽญ๐ŸŽญ"]; + const clean = ["Zerebos", "some_user", "a-b.c", "plain name", "123", "A_B-C.D"]; + + // The regex was module-level with a /g flag. RegExp.test advances lastIndex + // on a global regex, so consecutive calls returned alternating answers. + test("every disallowed name is caught, on every pass", () => { + for (let pass = 0; pass < 3; pass++) { + for (const name of dirty) expect(hasDisallowedChars(name)).toBe(true); + } + }); + + test("every allowed name passes, on every pass", () => { + for (let pass = 0; pass < 3; pass++) { + for (const name of clean) expect(hasDisallowedChars(name)).toBe(false); + } + }); + + test("the same input gives the same answer twenty times running", () => { + const answers = new Set(Array.from({length: 20}, () => hasDisallowedChars("Badโ™ฅThree"))); + expect([...answers]).toEqual([true]); + }); +}); + + +describe("config (was: snowflakes inline in six files)", () => { + test("every id is a plausible snowflake", () => { + const ids = [ + config.guilds.betterDiscord, + config.roles.pluginDeveloper, + config.roles.themeDeveloper, + config.roles.communityPluginDeveloper, + config.roles.communityThemeDeveloper, + config.channels.accountIssues, + config.automod.spamLinkRule + ]; + for (const id of ids) expect(id).toMatch(/^\d{15,25}$/); + }); + + test("ids are distinct, so none of them is a copy-paste slip", () => { + const roles = Object.values(config.roles); + expect(new Set(roles).size).toBe(roles.length); + }); +}); + + +describe("notices satisfy every send path (was: casts at each call site)", () => { + // The djsx widgets were typed as an intersection that satisfied neither + // reply nor editReply, so every call site needed `as MessageOptions`. + test("a notice is a plain object with numeric flags and container components", () => { + const notice = notices.success("done", {ephemeral: true}); + expect(typeof notice.flags).toBe("number"); + expect(Array.isArray(notice.components)).toBe(true); + expect(notice.components).toHaveLength(1); + }); + + test("interpolation happens in the template, not in markup", () => { + // The JSX version emitted a literal "$" here: `${...}` inside JSX text + // is not interpolated. + const isUpdating = true; + const notice = notices.success(`Tag \`hello\` has been ${isUpdating ? "updated" : "created"} successfully!`); + const content = String((notice.components[0] as unknown as {components: Array<{content: string}>}).components[0].content); + expect(content).toContain("has been updated successfully!"); + expect(content).not.toContain("$"); + }); +}); diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 0000000..e8a7032 --- /dev/null +++ b/tests/session.test.ts @@ -0,0 +1,89 @@ +import {describe, expect, test} from "bun:test"; +import {isSessionId, runSession, sessionId} from "../src/framework/session"; +import {sessionHarness} from "./helpers/session"; + + +describe("session ids", () => { + test("are namespaced so the dispatcher can tell them apart", () => { + expect(isSessionId(sessionId("next"))).toBe(true); + expect(isSessionId("selfroles.open:user")).toBe(false); + }); +}); + + +describe("runSession", () => { + const counter = (harness: ReturnType) => runSession({ + interaction: harness.interaction, + initial: 1, + render: (n, {ended}) => ({content: `n=${n} ended=${ended}`}), + reduce: (action, n) => action === "inc" ? n + 1 : action === "dec" ? n - 1 : undefined + }); + + test("renders the initial state immediately", async () => { + const harness = sessionHarness(); + const done = counter(harness); + await harness.press("noop"); + expect(harness.shown[0]).toEqual({content: "n=1 ended=false"}); + await harness.end(); + await done; + }); + + test("applies each action in order", async () => { + const harness = sessionHarness(); + const done = counter(harness); + await harness.press("inc"); + await harness.press("inc"); + await harness.press("dec"); + await harness.end(); + expect(await done).toBe(2); + }); + + test("an unrecognised action acknowledges without changing state", async () => { + const harness = sessionHarness(); + const done = counter(harness); + const before = harness.shown.length; + await harness.press("mystery"); + expect(harness.shown).toHaveLength(before); + await harness.end(); + expect(await done).toBe(1); + }); + + /** + * The old Paginator assigned `this.buttonInteraction = i` before checking + * the user, so anyone could redirect someone else's menu. + */ + test("a different user cannot drive the menu", async () => { + const harness = sessionHarness("owner"); + const done = counter(harness); + await harness.press("inc"); + + const refusals = await harness.press("inc", "someone-else"); + expect(String(refusals[0]?.content)).toContain("belongs to someone else"); + + await harness.end(); + expect(await done).toBe(2); + }); + + test("audience:anyone opts out of the ownership check", async () => { + const harness = sessionHarness("owner"); + const done = runSession({ + interaction: harness.interaction, + initial: 0, + audience: "anyone", + render: n => ({content: `n=${n}`}), + reduce: (action, n) => action === "inc" ? n + 1 : undefined + }); + expect(await harness.press("inc", "a-stranger")).toEqual([]); + await harness.end(); + expect(await done).toBe(1); + }); + + test("the final render is marked ended so controls can be disabled", async () => { + const harness = sessionHarness(); + const done = counter(harness); + await harness.press("inc"); + await harness.end(); + await done; + expect(harness.shown.at(-1)).toEqual({content: "n=2 ended=true"}); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f93fd39..6aefde0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "verbatimModuleSyntax": true, "noEmit": true, "resolveJsonModule": true, + "types": ["bun"], // Best practices "strict": true, From 28bf9d13881c9c03a5b682f6da9bb9d54ec91d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:35:03 +0000 Subject: [PATCH 18/22] Convert addon rendering to plain component data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit util/addons.ts was the last file building components with the discord.js builder pattern โ€” the thing this whole refactor started from. Its four render functions now return plain data like everything else: createAddonComponent -> ContainerComponentData createAddonSection -> SectionComponentData createAddonList -> [TextDisplayComponentData, ContainerComponentData] createNavigation -> ActionRowData The `new ContainerBuilder().addSectionComponents(new SectionBuilder()...)` chain that the original review used as the argument against builders is gone. Two small local helpers (separator, thumbnail) plus the existing row / container / text cover the rest. Verified by rendering both versions of all four functions and diffing the API JSON, normalising the camelCase/snake_case difference between the data interfaces and the builders' output: a full addon page, the same page with a support-server button, a section, a two-addon list, and the navigation menu in both selected and disabled states are all identical, 6/6. The only EmbedBuilder left in src/ is /about, which keeps it deliberately and says why. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/util/addons.ts | 135 ++++++++++++++++++++++++--------------------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/src/util/addons.ts b/src/util/addons.ts index b53bc82..cecd316 100644 --- a/src/util/addons.ts +++ b/src/util/addons.ts @@ -1,4 +1,10 @@ -import {ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, ContainerBuilder, MessageFlags, SectionBuilder, SeparatorBuilder, SeparatorSpacingSize, StringSelectMenuBuilder, StringSelectMenuInteraction, StringSelectMenuOptionBuilder, TextDisplayBuilder, ThumbnailBuilder} from "discord.js"; +import { + ButtonStyle, ChatInputCommandInteraction, ComponentType, MessageFlags, SeparatorSpacingSize, + StringSelectMenuInteraction, + type ActionRowData, type ComponentInContainerData, type ContainerComponentData, + type MessageActionRowComponentData, type SectionComponentData, type TextDisplayComponentData +} from "discord.js"; +import {container, row, text} from "../framework"; import type {BdWebAddon} from "../types"; import Web from "../util/web"; @@ -72,86 +78,91 @@ export function sortAddons(addons: BdWebAddon[], sortBy: "likes" | "downloads" | } -export function createAddonComponent(addon: BdWebAddon) { +const separator = (spacing: SeparatorSpacingSize, divider: boolean): ComponentInContainerData => + ({type: ComponentType.Separator, spacing, divider}); - const buttons = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("View Online") - .setURL(Web.pages[addon.type](addon.name)), - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Download Now") - .setURL(Web.redirects.download(addon.id.toString())), - ); +const thumbnail = (url: string) => ({type: ComponentType.Thumbnail as const, media: {url}}); + +/** The link buttons every addon carries, plus a support server when there is one. */ +function addonLinks(addon: BdWebAddon): MessageActionRowComponentData[] { + const buttons: MessageActionRowComponentData[] = [ + {type: ComponentType.Button, style: ButtonStyle.Link, label: "View Online", url: Web.pages[addon.type](addon.name)}, + {type: ComponentType.Button, style: ButtonStyle.Link, label: "Download Now", url: Web.redirects.download(addon.id.toString())} + ]; if (addon.author.guild?.invite_link) { - buttons.addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Support Server") - .setURL(addon.author.guild.invite_link), - ); + buttons.push({type: ComponentType.Button, style: ButtonStyle.Link, label: "Support Server", url: addon.author.guild.invite_link}); } - const page = new ContainerBuilder() - .addSectionComponents( - new SectionBuilder() - .setThumbnailAccessory( - new ThumbnailBuilder().setURL(Web.resources.thumbnail(addon.thumbnail_url)) - ) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent(`# ${addon.name} v${addon.version}`), - new TextDisplayBuilder().setContent(addon.description ?? "No description provided."), - new TextDisplayBuilder().setContent(addon.tags.map(tag => `\`${tag}\``).join(" ")), - ), - ) - .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small).setDivider(false)) - .addTextDisplayComponents(new TextDisplayBuilder().setContent(`๐Ÿ‘ ${addon.likes.toLocaleString()} Likes โฌ‡๏ธ ${addon.downloads.toLocaleString()} Downloads`)) - .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true)) - .addActionRowComponents(buttons) - .addTextDisplayComponents(new TextDisplayBuilder().setContent(`-# Updated ${new Date(addon.latest_release_date).toLocaleDateString()} โ€ข Released ${new Date(addon.initial_release_date).toLocaleDateString()}`)); - - return page; + return buttons; } -export function createAddonSection(addon: BdWebAddon) { +/** One addon rendered in full, as its own page. */ +export function createAddonComponent(addon: BdWebAddon): ContainerComponentData { + const details: TextDisplayComponentData[] = [ + {type: ComponentType.TextDisplay, content: `# ${addon.name} v${addon.version}`}, + {type: ComponentType.TextDisplay, content: addon.description ?? "No description provided."}, + {type: ComponentType.TextDisplay, content: addon.tags.map(tag => `\`${tag}\``).join(" ")} + ]; + + return container([ + { + type: ComponentType.Section, + components: details, + accessory: thumbnail(Web.resources.thumbnail(addon.thumbnail_url)) + }, + separator(SeparatorSpacingSize.Small, false), + text(`๐Ÿ‘ ${addon.likes.toLocaleString()} Likes โฌ‡๏ธ ${addon.downloads.toLocaleString()} Downloads`), + separator(SeparatorSpacingSize.Large, true), + row(...addonLinks(addon)), + text(`-# Updated ${new Date(addon.latest_release_date).toLocaleDateString()} โ€ข Released ${new Date(addon.initial_release_date).toLocaleDateString()}`) + ]); +} + + +/** One addon as a compact row within a list. */ +export function createAddonSection(addon: BdWebAddon): SectionComponentData { const links = [ `[View Online](${Web.pages[addon.type](addon.name)})`, `[Download Now](${Web.redirects.download(addon.id.toString())})`, - addon.author.guild?.invite_link && `[Support Server](${addon.author.guild?.invite_link})` + addon.author.guild?.invite_link && `[Support Server](${addon.author.guild.invite_link})` ].filter(Boolean).join(" โ€ข "); - const section = new SectionBuilder() - .setThumbnailAccessory(new ThumbnailBuilder().setURL(Web.resources.thumbnail(addon.thumbnail_url))) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent(`### ${addon.name}`), - new TextDisplayBuilder().setContent(addon.description ?? "No description provided."), - new TextDisplayBuilder().setContent(links), - ); - - return section; + return { + type: ComponentType.Section, + components: [ + {type: ComponentType.TextDisplay, content: `### ${addon.name}`}, + {type: ComponentType.TextDisplay, content: addon.description ?? "No description provided."}, + {type: ComponentType.TextDisplay, content: links} + ], + accessory: thumbnail(Web.resources.thumbnail(addon.thumbnail_url)) + }; } -export function createAddonList(title: string, addons: BdWebAddon[]) { - const page = new ContainerBuilder(); + +export function createAddonList(title: string, addons: BdWebAddon[]): [TextDisplayComponentData, ContainerComponentData] { + const body: ComponentInContainerData[] = []; for (const [index, addon] of addons.entries()) { - page.addSectionComponents(createAddonSection(addon)); - if (index < addons.length - 1) page.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true)); + body.push(createAddonSection(addon)); + if (index < addons.length - 1) body.push(separator(SeparatorSpacingSize.Large, true)); } - return [new TextDisplayBuilder().setContent(`## ${title}`), page]; + + return [{type: ComponentType.TextDisplay, content: `## ${title}`}, container(body)]; } -export function createNavigation(addons: BdWebAddon[], selectedIndex = 0, disabled = false) { - const navigation = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder().setCustomId(`addons-navigation`).addOptions( - ...addons.map((addon, index) => new StringSelectMenuOptionBuilder().setLabel(`${index + 1}. ${addon.name}`).setValue(addon.name).setDefault(index === selectedIndex)) - ) - .setDisabled(disabled) - ); - return navigation; + +export function createNavigation(addons: BdWebAddon[], selectedIndex = 0, disabled = false): ActionRowData { + return row({ + type: ComponentType.StringSelect, + customId: "addons-navigation", + disabled, + options: addons.map((addon, index) => ({ + "label": `${index + 1}. ${addon.name}`, + "value": addon.name, + "default": index === selectedIndex + })) + }); } From 3829be4cf0976af08679c95525b1c0052ab5a8a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:39:38 +0000 Subject: [PATCH 19/22] Rebuild the addon browser on runSession; no hand-rolled collectors left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paginateAddonPages was the last hand-rolled component collector: it repeated the ownership check, the timeout and the disable-on-end pass that framework/session.ts already does. It is now a runSession whose state is the selected index, and its select menu carries a session-owned custom id so the dispatcher leaves it to the collector. Three bugs fell out of the conversion. 1. `/addons search` with no matches threw. string-similarity's findBestMatch rejects an empty candidate list ("Bad arguments"), so a search that matched nothing surfaced as the dispatcher's generic "Something went wrong" rather than "no results". It now answers before ranking. 2. An empty addon list built an illegal select menu. Discord requires between 1 and 25 options; createNavigation([]) produced zero, so `/addons top` and friends would have been rejected at send time whenever the cache was empty โ€” the same shape as the selfroles setMaxValues(0) crash. paginateAddonPages now answers with a notice instead, and callers pass a fitting message. 3. `/addons random` on an empty cache indexed past the end and rendered `undefined`. Guarded. The selection handler also loses `addons.find(...)!`; an unrecognised value now leaves the selection alone rather than asserting the lookup cannot fail. Two follow-ons: - paginateAddonPages takes a RepliableInteraction rather than a ChatInputCommandInteraction. It only defers and edits, so the tighter type was claiming more than the code uses. - tags.ts was still calling awaitModalSubmit directly inside a try/catch that treated any throw as a timeout โ€” including a database failure, which the user would have been told was a submission timeout. It uses the framework's awaitModal now, which returns null only on timeout. src/ no longer contains a hand-rolled collector or modal wait. 16 new tests cover the browser (rendering, selection moving the tick and the page, an unknown value being ignored, ownership, disable-on-end, V2 flags on every render), list separators, sorting, and the conditional support-server button. Writing them exposed two gaps in the shared session harness โ€” no select-menu type guards and no collector.stop() โ€” both now fixed, so the harness can drive select menus and exercise runSession's error path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/addons.ts | 23 +++-- src/commands/tags.ts | 35 +++----- src/util/addons.ts | 63 +++++++------ tests/addons.test.ts | 188 +++++++++++++++++++++++++++++++++++++++ tests/helpers/session.ts | 26 +++++- tests/session.test.ts | 4 +- 6 files changed, 282 insertions(+), 57 deletions(-) create mode 100644 tests/addons.test.ts diff --git a/src/commands/addons.ts b/src/commands/addons.ts index 38664bf..ffb6d98 100644 --- a/src/commands/addons.ts +++ b/src/commands/addons.ts @@ -46,29 +46,40 @@ async function browse(interaction: ChatInputCommandInteraction) { async function search(interaction: ChatInputCommandInteraction) { const name = interaction.options.getString("name", true).toLowerCase(); - let results: BdWebAddon[] = []; + const results: BdWebAddon[] = []; for (const addon of cache) { if (addon.name.toLowerCase().includes(name) || (addon.description?.toLowerCase().includes(name))) { results.push(addon); } } - results = Similarity.findBestMatch(name, results.map(a => a.name)).ratings + // findBestMatch throws on an empty candidate list, so a search that matched + // nothing used to surface as the dispatcher's generic error. + if (!results.length) { + return await interaction.editReply(notices.info(`No addons matched \`${name}\`.`)); + } + + const ranked = Similarity.findBestMatch(name, results.map(addon => addon.name)).ratings .sort((a, b) => b.rating - a.rating) .slice(0, 10) - .map(rating => results.find(a => a.name === rating.target)!) - .filter(a => !!a); + .map(rating => results.find(addon => addon.name === rating.target)) + .filter((addon): addon is BdWebAddon => addon !== undefined); - await paginateAddonPages(interaction, results); + await paginateAddonPages(interaction, ranked, `No addons matched \`${name}\`.`); } async function top10(interaction: ChatInputCommandInteraction, sortBy: "likes" | "downloads" | "initial_release_date" | "latest_release_date") { - await paginateAddonPages(interaction, sortAddons(Array.from(cache), sortBy).slice(0, 10)); + await paginateAddonPages(interaction, sortAddons(Array.from(cache), sortBy).slice(0, 10), "The addon store is empty right now. Please try again shortly."); } async function random(interaction: ChatInputCommandInteraction) { const addonsArray = Array.from(cache); + // An empty cache would otherwise index past the end and render `undefined`. + if (!addonsArray.length) { + return await interaction.editReply(notices.info("The addon store is empty right now. Please try again shortly.")); + } + const randomAddon = addonsArray[Math.floor(Math.random() * addonsArray.length)]; return await interaction.editReply({components: [createAddonComponent(randomAddon)], flags: MessageFlags.IsComponentsV2}); } diff --git a/src/commands/tags.ts b/src/commands/tags.ts index 1e40708..effacc7 100644 --- a/src/commands/tags.ts +++ b/src/commands/tags.ts @@ -3,7 +3,7 @@ import { AutocompleteInteraction, ChatInputCommandInteraction, ComponentType, InteractionContextType, MessageFlags, type RESTPostAPIChatInputApplicationCommandsJSONBody } from "discord.js"; -import {defineCommand} from "../framework"; +import {awaitModal, defineCommand} from "../framework"; import type {AtLeast, Tag} from "../types"; import {tagsDB} from "../db"; import {msInMinute} from "../util/time"; @@ -108,28 +108,21 @@ async function list(interaction: ChatInputCommandInteraction<"cached">) { async function showTagModal(interaction: ChatInputCommandInteraction<"cached">, tag: AtLeast) { const isUpdating = !!tag.content; - await interaction.showModal(updateTagModal(tag)); + // awaitModal returns null only on timeout, so a database failure below is no + // longer reported to the user as "submission timed out". + const submitted = await awaitModal(interaction, updateTagModal(tag), ["title", "content", "thumbnail"], {time: msInMinute * 5}); + if (!submitted) return await interaction.followUp(error("Modal submission timed out!")); - try { - const modalInteraction = await interaction.awaitModalSubmit({time: msInMinute * 5}); - const title = modalInteraction.fields.getTextInputValue("title"); - const content = modalInteraction.fields.getTextInputValue("content"); - const thumbnailUrl = modalInteraction.fields.getTextInputValue("thumbnail"); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + guildTags[tag.name] = { + name: tag.name, + title: submitted.values.title || undefined, + content: submitted.values.content, + thumbnailUrl: submitted.values.thumbnail || undefined + }; + await tagsDB.set(interaction.guildId, guildTags); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - guildTags[tag.name] = { - name: tag.name, - title: title || undefined, - content, - thumbnailUrl: thumbnailUrl || undefined, - }; - await tagsDB.set(interaction.guildId, guildTags); - - await modalInteraction.reply(success(`Tag \`${tag.name}\` has been ${isUpdating ? "updated" : "created"} successfully!`)); - } - catch { - await interaction.followUp(error("Modal submission timed out!")); - } + await submitted.submission.reply(success(`Tag \`${tag.name}\` has been ${isUpdating ? "updated" : "created"} successfully!`)); } diff --git a/src/util/addons.ts b/src/util/addons.ts index cecd316..6ad5392 100644 --- a/src/util/addons.ts +++ b/src/util/addons.ts @@ -1,10 +1,11 @@ import { - ButtonStyle, ChatInputCommandInteraction, ComponentType, MessageFlags, SeparatorSpacingSize, - StringSelectMenuInteraction, + ButtonStyle, ComponentType, MessageFlags, SeparatorSpacingSize, type ActionRowData, type ComponentInContainerData, type ContainerComponentData, - type MessageActionRowComponentData, type SectionComponentData, type TextDisplayComponentData + type MessageActionRowComponentData, type RepliableInteraction, type SectionComponentData, + type TextDisplayComponentData } from "discord.js"; -import {container, row, text} from "../framework"; +import {container, row, runSession, sessionId, text} from "../framework"; +import * as notices from "./notices"; import type {BdWebAddon} from "../types"; import Web from "../util/web"; @@ -152,10 +153,13 @@ export function createAddonList(title: string, addons: BdWebAddon[]): [TextDispl } +/** The select menu action, as carried in its session-owned custom id. */ +const NAVIGATE = "addons-navigate"; + export function createNavigation(addons: BdWebAddon[], selectedIndex = 0, disabled = false): ActionRowData { return row({ type: ComponentType.StringSelect, - customId: "addons-navigation", + customId: sessionId(NAVIGATE), disabled, options: addons.map((addon, index) => ({ "label": `${index + 1}. ${addon.name}`, @@ -166,28 +170,37 @@ export function createNavigation(addons: BdWebAddon[], selectedIndex = 0, disabl } -export async function paginateAddonPages(interaction: ChatInputCommandInteraction, addons: BdWebAddon[]) { - const navigation = createNavigation(addons); - const pages = addons.map(addon => createAddonComponent(addon)); +/** + * An addon browser: a select menu that swaps which addon is shown. + * + * Built on runSession, so the ownership check, the timeout and disabling the + * menu when it expires are the framework's job rather than this file's. + */ +export async function paginateAddonPages(interaction: RepliableInteraction, addons: BdWebAddon[], emptyMessage = "No addons matched.") { + // A select menu needs between 1 and 25 options; Discord rejects an empty + // one, so an empty result set has to be answered rather than rendered. + if (!addons.length) { + await interaction.editReply(notices.info(emptyMessage)); + return; + } - const msg = await interaction.fetchReply(); - const collector = msg.createMessageComponentCollector({time: 5 * msInMinute}); + await runSession({ + interaction, + initial: 0, + timeout: 5 * msInMinute, - let selectedIndex = 0; - collector.on("collect", async (i: StringSelectMenuInteraction) => { - if (i.user.id !== interaction.user.id) return await i.reply({content: "You cannot interact with this menu.", flags: MessageFlags.Ephemeral}); + render: (selectedIndex, {ended}) => ({ + flags: MessageFlags.IsComponentsV2, + components: [createNavigation(addons, selectedIndex, ended), createAddonComponent(addons[selectedIndex])] + }), - const selectedAddonName = i.values[0]; - const selectedAddon = addons.find(a => a.name === selectedAddonName)!; - selectedIndex = addons.indexOf(selectedAddon); - const newPage = pages[selectedIndex]; - const newNavigation = createNavigation(addons, selectedIndex); - await i.update({components: [newNavigation, newPage], flags: MessageFlags.IsComponentsV2}); - }); + reduce(action, _selectedIndex, component) { + if (action !== NAVIGATE || !component.isStringSelectMenu()) return undefined; - collector.on("end", async () => { - await interaction.editReply({components: [createNavigation(addons, selectedIndex, true), pages[selectedIndex]], flags: MessageFlags.IsComponentsV2}); + // An unrecognised value leaves the state alone instead of throwing; + // the previous version asserted the lookup could not fail. + const next = addons.findIndex(addon => addon.name === component.values[0]); + return next === -1 ? undefined : next; + } }); - - await interaction.editReply({components: [navigation, pages[0]], flags: MessageFlags.IsComponentsV2}); -} \ No newline at end of file +} diff --git a/tests/addons.test.ts b/tests/addons.test.ts new file mode 100644 index 0000000..3b4e9b0 --- /dev/null +++ b/tests/addons.test.ts @@ -0,0 +1,188 @@ +import {describe, expect, test} from "bun:test"; + +import {createAddonComponent, createAddonList, createNavigation, paginateAddonPages, sortAddons} from "../src/util/addons"; +import {isSessionId} from "../src/framework"; +import type {BdWebAddon} from "../src/types"; +import {sessionHarness} from "./helpers/session"; + + +function addon(over: Partial = {}): BdWebAddon { + return { + id: 7, + name: "CoolPlugin", + file_name: "c.plugin.js", + type: "plugin", + description: "Does things", + version: "1.2.3", + likes: 1234, + downloads: 56789, + tags: [], + thumbnail_url: "/resources/x.png", + latest_source_url: "u", + initial_release_date: new Date("2020-01-02T00:00:00Z"), + latest_release_date: new Date("2024-03-04T00:00:00Z"), + author: { + github_id: "1", + github_name: "g", + display_name: "d", + discord_name: "dn", + discord_avatar_hash: null, + discord_snowflake: "1", + guild: null + }, + guild: null, + ...over + }; +} + +const IS_COMPONENTS_V2 = 1 << 15; + +interface Row {type: number; components: Array<{options?: Array<{label: string; value: string; default: boolean}>; disabled?: boolean}>} +const navOf = (shown: Record) => (shown.components as unknown[])[0] as Row; +const menu = (shown: Record) => navOf(shown).components[0]; + + +describe("navigation menu", () => { + const list = [addon({name: "Alpha"}), addon({name: "Beta"}), addon({name: "Gamma"})]; + + test("is session-owned so the dispatcher leaves it alone", () => { + const control = createNavigation(list).components[0] as {customId: string}; + expect(isSessionId(control.customId)).toBe(true); + }); + + test("numbers the options and marks the selected one", () => { + const options = menu({components: [createNavigation(list, 1)]}).options ?? []; + expect(options.map(option => option.label)).toEqual(["1. Alpha", "2. Beta", "3. Gamma"]); + expect(options.map(option => option.default)).toEqual([false, true, false]); + }); +}); + + +describe("addon browser", () => { + const list = [addon({name: "Alpha"}), addon({name: "Beta"}), addon({name: "Gamma"})]; + + function browse(addons: BdWebAddon[]) { + const harness = sessionHarness(); + const done = paginateAddonPages(harness.interaction, addons); + return {harness, done, latest: () => harness.shown.at(-1) ?? {}}; + } + + test("an empty list answers instead of building an illegal select menu", async () => { + // Discord rejects a string select with zero options. + const {harness, done} = browse([]); + await done; + const shown = harness.shown.at(-1) ?? {}; + expect(Number(shown.flags) & IS_COMPONENTS_V2).toBe(IS_COMPONENTS_V2); + expect(JSON.stringify(shown)).toContain("No addons matched"); + }); + + test("renders the first addon with its menu", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Alpha"]}); + const options = menu(harness.shown[0]).options ?? []; + expect(options).toHaveLength(3); + expect(options[0]?.default).toBe(true); + await harness.end(); + await done; + }); + + test("the menu is disabled once the session ends", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Alpha"]}); + await harness.end(); + await done; + expect(menu(harness.shown.at(-1) ?? {}).disabled).toBe(true); + }); + + test("every render carries IsComponentsV2", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Alpha"]}); + await harness.end(); + await done; + expect(harness.shown.every(shown => Number(shown.flags) === IS_COMPONENTS_V2)).toBe(true); + }); + + test("a stranger cannot drive the browser", async () => { + const {harness, done} = browse(list); + const refusals = await harness.press("addons-navigate", {userId: "someone-else", values: ["Beta"]}); + expect(String(refusals[0]?.content)).toContain("belongs to someone else"); + await harness.end(); + await done; + }); + + test("selecting an addon swaps the page and moves the tick", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Gamma"]}); + + const options = menu(harness.shown.at(-1) ?? {}).options ?? []; + expect(options.map(option => option.default)).toEqual([false, false, true]); + expect(JSON.stringify(harness.shown.at(-1))).toContain("# Gamma v1.2.3"); + + await harness.end(); + await done; + }); + + // The previous version did `addons.find(...)!` and would have thrown. + test("an unknown value leaves the selection alone", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["NoSuchAddon"]}); + const options = menu(harness.shown.at(-1) ?? {}).options ?? []; + expect(options.map(option => option.default)).toEqual([true, false, false]); + await harness.end(); + await done; + }); +}); + + +describe("list rendering", () => { + test("separates entries but does not trail one", () => { + const [, page] = createAddonList("Plugins", [addon({name: "A"}), addon({name: "B"})]); + const types = (page.components as Array<{type: number}>).map(component => component.type); + expect(types).toEqual([9, 14, 9]); + }); + + test("a single entry gets no separator", () => { + const [, page] = createAddonList("Plugins", [addon()]); + expect(page.components).toHaveLength(1); + }); + + test("the heading is a separate top-level text display", () => { + const [heading] = createAddonList("Plugins sorted by downloads", [addon()]); + expect(heading.content).toBe("## Plugins sorted by downloads"); + }); +}); + + +describe("sorting", () => { + test("orders by the numeric field, descending", () => { + const list = [addon({name: "a", likes: 1}), addon({name: "b", likes: 9}), addon({name: "c", likes: 5})]; + expect(sortAddons(list, "likes").map(a => a.name)).toEqual(["b", "c", "a"]); + }); + + test("orders by date, newest first", () => { + const list = [ + addon({name: "old", latest_release_date: new Date("2020-01-01T00:00:00Z")}), + addon({name: "new", latest_release_date: new Date("2024-01-01T00:00:00Z")}) + ]; + expect(sortAddons(list, "latest_release_date").map(a => a.name)).toEqual(["new", "old"]); + }); +}); + + +describe("addon page", () => { + test("adds a support-server button only when the author has a guild", () => { + const withoutGuild = createAddonComponent(addon()); + const withGuild = createAddonComponent(addon({ + author: {...addon().author, guild: {name: "G", snowflake: "1", invite_link: "https://discord.gg/abc"}} + })); + const labels = (page: {components: readonly unknown[]}) => + JSON.stringify(page.components).match(/"label":"[^"]+"/g) ?? []; + expect(labels(withoutGuild)).toHaveLength(2); + expect(labels(withGuild)).toHaveLength(3); + }); + + test("falls back when an addon has no description", () => { + const page = createAddonComponent(addon({description: undefined as unknown as string})); + expect(JSON.stringify(page)).toContain("No description provided."); + }); +}); diff --git a/tests/helpers/session.ts b/tests/helpers/session.ts index 922a583..d34a3d2 100644 --- a/tests/helpers/session.ts +++ b/tests/helpers/session.ts @@ -7,17 +7,27 @@ import {EventEmitter} from "node:events"; import type {RepliableInteraction} from "discord.js"; +export interface PressOptions { + userId?: string; + /** Present for a select menu; its absence makes the stub a button. */ + values?: string[]; +} + export interface SessionHarness { interaction: RepliableInteraction; /** Every payload a viewer would have seen, from editReply or update. */ shown: Array>; - press(action: string, userId?: string): Promise>>; + /** Returns anything the component replied with, e.g. an ownership refusal. */ + press(action: string, options?: PressOptions): Promise>>; end(): Promise; } export function sessionHarness(ownerId = "owner"): SessionHarness { - const collector = new EventEmitter(); + const collector = Object.assign(new EventEmitter(), { + // runSession calls stop() on its error path. + stop: () => {collector.emit("end");} + }); const shown: Array> = []; const interaction = { @@ -47,14 +57,24 @@ export function sessionHarness(ownerId = "owner"): SessionHarness { interaction: interaction as unknown as RepliableInteraction, shown, - async press(action, userId = ownerId) { + async press(action, {userId = ownerId, values}: PressOptions = {}) { await whenListening(); const refusals: Array> = []; + const isSelect = values !== undefined; const component = { customId: `~${action}`, user: {id: userId}, + values: values ?? [], replied: false, deferred: false, + + isButton: () => !isSelect, + isStringSelectMenu: () => isSelect, + isRoleSelectMenu: () => false, + isUserSelectMenu: () => false, + isChannelSelectMenu: () => false, + isMentionableSelectMenu: () => false, + reply: (payload: Record) => {refusals.push(payload); return Promise.resolve();}, update: (payload: Record) => {shown.push(payload); return Promise.resolve();}, deferUpdate: () => Promise.resolve() diff --git a/tests/session.test.ts b/tests/session.test.ts index e8a7032..8a9f662 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -57,7 +57,7 @@ describe("runSession", () => { const done = counter(harness); await harness.press("inc"); - const refusals = await harness.press("inc", "someone-else"); + const refusals = await harness.press("inc", {userId: "someone-else"}); expect(String(refusals[0]?.content)).toContain("belongs to someone else"); await harness.end(); @@ -73,7 +73,7 @@ describe("runSession", () => { render: n => ({content: `n=${n}`}), reduce: (action, n) => action === "inc" ? n + 1 : undefined }); - expect(await harness.press("inc", "a-stranger")).toEqual([]); + expect(await harness.press("inc", {userId: "a-stranger"})).toEqual([]); await harness.end(); expect(await done).toBe(1); }); From c027fddcea87ce2e5385ddf111b6f2eeb2af00ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:41:27 +0000 Subject: [PATCH 20/22] Add CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orientation for future sessions: commands, layout, the conventions the refactor settled on, and a footgun list. The footgun section is the part worth having. Every entry caused a real, shipped bug in this repo โ€” stateful /g regexes, a message that cannot change between embed and Components V2 mode, `update()` only working once, select menus needing at least one option, MessageFlags widening in an unannotated literal, caches that clear before they fetch. They are recorded with the symptom rather than just the rule, so the next person recognises the failure rather than having to rediscover the cause. Also records what is deliberately not done, so it does not get "fixed": /about keeps its embed because Components V2 has no inline field grid, util/web.ts keeps its ids because they are upstream website data, and the invite whitelist stays hardcoded because making it configurable is a feature decision rather than cleanup. Every factual claim was checked against the tree: script names, exported helper names, the test count, the pinned Bun version, and the three "deliberately left alone" items. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- CLAUDE.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..40f48bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,161 @@ +# BetterDiscordBot + +Discord bot for the BetterDiscord community. Bun + TypeScript + discord.js 14, +Keyv over SQLite for storage. Private to BD โ€” not publicly invitable. + +## Commands + +``` +bun run start # run the bot +bun run deploy # register slash commands (skips if unchanged; --force overrides) +bun run clear # deregister everything +bun run validate # check required environment variables +bun test # 96 tests +bun run typecheck # tsc --noEmit +bun run lint # eslint . +``` + +CI runs `bun test`, `bun run typecheck` and `bun run lint`. All three must pass. +Run them before committing โ€” the type-aware lint rules apply to tests too, and +several genuine bugs have surfaced from `lint` rather than `tsc`. + +Bun is **pinned to 1.4.0** in `.github/workflows/ci.yml`. `bun.lock` is version +sensitive; if you change dependencies, regenerate the lockfile with the same Bun +version CI uses, or `--frozen-lockfile` fails there and not locally. + +## Layout + +``` +src/framework/ command / component / event plumbing (see its own README) +src/commands/ one file per slash command +src/components/ reusable message pieces +src/events/ one file per gateway listener (may export several) +src/util/ notices, modlog, colors, addons, names, stats, time, web +src/config.ts every Discord snowflake, env-overridable +tests/ bun test; helpers/ has the interaction and session stubs +``` + +`src/index.ts` builds the dispatcher at startup, so a malformed command or a +duplicate component namespace fails at boot rather than on first use. + +## Conventions + +**Plain object literals, never builders.** discord.js's data interfaces +(`ContainerComponentData`, `ActionRowData`, `RESTPostAPIChatInputApplicationCommandsJSONBody`โ€ฆ) +are fully typed and infer correctly. `SlashCommandBuilder` and `ContainerBuilder` +are not used anywhere. There was a JSX layer (`djsx/`) here until recently; it +was removed because TypeScript has one global `JSX.Element` type, so every +expression needed an `as` cast and the type checker stopped helping at exactly +the boundary where mistakes are expensive. Don't reintroduce it. + +**Components V2, not embeds.** Status messages go through +`src/util/notices.ts` (`success` / `info` / `warn` / `error` / `danger`), +moderation logs through `src/util/modlog.ts`. `/about` is the single deliberate +exception and says why in a comment: its stats are inline fields three to a row, +and V2 has no field grid. + +**Two interaction mechanisms, chosen by lifetime.** Read +`src/framework/README.md` before touching interaction code. Short version: + +- Must survive a restart or outlive the 15-minute token โ†’ `defineComponent`, + with state encoded in a typed custom id. +- Belongs to one invocation by one user โ†’ `runSession` (or `awaitModal`). + +Never hand-roll a collector. The ownership check, the timeout and the +disable-on-end pass live in `src/framework/session.ts` and nowhere else. + +**Config, not literals.** Snowflakes belong in `src/config.ts`, which allows an +env override per entry. `src/util/web.ts` keeps its release-channel ids โ€” that +is upstream BetterDiscord website data, not deployment config. + +**Style.** 4 spaces, double quotes, `{noSpaces}` inside braces, semicolons. +Match the file you're in. + +## Footguns + +Every one of these caused a real, shipped bug in this repo. + +**`RegExp.prototype.test` with a `/g` flag is stateful.** It advances +`lastIndex` and resumes there next call, so a shared module-level regex returns +alternating answers for the same input. Use `/g` only with `matchAll` or +`String.match`. See `src/util/names.ts`. + +**A message cannot switch between embed mode and Components V2 mode after it is +created.** If a flow replies one way and updates the other, Discord rejects it. +Convert a whole flow at once or not at all. + +**`interaction.update()` works once.** A second call throws +`InteractionAlreadyReplied`. Use `editReply()` for subsequent edits โ€” a +long-running handler that updates then updates again will silently never show +its result. + +**`editReply()` before any defer or reply throws.** Handlers that end in +`showModal()` can never defer, so their early exits must `reply()`. + +**Select menus need between 1 and 25 options**, and `setMaxValues(0)` is +invalid. Always guard the empty case before rendering a menu. + +**`MessageFlags.IsComponentsV2` widens to the whole `MessageFlags` enum** in an +unannotated object literal, which is not assignable to discord.js's narrower +per-method flag unions. Annotate with `ComponentMessage` from +`src/framework/ui.ts`, whose `flags` is `number` (assignable to a numeric enum). + +**Mixed-type component arrays need their element type pinned.** TypeScript +infers a union of object literals, fails to match a branch of the `components` +union, falls through to the snake_case API branch, and produces a 30-line +unreadable error. Use `row()` / `container()` / `text()` from +`src/framework/ui.ts`. These are annotations, not casts. + +**`TextInputComponentData` still requires `label`** even inside a `Label` +component, where the API ignores it. `src/components/tags.ts` absorbs this in +one helper; the payload deliberately omits it to match what ships. + +**Custom ids are capped at 100 characters.** `customId()` throws rather than +letting the API reject the message. Store a payload and reference it by key if +you need more. + +**`defineCommand` rejects extra properties**, so subcommand handlers must be +module-level functions, not methods called through `this`. + +**`string-similarity`'s `findBestMatch` throws on an empty candidate array.** +Guard before ranking. + +**Refresh caches by fetching into a local, then swapping.** Clearing and +stamping a timestamp before the request means one failure leaves an empty cache +that will not retry. See `ensureCache` in `src/util/addons.ts`. + +## Testing + +`bun test`. Two shared harnesses keep tests free of gateway or network setup: + +- `tests/helpers/interactions.ts` โ€” stubs the type guards and reply methods the + dispatcher actually calls. +- `tests/helpers/session.ts` โ€” stands in for the message component collector. + `press(action, {userId, values})` delivers a click; omit `values` for a + button, pass them for a select menu. + +The session harness waits for `runSession` to attach its collector before +emitting. A press issued immediately after starting a session lands before the +listener exists โ€” that is the shape of a flake here, and it has twice turned out +to be the harness at fault rather than the code. Suspect the harness first. + +`tests/commands.test.ts` snapshots every deployed command payload against +`tests/fixtures/command-payloads.json`. Refactors should leave it untouched. +When a command change is intended: + +``` +bun run tests/fixtures/regenerate-payloads.ts +``` + +and review the resulting diff โ€” that diff is the point of the fixture. + +`tests/regressions.test.ts` has one test per bug fixed during the refactor, each +naming the failure it guards against. Add to it when you fix something silent. + +## Deliberately left alone + +- `/about` keeps its `EmbedBuilder` (see above). +- The invite whitelist in `src/events/invitefilter.ts` is still a hardcoded + array with a `TODO`. Making it configurable is a feature decision. +- `src/commands/debug.tsx` is gitignored. If a local copy exists it predates the + djsx removal and will not load. From 4180a4fceedc10d0c4f2e5d8ad7aeb853173b05c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 23:43:33 +0000 Subject: [PATCH 21/22] Open the SQLite store lazily; fixes intermittent CI aborts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed on the PR with every test passing: 96 pass / 0 fail panic: NAPI FATAL ERROR: Error::ThrowAsJavaScriptException napi_throw Aborted (core dumped) bun test -> exit 134 NAPI means a native addon, which here is sqlite3. Every command module imports src/db transitively, and the store was constructed at import, so `bun test` opened a real database purely to read command metadata. The addon then intermittently aborted the process during teardown, after the suite had already succeeded. It is a race: the push-event run on the same commit passed and the pull_request-event run failed, and 15 local runs did not reproduce it. Probed what actually opens the connection: importing @keyv/sqlite does not, `new Sqlite(uri)` does. So the store and its Keyv instances are now built on first use behind a small proxy, and the exported names are unchanged โ€” no call site moves. Nothing in the suite calls a database method, so sqlite3 is never loaded into the test process at all. That also stops `bun test` and `bun run deploy` from writing settings.sqlite3 into the working directory as a side effect of listing commands. Verified: - the proxy is still a working database: set/get round-trips an object, missing keys are undefined, delete works, and the six namespaces stay separate against one backing file - importing src/db opens nothing; the first real call opens it - the deploy script still opens the store for its fingerprint, and the bot's startup path registers 10 commands, 4 components and 11 listeners with the store still closed - 25 consecutive `bun test` runs, no failures, no database file Added a regression test asserting that reading command metadata leaves the store unopened, with a comment explaining that a future test needing the database should mock src/db rather than open it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/db.ts | 61 ++++++++++++++++++++++++++++----------- tests/regressions.test.ts | 22 ++++++++++++++ 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/db.ts b/src/db.ts index c0baebc..04432c1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,20 +4,47 @@ import Keyv from "keyv"; import Sqlite from "@keyv/sqlite"; import type {BdWebAddon, CommandStats, GuildSettings, Tag} from "./types"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Single SQLite connection string -const sqliteUri = "sqlite://" + path.resolve(__dirname, "..", "settings.sqlite3"); - -// Create one Sqlite store instance -const sqliteStore = new Sqlite(sqliteUri); - -// Export pre-configured database instances sharing the same store -export const guildDB = new Keyv(sqliteStore, {namespace: "settings"}); -export const globalDB = new Keyv(sqliteStore, {namespace: "global"}); -export const selfrolesDB = new Keyv(sqliteStore, {namespace: "selfroles"}); -export const voicetextDB = new Keyv(sqliteStore, {namespace: "voicetext"}); -export const statsDB = new Keyv(sqliteStore, {namespace: "stats"}); -export const tagsDB = new Keyv>(sqliteStore, {namespace: "tags"}); -export const userInstallNotices = new Keyv(sqliteStore, {namespace: "userInstallNotices"}); \ No newline at end of file + +const here = path.dirname(fileURLToPath(import.meta.url)); +const sqliteUri = "sqlite://" + path.resolve(here, "..", "settings.sqlite3"); + +/** + * The store is built on first use, not at import. + * + * Anything that loads a command module for its metadata โ€” the loader, the + * deploy script, the test suite โ€” pulls this file in transitively. Constructing + * the store is what opens the connection and writes settings.sqlite3, so doing + * it eagerly meant merely listing the commands created a database. Worse, it + * pulled the sqlite3 native addon into the test process, where it + * intermittently aborted the runner at exit with a NAPI panic (exit code 134) + * after every test had already passed. + */ +let store: Sqlite | undefined; + +/** For tests: whether anything has actually opened the database yet. */ +export const isStoreOpen = (): boolean => store !== undefined; + +function lazyKeyv(namespace: string): Keyv { + let instance: Keyv | undefined; + + return new Proxy({} as Keyv, { + get(_target, property) { + store ??= new Sqlite(sqliteUri); + instance ??= new Keyv(store, {namespace}); + + const value: unknown = Reflect.get(instance, property); + if (typeof value === "function") return (value as (...args: unknown[]) => unknown).bind(instance); + return value; + } + }); +} + + +// Pre-configured database instances, all sharing one store once it exists +export const guildDB = lazyKeyv("settings"); +export const globalDB = lazyKeyv("global"); +export const selfrolesDB = lazyKeyv("selfroles"); +export const voicetextDB = lazyKeyv("voicetext"); +export const statsDB = lazyKeyv("stats"); +export const tagsDB = lazyKeyv>("tags"); +export const userInstallNotices = lazyKeyv("userInstallNotices"); diff --git a/tests/regressions.test.ts b/tests/regressions.test.ts index 9120250..f457f66 100644 --- a/tests/regressions.test.ts +++ b/tests/regressions.test.ts @@ -1,5 +1,8 @@ import {describe, expect, test} from "bun:test"; +import path from "node:path"; import {hasDisallowedChars} from "../src/util/names"; +import {isStoreOpen} from "../src/db"; +import {loadCommands} from "../src/framework"; import config from "../src/config"; import * as notices from "../src/util/notices"; @@ -75,3 +78,22 @@ describe("notices satisfy every send path (was: casts at each call site)", () => expect(content).not.toContain("$"); }); }); + + +describe("loading commands does not open the database (was: NAPI crash in CI)", () => { + /** + * Every command module imports src/db transitively. The store used to be + * built at import, so merely listing the commands opened SQLite and wrote + * settings.sqlite3 โ€” and pulled the sqlite3 native addon into the test + * process, where it intermittently aborted the runner at exit with a NAPI + * panic after all 96 tests had passed. + * + * Nothing in the suite calls a database method, so this stays false. If a + * future test does open the store, this will fail; that is the signal, and + * such a test should mock src/db instead. + */ + test("reading command metadata leaves the store unopened", async () => { + await loadCommands(path.join(import.meta.dir, "..", "src", "commands")); + expect(isStoreOpen()).toBe(false); + }); +}); From bb5c6792460216dfce07c684bdaebe10d8bd7d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 00:21:52 +0000 Subject: [PATCH 22/22] Address Copilot review: strict Bool codec, stale comment, two typos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the five findings held up on inspection. Bool.parse accepted anything (src/framework/ids.ts) parse: raw => raw === "1" so a stale or tampered id decoded to `false` instead of throwing. Every other codec โ€” Num, Id, oneOf โ€” raises IdError on a malformed value, and the dispatcher relies on that to show "This message is out of date" rather than running a handler with wrong params. Bool now rejects anything that is not "0" or "1", with a test covering "", "true", "banana" and "2". Stale header comment (src/util/notices.ts) The file said it was "one of two message layers" and pointed at `./messages.ts`. That was true when notices.ts was added, but messages.ts was deleted two commits later in this same branch and the comment never caught up. It now describes the actual state, including /about as the one deliberate embed exception. Two typos in command descriptions "Syncs roles between severs" -> "servers" (developer.ts) and "from it's partner" -> "its" (voicetext.ts). Both predate this branch and were carried over verbatim during the migration. These are user-visible: they change what Discord shows in the command picker, and therefore the deployed payload. The snapshot test caught that, which is what it is for; the regenerated fixture diff is exactly those two lines and nothing else. Not changed: the report that `attachment.name` can be null in src/events/forwarding.ts. discord.js 14.25 declares `public name: string` on Attachment. That class does declare seven genuinely nullable members โ€” contentType, description, duration, height, title, waveform, width โ€” all written `| null`; name is not among them. It was nullable in v13, which is likely where the pattern comes from. A fallback would be unreachable code contradicting the type, so this is answered on the thread instead. Verified: 98 tests pass, typecheck and lint clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE --- src/commands/developer.ts | 2 +- src/commands/voicetext.ts | 2 +- src/framework/ids.ts | 7 ++++++- src/util/notices.ts | 6 +++--- tests/fixtures/command-payloads.json | 4 ++-- tests/ids.test.ts | 9 +++++++++ 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/commands/developer.ts b/src/commands/developer.ts index 3f48e87..8dc3a82 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -146,7 +146,7 @@ export const command = defineCommand({ { type: ApplicationCommandOptionType.Subcommand, name: "sync", - description: "Syncs roles between severs.", + description: "Syncs roles between servers.", options: [userOption("Which developer to resync?")] }, { diff --git a/src/commands/voicetext.ts b/src/commands/voicetext.ts index 468f7bf..0c8ad33 100644 --- a/src/commands/voicetext.ts +++ b/src/commands/voicetext.ts @@ -75,7 +75,7 @@ export const command = defineCommand({ contexts: [InteractionContextType.Guild], options: [ {type: ApplicationCommandOptionType.Subcommand, name: "status", description: "Checks the bound status of a voice channel.", options: [voiceOption("Which voice channel to check?")]}, - {type: ApplicationCommandOptionType.Subcommand, name: "unbind", description: "Unbinds a voice channel from it's partner.", options: [voiceOption("Which voice channel to unbind?")]}, + {type: ApplicationCommandOptionType.Subcommand, name: "unbind", description: "Unbinds a voice channel from its partner.", options: [voiceOption("Which voice channel to unbind?")]}, { type: ApplicationCommandOptionType.Subcommand, name: "bind", diff --git a/src/framework/ids.ts b/src/framework/ids.ts index b9fdd99..416b8e2 100644 --- a/src/framework/ids.ts +++ b/src/framework/ids.ts @@ -34,7 +34,12 @@ export const Num: ParamCodec = { }; export const Bool: ParamCodec = { - parse: raw => raw === "1", + parse(raw) { + // Anything else is a stale or tampered id, and must fail like the other + // codecs rather than quietly decoding to false. + if (raw !== "0" && raw !== "1") throw new IdError(`expected a boolean, got ${JSON.stringify(raw)}`); + return raw === "1"; + }, format: value => value ? "1" : "0" }; diff --git a/src/util/notices.ts b/src/util/notices.ts index 55f3c39..ccb738f 100644 --- a/src/util/notices.ts +++ b/src/util/notices.ts @@ -4,9 +4,9 @@ * These replace the `` / `` / `` / `` JSX widgets * from djsx and produce the same payload. * - * NOTE: this is one of two message layers in the codebase right now. The other - * is the embed-based `Messages` class in `./messages.ts`, which the unmigrated - * commands still use. Collapsing them onto this one is the next pass. + * This is how the bot sends a short status message. The one deliberate + * exception is `/about`, which keeps an embed because its stats are inline + * fields three to a row and Components V2 has no field grid. */ import { diff --git a/tests/fixtures/command-payloads.json b/tests/fixtures/command-payloads.json index 5cd8c18..67b0bc2 100644 --- a/tests/fixtures/command-payloads.json +++ b/tests/fixtures/command-payloads.json @@ -289,7 +289,7 @@ "type": 1 }, { - "description": "Syncs roles between severs.", + "description": "Syncs roles between servers.", "name": "sync", "options": [ { @@ -532,7 +532,7 @@ "type": 1 }, { - "description": "Unbinds a voice channel from it's partner.", + "description": "Unbinds a voice channel from its partner.", "name": "unbind", "options": [ { diff --git a/tests/ids.test.ts b/tests/ids.test.ts index fa786a1..5aa46f5 100644 --- a/tests/ids.test.ts +++ b/tests/ids.test.ts @@ -51,6 +51,15 @@ describe("codec validation", () => { expect(() => decodeId(spec, "ns:a:b:c")).toThrow(IdError); }); + // Every codec must fail loudly on a malformed value; Bool used to decode + // anything that was not "1" as false, so a tampered or stale id could slip + // through instead of taking the "out of date" path. + test("Bool rejects anything that is not 0 or 1", () => { + expect(Bool.parse("1")).toBe(true); + expect(Bool.parse("0")).toBe(false); + for (const bad of ["", "true", "banana", "2"]) expect(() => Bool.parse(bad)).toThrow(IdError); + }); + test("oneOf rejects a value outside the set", () => { const mode = oneOf("user", "admin"); expect(mode.parse("admin")).toBe("admin");