From 9f33606aefa61d5876cffcd5578856d7e6129611 Mon Sep 17 00:00:00 2001 From: "Kkermit." <132657029+Kkkermit@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:35:36 +0000 Subject: [PATCH 1/3] fix: Refused a prefix command typed without its subcommand instead of failing Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JrFnSipitEXUhVySPKfrMR --- AGENTS.md | 3 ++- src/config/replies.ts | 8 +++++++ src/core/checks.ts | 44 +++++++++++++++++++++++++++---------- src/core/prefix.ts | 2 +- tests/core/checks.test.ts | 46 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2ed5e030..4c05466b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)` diff --git a/src/config/replies.ts b/src/config/replies.ts index 021e4018..fd28a9c1 100644 --- a/src/config/replies.ts +++ b/src/config/replies.ts @@ -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.", @@ -48,6 +49,7 @@ export const REPLIES: Record> = { 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.", @@ -85,6 +87,7 @@ export const REPLIES: Record> = { 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.", @@ -122,6 +125,7 @@ export const REPLIES: Record> = { 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.", @@ -158,6 +162,7 @@ export const REPLIES: Record> = { 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.", @@ -194,6 +199,7 @@ export const REPLIES: Record> = { youNeed: "Вам нужны эти права: {permissions}", iNeed: "Мне нужны эти права: {permissions}", iNeedIn: "Мне нужны эти права в {channel}: {permissions}", + pickSubcommand: "После названия этой команды нужно указать одно из: {subcommands}.", paused: "{bot} сейчас на паузе. Владелец бота может возобновить его в панели.", offHere: "Эта команда выключена на этом сервере. Любой с правом «Управлять сервером» может включить её снова.", featureOff: "Владелец {bot} выключил эту часть бота, поэтому она не работает ни на одном сервере.", @@ -227,6 +233,7 @@ export const REPLIES: Record> = { 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.", @@ -262,6 +269,7 @@ export const REPLIES: Record> = { youNeed: "次の権限が必要です:{permissions}", iNeed: "ボットに次の権限が必要です:{permissions}", iNeedIn: "{channel} でボットに次の権限が必要です:{permissions}", + pickSubcommand: "このコマンドの名前の後に、次のいずれかを付けてください:{subcommands}", paused: "{bot} は現在一時停止中です。ボットのオーナーがダッシュボードから再開できます。", offHere: "このコマンドはこのサーバーでオフになっています。「サーバー管理」の権限があれば誰でもオンに戻せます。", featureOff: "{bot}のオーナーがボットのこの機能をオフにしているため、どのサーバーでも使えません。", diff --git a/src/core/checks.ts b/src/core/checks.ts index fb8188d4..69c40cd0 100644 --- a/src/core/checks.ts +++ b/src/core/checks.ts @@ -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"; @@ -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) { @@ -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)); @@ -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; } @@ -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 { +async function checkMusicSystem( + interaction: CommandInput, + command: Command, + subcommand: Subcommand | null, +): Promise { 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; @@ -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 { +async function checkCasino( + interaction: CommandInput, + command: Command, + subcommand: Subcommand | null, +): Promise { 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( diff --git a/src/core/prefix.ts b/src/core/prefix.ts index 4a28bf87..3faf8e9e 100644 --- a/src/core/prefix.ts +++ b/src/core/prefix.ts @@ -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; subcommand: string | null; declared: CommandOption[] } { if (this.parsed !== null) return this.parsed; diff --git a/tests/core/checks.test.ts b/tests/core/checks.test.ts index aca46a13..6f913536 100644 --- a/tests/core/checks.test.ts +++ b/tests/core/checks.test.ts @@ -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.resolve(null), @@ -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 () => { From 292c1f00b5d8551a21ad1bc9b7e826ba27f7fb8e Mon Sep 17 00:00:00 2001 From: "Kkermit." <132657029+Kkkermit@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:18:41 +0000 Subject: [PATCH 2/3] feat: Added a Refresh button to the owner console's logs Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JrFnSipitEXUhVySPKfrMR --- AGENTS.md | 5 +- .../features/owner/OwnerAnalytics.test.tsx | 39 +++++- .../features/owner/components/RefreshLogs.tsx | 118 ++++++++++++++++++ dashboard/src/features/owner/tabs/LogsTab.tsx | 15 ++- dashboard/src/i18n/locales/de.json | 5 + dashboard/src/i18n/locales/en.json | 5 + dashboard/src/i18n/locales/es.json | 5 + dashboard/src/i18n/locales/fr.json | 5 + dashboard/src/i18n/locales/it.json | 5 + dashboard/src/i18n/locales/ja.json | 5 + dashboard/src/i18n/locales/nl.json | 5 + dashboard/src/i18n/locales/ru.json | 5 + 12 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 dashboard/src/features/owner/components/RefreshLogs.tsx diff --git a/AGENTS.md b/AGENTS.md index 4c05466b..496f8718 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3637,7 +3637,10 @@ wrote. Five things about the history are load-bearing: - **The run going now is read from memory.** The Logs tab is the screen you open when something is wrong, and the database may be the thing that is wrong, so `/analytics/logs` without a `run` reads the ring and `/analytics/logs/runs` lists this run first whatever the database says, answering `pastUnavailable` when it - cannot read the rest. A history that could not be started costs the history, never the bot. + cannot read the rest. A history that could not be started costs the history, never the bot. **Refresh** + reads the run shown and the list again at once and says so; a read that fails, by hand or on the timer, says why + beside it while the lines already read stay on screen (`RefreshLogs`), since an error page in their place would hide + them exactly when they are wanted. - **Search is literal and spans every run.** The ring and `readLogLines` both match the message and the stringified context, so a guild id or an error reference finds every line about it, and a search is escaped before it becomes a pattern, so `(` or `.*` finds those characters. The runs list counts what the search finds in each run at the diff --git a/dashboard/src/features/owner/OwnerAnalytics.test.tsx b/dashboard/src/features/owner/OwnerAnalytics.test.tsx index acf428cb..42e365a5 100644 --- a/dashboard/src/features/owner/OwnerAnalytics.test.tsx +++ b/dashboard/src/features/owner/OwnerAnalytics.test.tsx @@ -5,7 +5,7 @@ import { barWidth } from "@/components/primitives/UsageBars"; import { levelFrom, shortDay, windowFrom } from "@/features/owner/owner.utils"; import { OwnerPage } from "@/features/owner/OwnerPage"; import { expectNoViolations } from "@/test/axe"; -import { logRuns, PAST_RUN } from "@/test/fixtures/owner"; +import { logFeed, logRuns, PAST_RUN } from "@/test/fixtures/owner"; import { renderWithProviders } from "@/test/renderWithProviders"; import { server } from "@/test/setup"; @@ -200,6 +200,43 @@ describe("the logs tab", () => { expect(screen.getByRole("radio", { name: /Running now/ })).toBeInTheDocument(); expect(screen.getByText(/Deleted the logs of the run going now/)).toBeInTheDocument(); }); + + /** A paused feed, or a past run, otherwise shows what it read until the tab is opened again. */ + it("reads the logs again on demand and says that it has", async () => { + const user = userEvent.setup(); + renderTab("logs"); + await screen.findByText("[READY] Logged in"); + + const latest = { at: "2026-08-01T12:01:00.000Z", level: "info", message: "[HEARTBEAT] Still here", context: {} }; + server.use( + http.get("/api/analytics/logs", () => HttpResponse.json({ ...logFeed, lines: [latest, ...logFeed.lines] })), + ); + await user.click(screen.getByRole("button", { name: "Refresh" })); + + expect(await screen.findByText("[HEARTBEAT] Still here")).toBeInTheDocument(); + expect((await screen.findByText(/^Refreshed at /)).closest("[role=status]")).toHaveClass("text-success"); + }); + + /** A failed refresh says why, and keeps the lines it already had rather than swapping them for an error page. */ + it("says why a refresh failed, and keeps the lines it had", async () => { + const user = userEvent.setup(); + renderTab("logs"); + await screen.findByText("[READY] Logged in"); + + server.use( + http.get("/api/analytics/logs", () => + HttpResponse.json({ error: { code: "internal", message: "The log store is busy." } }, { status: 500 }), + ), + ); + await user.click(screen.getByRole("button", { name: "Refresh" })); + + const note = await screen.findByText( + /^Couldn’t refresh the logs\. The log store is busy\. The lines shown were read at /, + ); + expect(note.closest("[role=status]")).toHaveClass("text-destructive-text"); + expect(screen.getByText("[READY] Logged in")).toBeInTheDocument(); + expect(screen.queryByText(/^Refreshed at /)).toBeNull(); + }); }); describe("the runtime tab", () => { diff --git a/dashboard/src/features/owner/components/RefreshLogs.tsx b/dashboard/src/features/owner/components/RefreshLogs.tsx new file mode 100644 index 00000000..50a43c7f --- /dev/null +++ b/dashboard/src/features/owner/components/RefreshLogs.tsx @@ -0,0 +1,118 @@ +import { AlertTriangle, Check, RefreshCw } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { describe } from "@/app/ErrorState"; +import { Button } from "@/components/primitives"; +import { cn } from "@/lib/cn"; +import { clockTime } from "@/lib/datetime"; + +const REFRESHED_NOTE_MS = 5_000; + +interface Read { + isError: boolean; + error: unknown; +} + +type Outcome = + | { state: "idle" } + | { state: "refreshing" } + | { state: "refreshed"; at: number } + | { state: "failed"; error: unknown; at: number }; + +const timeOf = (at: number): string => clockTime(new Date(at).toISOString()); + +/** Reads the logs again on demand and says how that went; a failure stands until a later read of the lines works. */ +export function RefreshLogs({ + refetch, + failure, + readAt, + children, +}: { + refetch: () => Promise; + /** Why the latest read failed, or null; only said here while older lines are still on screen. */ + failure: unknown; + /** When the lines on screen were read, or 0 before any were. */ + readAt: number; + /** The row's other actions, between the button and what it says. */ + children?: ReactNode; +}): React.JSX.Element { + const { t } = useTranslation(); + const [outcome, setOutcome] = useState({ state: "idle" }); + const refreshing = outcome.state === "refreshing"; + + useEffect(() => { + if (outcome.state !== "refreshed") return; + const timer = setTimeout(() => { + setOutcome({ state: "idle" }); + }, REFRESHED_NOTE_MS); + return () => { + clearTimeout(timer); + }; + }, [outcome]); + + async function refresh(): Promise { + if (refreshing) return; + setOutcome({ state: "refreshing" }); + + let failed: Read | undefined; + try { + failed = (await refetch()).find((read) => read.isError); + } catch (error) { + failed = { isError: true, error }; + } + + setOutcome( + failed === undefined + ? { state: "refreshed", at: Date.now() } + : { state: "failed", error: failed.error, at: Date.now() }, + ); + } + + const shownFailure = + outcome.state === "failed" && readAt <= outcome.at + ? outcome.error + : failure !== null && readAt > 0 + ? failure + : null; + + let note: ReactNode = null; + if (shownFailure !== null) { + const reason = describe(shownFailure, t).body; + note = ( + <> +