diff --git a/src/commands/commands.loader.ts b/src/commands/commands.loader.ts index 896a5b34..df049833 100644 --- a/src/commands/commands.loader.ts +++ b/src/commands/commands.loader.ts @@ -44,6 +44,5 @@ export const COMMANDS = new Collection([ [ShuffleCommand.ID, new ShuffleCommand()], [ToMeCommand.ID, new ToMeCommand()], [VoiceCommand.ID, new VoiceCommand()], - [VoiceCommand.ID, new VoiceCommand()], [WhitelistCommand.ID, new WhitelistCommand()], ]); diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts new file mode 100644 index 00000000..8a3aca63 --- /dev/null +++ b/src/commands/commands.test.ts @@ -0,0 +1,33 @@ +import { afterAll, describe, expect, it } from "vitest"; + +import { CLIENT } from "../client/client.ts"; +import { DISCORD_COMMAND_SIZE_LIMIT, getCommandSize } from "../utils/command-size.utils.ts"; +import { COMMANDS } from "./commands.loader.ts"; + +// Constructing CLIENT (via the import graph) starts sweeper intervals; clear +// them so Vitest exits cleanly. +afterAll(() => CLIENT.destroy()); + +describe("slash command registration constraints", () => { + const built = COMMANDS.map(command => command.data.toJSON()); + + it("builds every command without throwing", () => { + expect(built.length).toBe(COMMANDS.size); + }); + + it("keeps every command within Discord's 8000-character limit", () => { + const offenders = built + .map(command => ({ name: command.name, size: getCommandSize(command) })) + .filter(command => command.size > DISCORD_COMMAND_SIZE_LIMIT); + + expect( + offenders, + `Commands exceeding the ${DISCORD_COMMAND_SIZE_LIMIT}-char limit: ${JSON.stringify(offenders)}`, + ).toEqual([]); + }); + + it("has no duplicate command names", () => { + const names = built.map(command => command.name); + expect(new Set(names).size).toBe(names.length); + }); +}); diff --git a/src/commands/commands/events.command.ts b/src/commands/commands/events.command.ts index 21c526b4..70ddef29 100644 --- a/src/commands/commands/events.command.ts +++ b/src/commands/commands/events.command.ts @@ -355,8 +355,8 @@ export class EventsCommand extends AdminCommand { lockOffsetMinutes: new LockOffsetMinutesOption({ description: "Minutes after start to lock (neg=before)" }), cleanupOffsetHours: new CleanupOffsetHoursOption({ description: "Hours after rooms finish to cleanup" }), announcementChannel: new AnnouncementChannelOption({ description: "Announcement channel" }), - announcementMessage: new AnnouncementMessageOption({ description: "Use {event_name}, {start_time}, {start_time_relative}, {room_queues_channel}, {sub_queues_channel}" }), - roomPingMessage: new RoomPingMessageOption({ description: "Use {room_role}, {room_name}, {event_name}, {start_time}, {ping_channel}, /events help for more" }), + announcementMessage: new AnnouncementMessageOption({ description: "Announcement template — placeholders: /events help" }), + roomPingMessage: new RoomPingMessageOption({ description: "Per-room ping template — placeholders: /events help" }), maxRoomsPerUser: new MaxRoomsPerUserOption({ description: "Max rooms per user (0=unlimited)" }), maxSubsPerUser: new MaxSubsPerUserOption({ description: "Max subs per user (0=unlimited)" }), parentSubMutuallyExclusive: new ParentSubMutuallyExclusiveOption({ description: "Room + matching sub mutually exclusive" }), @@ -368,7 +368,7 @@ export class EventsCommand extends AdminCommand { shuffleSubsBeforeAutoPullToggle: new ShuffleSubsBeforeAutoPullToggleOption({ description: "Shuffle subs before auto-pull" }), subAutoPullMode: new SubAutoPullModeOption({ description: "Auto-pull mode" }), createDiscordEvent: new CreateDiscordEventToggleOption({ description: "Create Discord scheduled event per occurrence" }), - discordEventDescription: new DiscordEventDescriptionOption({ description: "Use {event_name}, {start_time}, {start_time_relative}, {room_queues_channel}, {sub_queues_channel}" }), + discordEventDescription: new DiscordEventDescriptionOption({ description: "Discord event description — placeholders: /events help" }), }; static async events_add(inter: SlashInteraction) { @@ -440,8 +440,8 @@ export class EventsCommand extends AdminCommand { lockOffsetMinutes: new LockOffsetMinutesOption({ description: "Minutes after start to lock" }), cleanupOffsetHours: new CleanupOffsetHoursOption({ description: "Hours after rooms finish to cleanup" }), announcementChannel: new AnnouncementChannelOption({ description: "Announcement channel" }), - announcementMessage: new AnnouncementMessageOption({ description: "Use {event_name}, {start_time}, {start_time_relative}, {room_queues_channel}, {sub_queues_channel}" }), - roomPingMessage: new RoomPingMessageOption({ description: "Use {room_role}, {room_name}, {event_name}, {start_time}, {ping_channel}, /events help for more" }), + announcementMessage: new AnnouncementMessageOption({ description: "Announcement template — placeholders: /events help" }), + roomPingMessage: new RoomPingMessageOption({ description: "Per-room ping template — placeholders: /events help" }), maxRoomsPerUser: new MaxRoomsPerUserOption({ description: "Max rooms per user (0=unlimited)" }), maxSubsPerUser: new MaxSubsPerUserOption({ description: "Max subs per user (0=unlimited)" }), parentSubMutuallyExclusive: new ParentSubMutuallyExclusiveOption({ description: "Room + matching sub mutually exclusive" }), @@ -454,7 +454,7 @@ export class EventsCommand extends AdminCommand { shuffleSubsBeforeAutoPullToggle: new ShuffleSubsBeforeAutoPullToggleOption({ description: "Shuffle subs before auto-pull" }), subAutoPullMode: new SubAutoPullModeOption({ description: "Auto-pull mode" }), createDiscordEvent: new CreateDiscordEventToggleOption({ description: "Create Discord scheduled event per occurrence" }), - discordEventDescription: new DiscordEventDescriptionOption({ description: "Use {event_name}, {start_time}, {start_time_relative}, {room_queues_channel}, {sub_queues_channel}" }), + discordEventDescription: new DiscordEventDescriptionOption({ description: "Discord event description — placeholders: /events help" }), winnerRole: new WinnerRoleOption({ description: "Role granted to declared winners" }), }; diff --git a/src/db/db.ts b/src/db/db.ts index c94bcc50..adf932e9 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -4,7 +4,9 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import * as schema from "./schema.ts"; -export const DB_FILEPATH = "data/main.sqlite"; +// Under Vitest, use a throwaway in-memory DB so importing the command/option +// graph (which transitively connects here) never touches the real dev/prod DB. +export const DB_FILEPATH = process.env.VITEST ? ":memory:" : "data/main.sqlite"; export const DB_BACKUP_DIRECTORY = "data/backups"; export const MIGRATIONS_FOLDER = "data/migrations"; diff --git a/src/utils/client.utils.ts b/src/utils/client.utils.ts index dd181a37..4039ffeb 100644 --- a/src/utils/client.utils.ts +++ b/src/utils/client.utils.ts @@ -19,6 +19,7 @@ import { COMMANDS } from "../commands/commands.loader.ts"; import { Queries } from "../db/queries.ts"; import { Store } from "../db/store.ts"; import { Color, DisplayUpdateType } from "../types/db.types.ts"; +import { DISCORD_COMMAND_SIZE_LIMIT, findOversizedCommands } from "./command-size.utils.ts"; import { DisplayUtils } from "./display.utils.ts"; export namespace ClientUtils { @@ -30,6 +31,16 @@ export namespace ClientUtils { console.time(`Registered ${COMMANDS.size} commands with server`); const commandsPutRoute = Routes.applicationCommands(process.env.CLIENT_ID); const commandsJSON = COMMANDS.map(c => c.data.toJSON()); + + const oversized = findOversizedCommands(commandsJSON); + if (oversized.length > 0) { + const details = oversized.map(o => `/${o.name} (${o.size}/${DISCORD_COMMAND_SIZE_LIMIT} chars)`).join(", "); + throw new Error( + `Aborting registration — command(s) exceed Discord's ${DISCORD_COMMAND_SIZE_LIMIT}-character limit: ${details}. ` + + "Shorten their subcommand/option names, descriptions, or choices.", + ); + } + await new REST() .setToken(process.env.TOKEN) .put(commandsPutRoute, { body: commandsJSON }); diff --git a/src/utils/command-size.utils.ts b/src/utils/command-size.utils.ts new file mode 100644 index 00000000..7edf21be --- /dev/null +++ b/src/utils/command-size.utils.ts @@ -0,0 +1,30 @@ +// Discord caps the *combined* character count of a single global application +// command — every name + description + choice name/value across the whole +// subcommand/option tree — at 8000. Exceeding it makes the bulk `PUT +// /commands` call fail with a cryptic `50035 APPLICATION_COMMAND_TOO_LARGE`, +// so we measure up-front (at startup and in CI) instead of finding out on deploy. +export const DISCORD_COMMAND_SIZE_LIMIT = 8000; + +interface CommandSizeNode { + name?: string; + description?: string; + choices?: { name?: string; value?: unknown }[]; + options?: CommandSizeNode[]; +} + +export function getCommandSize(node: CommandSizeNode): number { + let total = (node.name?.length ?? 0) + (node.description?.length ?? 0); + for (const choice of node.choices ?? []) { + total += (choice.name?.length ?? 0) + String(choice.value ?? "").length; + } + for (const option of node.options ?? []) { + total += getCommandSize(option); + } + return total; +} + +export function findOversizedCommands(commands: CommandSizeNode[]) { + return commands + .map(command => ({ name: command.name ?? "(unnamed)", size: getCommandSize(command) })) + .filter(entry => entry.size > DISCORD_COMMAND_SIZE_LIMIT); +}