diff --git a/src/bot/commands.test.ts b/src/bot/commands.test.ts index 6855f3f..7f502f6 100644 --- a/src/bot/commands.test.ts +++ b/src/bot/commands.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { parseCommand } from "./commands.js"; +import { canRunCommand, isAdminCommand } from "./commands.js"; describe("Command Parser", () => { it("parses simple command", () => { @@ -60,3 +61,36 @@ describe("Command Parser", () => { expect(result!.args).toBe("3"); }); }); + +describe("isAdminCommand classification", () => { + it("treats stop/clear/remove/move/vol/mode as admin", () => { + for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) { + expect(isAdminCommand(c)).toBe(true); + } + }); + it("treats follow and play as NOT admin", () => { + expect(isAdminCommand("follow")).toBe(false); + expect(isAdminCommand("play")).toBe(false); + }); +}); + +describe("canRunCommand", () => { + it("allows any public command regardless of groups", () => { + expect(canRunCommand("play", [], [6])).toBe(true); + expect(canRunCommand("follow", [], [6])).toBe(true); + }); + it("allows admin command when enforcement is off (empty adminGroups)", () => { + expect(canRunCommand("stop", [], [])).toBe(true); + }); + it("allows admin command when an invoker group matches (string vs number)", () => { + expect(canRunCommand("stop", ["6"], [6])).toBe(true); + expect(canRunCommand("stop", [6], [6])).toBe(true); + expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true); + }); + it("denies admin command when no invoker group matches", () => { + expect(canRunCommand("stop", ["8"], [6])).toBe(false); + }); + it("denies admin command when invoker has no groups and enforcement is on", () => { + expect(canRunCommand("clear", [], [6])).toBe(false); + }); +}); diff --git a/src/bot/commands.ts b/src/bot/commands.ts index bf40ddc..41e2209 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -5,14 +5,13 @@ export interface ParsedCommand { flags: Set; } -export const PUBLIC_COMMANDS = new Set([ - "play", "add", "queue", "list", "now", "lyrics", "vote", "help", - "playlist", "album", "fm", "prev", "next", "skip", "pause", "resume", - "artist", -]); - +/** + * The fixed set of "admin" chat commands. This is the SINGLE source of truth + * for which commands the permission gate restricts; reclassifying a command is + * a one-line edit here. Everything not in this set is public. + */ export const ADMIN_COMMANDS = new Set([ - "stop", "clear", "move", "vol", "mode", "follow", "remove", + "stop", "clear", "remove", "move", "vol", "mode", ]); export function parseCommand( @@ -59,3 +58,24 @@ export function parseCommand( export function isAdminCommand(commandName: string): boolean { return ADMIN_COMMANDS.has(commandName); } + +/** + * Decide whether a chat command may run, given the invoker's TS server groups + * and the configured admin groups. Pure + synchronous so it is trivially unit + * tested and reused by the async gate in BotInstance. + * + * Allowed iff: (1) it is a public command, OR (2) enforcement is off + * (adminGroups empty), OR (3) some invoker group is in adminGroups. + * invokerGroups (strings from TS) and adminGroups (numbers) are normalized to + * strings before comparison so "6" matches 6. + */ +export function canRunCommand( + commandName: string, + invokerGroups: readonly (string | number)[], + adminGroups: readonly number[], +): boolean { + if (!isAdminCommand(commandName)) return true; + if (adminGroups.length === 0) return true; + const admin = new Set(adminGroups.map((g) => String(g))); + return invokerGroups.some((g) => admin.has(String(g))); +}