Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/commands/commands.loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,5 @@ export const COMMANDS = new Collection<string, Command>([
[ShuffleCommand.ID, new ShuffleCommand()],
[ToMeCommand.ID, new ToMeCommand()],
[VoiceCommand.ID, new VoiceCommand()],
[VoiceCommand.ID, new VoiceCommand()],
[WhitelistCommand.ID, new WhitelistCommand()],
]);
33 changes: 33 additions & 0 deletions src/commands/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 6 additions & 6 deletions src/commands/commands/events.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand All @@ -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) {
Expand Down Expand Up @@ -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" }),
Expand All @@ -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" }),
};

Expand Down
4 changes: 3 additions & 1 deletion src/db/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
11 changes: 11 additions & 0 deletions src/utils/client.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 });
Expand Down
30 changes: 30 additions & 0 deletions src/utils/command-size.utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}