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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,8 @@ rather than a wait. Do not write the old "up to an hour to propagate" line back

- Slash → `events/command/interactionCreate.event.ts` → `checks.ts` gates → `command.run(input, client)`
- Prefix → `events/message/…` → `core/prefix.ts` resolves name or alias → **the same** `command.run`. The prefix matches in
any case, so `T?help` — what a phone's auto-capitalisation types — runs `t?help`
any case, so `T?help` — what a phone's auto-capitalisation types — runs `t?help`. A prefix run naming no
subcommand the command has is refused by `runChecks` in the reader's language, before a cooldown starts
- Components → the one `interactionCreate` listener → `client.buttons` registry keyed by the custom-ID prefix →
`button.run(interaction, { client, action, args })`
- Right-click → the same listener → `client.contextMenus` by name → `checkContextMenu` → `menu.run(interaction, client)`
Expand Down
8 changes: 8 additions & 0 deletions src/config/replies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const en = {
youNeed: "You need these permissions: {permissions}",
iNeed: "I need these permissions: {permissions}",
iNeedIn: "I need these permissions in {channel}: {permissions}",
pickSubcommand: "That command needs one of these after its name: {subcommands}.",
paused: "{bot} is paused right now. The bot owner can resume it from the dashboard.",
offHere: "That command is switched off in this server. Anybody with Manage Server can turn it back on.",
featureOff: "{bot}'s owner has switched this part of the bot off, so it does not work in any server.",
Expand Down Expand Up @@ -48,6 +49,7 @@ export const REPLIES: Record<BotLanguage, Record<ReplyKey, string>> = {
youNeed: "Dir fehlen diese Berechtigungen: {permissions}",
iNeed: "Mir fehlen diese Berechtigungen: {permissions}",
iNeedIn: "In {channel} fehlen mir diese Berechtigungen: {permissions}",
pickSubcommand: "Dieser Befehl braucht nach seinem Namen eines davon: {subcommands}.",
paused: "{bot} ist gerade pausiert. Der Bot-Betreiber kann ihn im Dashboard fortsetzen.",
offHere:
"Dieser Befehl ist auf diesem Server ausgeschaltet. Jeder mit „Server verwalten“ kann ihn wieder einschalten.",
Expand Down Expand Up @@ -85,6 +87,7 @@ export const REPLIES: Record<BotLanguage, Record<ReplyKey, string>> = {
youNeed: "Necesitas estos permisos: {permissions}",
iNeed: "Necesito estos permisos: {permissions}",
iNeedIn: "Necesito estos permisos en {channel}: {permissions}",
pickSubcommand: "Ese comando necesita una de estas opciones después de su nombre: {subcommands}.",
paused: "{bot} está en pausa ahora mismo. El dueño del bot puede reanudarlo desde el panel.",
offHere:
"Ese comando está desactivado en este servidor. Cualquiera con Gestionar servidor puede volver a activarlo.",
Expand Down Expand Up @@ -122,6 +125,7 @@ export const REPLIES: Record<BotLanguage, Record<ReplyKey, string>> = {
youNeed: "Il vous faut ces permissions : {permissions}",
iNeed: "Il me faut ces permissions : {permissions}",
iNeedIn: "Il me faut ces permissions dans {channel} : {permissions}",
pickSubcommand: "Cette commande attend l’une de ces options après son nom : {subcommands}.",
paused: "{bot} est en pause pour le moment. Le propriétaire du bot peut le relancer depuis le tableau de bord.",
offHere: "Cette commande est désactivée sur ce serveur. Toute personne ayant Gérer le serveur peut la réactiver.",
featureOff: "Le propriétaire de {bot} a désactivé cette partie du bot, elle ne fonctionne donc sur aucun serveur.",
Expand Down Expand Up @@ -158,6 +162,7 @@ export const REPLIES: Record<BotLanguage, Record<ReplyKey, string>> = {
youNeed: "Ti servono questi permessi: {permissions}",
iNeed: "Mi servono questi permessi: {permissions}",
iNeedIn: "Mi servono questi permessi in {channel}: {permissions}",
pickSubcommand: "Quel comando richiede una di queste opzioni dopo il nome: {subcommands}.",
paused: "{bot} è in pausa al momento. Il proprietario del bot può riprenderlo dalla dashboard.",
offHere: "Quel comando è disattivato in questo server. Chiunque abbia Gestisci server può riattivarlo.",
featureOff: "Il proprietario di {bot} ha disattivato questa parte del bot, quindi non funziona in nessun server.",
Expand Down Expand Up @@ -194,6 +199,7 @@ export const REPLIES: Record<BotLanguage, Record<ReplyKey, string>> = {
youNeed: "Вам нужны эти права: {permissions}",
iNeed: "Мне нужны эти права: {permissions}",
iNeedIn: "Мне нужны эти права в {channel}: {permissions}",
pickSubcommand: "После названия этой команды нужно указать одно из: {subcommands}.",
paused: "{bot} сейчас на паузе. Владелец бота может возобновить его в панели.",
offHere: "Эта команда выключена на этом сервере. Любой с правом «Управлять сервером» может включить её снова.",
featureOff: "Владелец {bot} выключил эту часть бота, поэтому она не работает ни на одном сервере.",
Expand Down Expand Up @@ -227,6 +233,7 @@ export const REPLIES: Record<BotLanguage, Record<ReplyKey, string>> = {
youNeed: "Je hebt deze rechten nodig: {permissions}",
iNeed: "Ik heb deze rechten nodig: {permissions}",
iNeedIn: "Ik heb deze rechten nodig in {channel}: {permissions}",
pickSubcommand: "Die opdracht heeft na de naam een van deze nodig: {subcommands}.",
paused: "{bot} is nu gepauzeerd. De eigenaar van de bot kan hem in het dashboard hervatten.",
offHere: "Die opdracht staat uit in deze server. Iedereen met Server beheren kan hem weer aanzetten.",
featureOff: "De eigenaar van {bot} heeft dit deel van de bot uitgezet, dus het werkt in geen enkele server.",
Expand Down Expand Up @@ -262,6 +269,7 @@ export const REPLIES: Record<BotLanguage, Record<ReplyKey, string>> = {
youNeed: "次の権限が必要です:{permissions}",
iNeed: "ボットに次の権限が必要です:{permissions}",
iNeedIn: "{channel} でボットに次の権限が必要です:{permissions}",
pickSubcommand: "このコマンドの名前の後に、次のいずれかを付けてください:{subcommands}",
paused: "{bot} は現在一時停止中です。ボットのオーナーがダッシュボードから再開できます。",
offHere: "このコマンドはこのサーバーでオフになっています。「サーバー管理」の権限があれば誰でもオンに戻せます。",
featureOff: "{bot}のオーナーがボットのこの機能をオフにしているため、どのサーバーでも使えません。",
Expand Down
44 changes: 33 additions & 11 deletions src/core/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { type BotLanguage } from "@config/languages";
import { theme } from "@config/theme";
import { botName } from "@core/brand";
import { type TestifyClient } from "@core/client";
import { type Command, type CommandInput, type Subcommand } from "@core/command";
import { type Command, type CommandInput, type Subcommand, subcommandsOf } from "@core/command";
import { UserFacingError } from "@core/errors";
import { durationIn, eitherOf, permissionList, readerLanguage, say } from "@core/language";
import { findBlacklistEntry } from "@database/repositories/blacklistRepository";
import { disabledGlobally, disabledInGuild } from "@database/repositories/commandToggleRepository";
Expand Down Expand Up @@ -63,10 +64,16 @@ export async function runChecks(
const switchedOff = await checkSwitchedOff(command.name, interaction.guildId, language);
if (switchedOff !== null) return switchedOff;

const musicOff = await checkMusicSystem(interaction, command);
const subcommand = chosenSubcommand(interaction, command);
if (subcommand === undefined) {
const names = subcommandsOf(command).map((sub) => `\`${sub.name}\``);
return say(language, "pickSubcommand", { subcommands: eitherOf(language, names) });
}

const musicOff = await checkMusicSystem(interaction, command, subcommand);
if (musicOff !== null) return musicOff;

const casinoClosed = await checkCasino(interaction, command);
const casinoClosed = await checkCasino(interaction, command, subcommand);
if (casinoClosed !== null) return casinoClosed;

if (command.guildOnly && !interaction.guild) {
Expand All @@ -80,7 +87,7 @@ export async function runChecks(
}

const member = interaction.member;
const needed = [...(command.permissions ?? []), ...(chosenSubcommand(interaction, command)?.permissions ?? [])];
const needed = [...(command.permissions ?? []), ...(subcommand?.permissions ?? [])];

if (needed.length > 0 && member !== null) {
const missing = permissionsOf(member).missing(new PermissionsBitField(needed));
Expand Down Expand Up @@ -234,11 +241,18 @@ async function checkSwitchedOff(name: string, guildId: string | null, language:
return null;
}

/** Which subcommand was used, or null — a command with no subcommands answers null the contract cannot express. */
function chosenSubcommand(interaction: CommandInput, command: Command): Subcommand | null {
/** Which subcommand was used: null for a command without them, undefined for a prefix run that named none it has. */
function chosenSubcommand(interaction: CommandInput, command: Command): Subcommand | null | undefined {
if (command.subcommands === undefined) return null;

const chosen: string | null = interaction.options.getSubcommand(false);
let chosen: string | null;
try {
chosen = interaction.options.getSubcommand(false);
} catch (error) {
// The prefix reader throws on its first read when the words typed name no subcommand.
if (error instanceof UserFacingError) return undefined;
throw error;
}

return command.subcommands.find((subcommand) => subcommand.name === chosen) ?? null;
}
Expand All @@ -262,10 +276,14 @@ function roleIdsOf(member: CommandInput["member"]): string[] {
* The music system's switch and DJ roles, gated here so no music command can forget them; `/music system` is the way
* back in.
*/
async function checkMusicSystem(interaction: CommandInput, command: Command): Promise<CheckFailure> {
async function checkMusicSystem(
interaction: CommandInput,
command: Command,
subcommand: Subcommand | null,
): Promise<CheckFailure> {
if (command.category !== "music" || interaction.guildId === null) return null;

if (chosenSubcommand(interaction, command)?.name === MUSIC_SYSTEM_SUBCOMMAND) return null;
if (subcommand?.name === MUSIC_SYSTEM_SUBCOMMAND) return null;

const settings = normaliseMusicSettings(await getMusicSettings(interaction.guildId));
const member = interaction.member;
Expand All @@ -277,10 +295,14 @@ async function checkMusicSystem(interaction: CommandInput, command: Command): Pr
}

/** The casino's switch and its per-game switches; settings and stats stay reachable while it is closed. */
async function checkCasino(interaction: CommandInput, command: Command): Promise<CheckFailure> {
async function checkCasino(
interaction: CommandInput,
command: Command,
subcommand: Subcommand | null,
): Promise<CheckFailure> {
if (command.name !== CASINO_COMMAND || interaction.guildId === null) return null;

const chosen = chosenSubcommand(interaction, command)?.name ?? null;
const chosen = subcommand?.name ?? null;
if (chosen === CASINO_SETTINGS_SUBCOMMAND || chosen === CASINO_STATS_SUBCOMMAND) return null;

return casinoRefusal(
Expand Down
2 changes: 1 addition & 1 deletion src/core/prefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class PrefixOptions extends OptionReader implements CommandInputOptions {
this.args = args;
}

/** Parsed on first use, so a missing subcommand fails inside the command's error boundary. */
/** Parsed on first use; a missing or unknown subcommand throws, which `runChecks` answers as a refusal. */
private get state(): { values: Map<string, string>; subcommand: string | null; declared: CommandOption[] } {
if (this.parsed !== null) return this.parsed;

Expand Down
46 changes: 45 additions & 1 deletion tests/core/checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
runChecks,
} from "@core/checks";
import { defineCommand } from "@core/command";
import { PrefixInteraction } from "@core/prefix";
import { refusalEmbed } from "@lib/discord/embeds.util";
import { createMockClient, createMockInteraction, OWNER_ID, USER_ID } from "@tests/helpers/mocks";
import { createMockClient, createMockInteraction, createMockMessage, OWNER_ID, USER_ID } from "@tests/helpers/mocks";

const findBlacklistEntry = jest.fn<Promise<{ reason: string; createdAt?: Date } | null>, []>(() =>
Promise.resolve(null),
Expand Down Expand Up @@ -350,6 +351,49 @@ describe("the music system's own switch", () => {
});
});

describe("a prefix run that names no subcommand the command has", () => {
const role = defineCommand({
name: "role",
description: "Roles.",
category: "moderation",
cooldown: 5_000,
subcommands: [
{ name: "add", description: "Adds.", run: jest.fn() },
{ name: "remove", description: "Removes.", run: jest.fn() },
],
});
const casino = defineCommand({
name: "casino",
description: "Games.",
category: "casino",
subcommands: [{ name: "roulette", description: "Spins.", run: jest.fn() }],
});
const typed = (command: typeof role, args: string[]) => new PrefixInteraction(createMockMessage(), command, args);

/** `t?role @someone` threw out of the checks, so nobody was answered and the handler logged an error. */
it("is refused with the subcommands to pick from, and costs no cooldown", async () => {
const client = createMockClient();

expect(await runChecks(typed(role, [`<@${USER_ID}>`]), role, client)).toBe(
"That command needs one of these after its name: `add` or `remove`.",
);
expect(await runChecks(typed(role, []), role, client)).toContain("`add` or `remove`");
expect(await runChecks(typed(role, ["add", `<@${USER_ID}>`]), role, client)).toBeNull();
});

/** The music and casino gates read the subcommand before the permissions do, and threw there first. */
it("is refused by the commands the music and casino gates look at too", async () => {
const refusals = await Promise.all(
[music, casino].map((command) => runChecks(typed(command, ["nonsense"]), command, createMockClient())),
);

expect(refusals).toEqual([
"That command needs one of these after its name: `skip` or `system`.",
"That command needs one of these after its name: `roulette`.",
]);
});
});

describe("a subcommand that needs its own permissions", () => {
/** `/music` is open to everybody, so the permission belongs to the one subcommand rather than the command. */
it("refuses somebody who cannot manage the server", async () => {
Expand Down
Loading