From 0c7f7e128b589a8ca78e67a5b08f59643ad210f3 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 25 Jun 2026 23:04:58 +0800 Subject: [PATCH 01/12] docs: TS chat-command permission control design spec Binary admin gate keyed on TS server groups (config.adminGroups), opt-in/backward-compatible (empty = no enforcement), gated in the chat handler (executeCommand stays agnostic so WebUI is unaffected), with adminGroups editable from the WebUI settings. Completes the unused adminGroups/ADMIN_COMMANDS scaffold. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-25-ts-command-permissions-design.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-ts-command-permissions-design.md diff --git a/docs/superpowers/specs/2026-06-25-ts-command-permissions-design.md b/docs/superpowers/specs/2026-06-25-ts-command-permissions-design.md new file mode 100644 index 0000000..c84cb04 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-ts-command-permissions-design.md @@ -0,0 +1,116 @@ +# TeamSpeak chat-command permission control — design + +**Origin:** User request — "给 ts 命令也加上权限控制" (give the TS chat commands permission control too, like the WebUI already has). Completes the unused `adminGroups` scaffold the original authors left behind. +**Date:** 2026-06-25 +**Status:** Approved (brainstorm), pending implementation plan + +## Scope + +Add permission control to **TeamSpeak chat commands** (`!play`, `!add`, `!stop`, …). Today any client in a channel with the bot can run any command; only the WebUI path is permission-gated. This adds a **binary admin gate** keyed on the sender's **TS server groups**: a fixed set of "admin" commands may be restricted to members of configured admin server-groups, while all other commands stay public. Enforcement is **opt-in and backward-compatible** — it activates only once an admin lists their server-group ID(s). + +The privileged server-groups are configured in `config.adminGroups` (already declared, currently unused) and become editable from the WebUI. + +## Problem + +`src/bot/commands.ts` already declares `PUBLIC_COMMANDS` / `ADMIN_COMMANDS` sets and an `isAdminCommand()` helper, and `src/bot/instance.ts:325` has the stub `// TODO: Check if invoker is in adminGroups` — but none of it gates anything. `config.adminGroups: number[]` (`src/data/config.ts:21,46`) is documented as a legacy placeholder and read nowhere. So chat commands are unauthenticated: anyone can `!stop`, `!clear`, `!remove`, move the bot, change volume/mode. The WebUI, by contrast, gates everything via `authorize()` at the HTTP layer. + +`executeCommand` (`instance.ts:351`) is **shared** by the chat handler and the WebUI player router; the WebUI gates at the HTTP layer, so the chat gate must live in the **chat handler**, never inside `executeCommand` (else the already-gated WebUI would be double-gated). + +## Decisions (from brainstorm) + +1. **Binary admin gate**, not per-group capabilities and not a whole-bot allowlist. Reuses the existing `adminGroups` scaffold. +2. **Admin command set (fixed, one source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. The set lives in one constant so reclassifying a command is a one-line change. +3. **Default = open / opt-in (backward-compatible):** when `config.adminGroups` is empty (the default), there is **no enforcement** — admin commands stay open to everyone, exactly as today. Enforcement turns on only when `adminGroups` is non-empty. +4. **Identity key = TS server groups**, matched against `adminGroups`. +5. **Fail-closed on undeterminable groups:** if an admin command arrives, enforcement is on, and the sender's groups cannot be determined (even after a fallback lookup), **deny**. +6. **Reply on deny:** the bot sends the sender a brief permission-denied message (silent denial is confusing; the bot already replies to commands). +7. **Config surface:** `adminGroups` becomes editable from an admin-only WebUI Settings section, live-applied via the existing `/api/bot/settings` endpoint; `config.json` continues to work. + +## Permission model + +Tier definitions live in `src/bot/commands.ts` (repurpose the existing dead sets; the admin set is the source of truth): +- **Admin commands:** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. +- **Public commands:** all others (`play`, `add`, `playnext`/`pn`, `skip`/`next`, `prev`, `pause`, `resume`, `now`, `queue`/`list`, `lyrics`, `vote`, `help`, `search`/`find`, `playlist`, `album`, `artist`, `fm`). + +**Enforcement rule** — a command is **allowed** iff: +1. it is a public command, **OR** +2. `config.adminGroups` is empty (enforcement off), **OR** +3. the sender's server groups ∩ `config.adminGroups` ≠ ∅. + +Otherwise it is **denied** (no execution; a denial reply is sent). + +Expressed as a pure, unit-testable helper (no TS/async dependency): +```ts +// returns true = allowed, false = denied +function canRunCommand( + commandName: string, + invokerGroups: readonly (string | number)[], + adminGroups: readonly number[] +): boolean +``` +- not an admin command → `true`. +- admin command, `adminGroups.length === 0` → `true` (enforcement off). +- admin command, non-empty `adminGroups` → `true` iff any `invokerGroups` value (normalized to number/string consistently) is in `adminGroups`, else `false`. + +> Note: `invokerGroups` from TS are strings; `adminGroups` are numbers. Normalize both sides (compare as the same type) to avoid `"6" !== 6` bugs. + +## Identity resolution + +The TS library already delivers the sender's server groups on each chat event (`TextMessage.invokerGroups: string[]` in `@honeybbq/teamspeak-client`), but the wrapper type `TS3TextMessage` (`src/ts-protocol/client.ts:58-64`) and its mapping (`client.ts:205-214`) **drop** it. + +Changes: +1. Add `invokerGroups: string[]` to `TS3TextMessage` and populate it from `msg.invokerGroups` in the mapping. +2. **Availability caveat:** `invokerGroups` is populated only when the sender's client is in the bot's local cache (typically same channel / in view). For a private message from an unseen client, it is `[]`. +3. **Fallback lookup (only when needed):** in the gate, if the command is admin-gated **and** enforcement is on **and** `invokerGroups` is empty, perform a targeted lookup of the sender's groups keyed on `invokerId` (clid) — reuse the already-wrapped `getClientsInChannel()` (`client.ts:314-323`, whose `ClientInfo` carries `serverGroups`), or add a thin wrapper around the library's `getClientInfo(client, clid)` for a precise `clientinfo` query. This query is skipped entirely for public commands, when enforcement is off, and when the event already carried groups (the common "listener in the channel types `!stop`" case). +4. **Fail-closed:** if after the fallback the groups are still unknown, deny the admin command. + +## Enforcement seam + +In `handleTextMessage` (`src/bot/instance.ts:317`), replace the dead stub at `instance.ts:325-327` with the real check, placed after `parseCommand` succeeds and **before** `executeCommand` (`instance.ts:335`): +- compute `allowed` via `canRunCommand(parsed.name, msg.invokerGroups, this.config.adminGroups)`, performing the async fallback lookup only when the synchronous check is "deny due to empty groups on an admin command with enforcement on"; +- if denied → send the denial reply to `msg` (respecting its `targetMode`/sender) and return without executing; +- if allowed → `executeCommand(parsed, msg)` as today. + +`executeCommand` stays permission-agnostic, so the WebUI path is unaffected. + +**Live config:** `BotInstance` already holds the shared `config` object by reference (passed through `BotInstanceOptions`); `POST /api/bot/settings` mutates that same object in place, so reading `this.config.adminGroups` in the gate reflects edits immediately — no restart, no re-wiring. (Implementation must confirm the instance reads `adminGroups` from the live `config` reference, not a copied-at-construction value.) + +## Denied UX + +The bot replies to the sender with a short bilingual-ish message, e.g. `⛔ 需要管理员权限(该命令仅限管理员服务器组)`, via the same reply mechanism the command handlers already use, honoring the message's `targetMode` (private vs channel). No execution occurs. + +## Config surface + +**Backend** (`src/web/api/bot.ts`): extend the existing settings endpoints (already admin-gated: `GET` behind `requireNotGuest`, `POST` behind `requirePermission("bot.manage")`): +- `GET /api/bot/settings` → also return `adminGroups: number[]`. +- `POST /api/bot/settings` → also accept `adminGroups`; validate it is an array of non-negative integers (filter/reject otherwise), assign to `config.adminGroups`, `saveConfig`. Reuses the in-place-mutation + `saveConfig` pattern already used for idle-timeout/auto-pause/guestMode, so it is live-applied. + +**Frontend** (`web/src/views/Settings.vue`): a new admin-only section **"命令权限 / Command permissions"** (`v-if="session.isAdmin.value"`), mirroring the idle-timeout/guest-mode sections: +- a text input for comma-separated server-group IDs (parsed to `number[]`, ignoring blanks/non-numbers), a Save button calling `POST /api/bot/settings`, hydrated by the existing `loadIdleTimeout()` GET; +- hint: "仅这些组可运行 stop/clear/remove/move/vol/mode;留空 = 不限制(所有人可用)。如何查看服务器组 ID 见 README。" + +**`config.json`**: `adminGroups` continues to work for file-based config. + +## Testing + +- **`canRunCommand` unit tests** (`src/bot/commands.test.ts` or a new file): public command always allowed; admin command with empty `adminGroups` allowed; admin command with a matching group allowed; admin command with no matching group denied; string-vs-number normalization (`["6"]` matches `[6]`). +- **Handler gate tests:** a denied admin command does NOT call `executeCommand` and triggers a denial reply; an allowed admin command (matching group) and any public command DO call `executeCommand`. (Use a fake `msg` + a `config` with `adminGroups` set; stub the reply + `executeCommand`.) +- **Fallback path:** admin command with empty `invokerGroups` + enforcement on triggers the group lookup; if the lookup yields a matching group → allowed; if it yields nothing → denied (fail-closed). +- **Settings round-trip** (`src/web/api/bot.test.ts`): `POST /api/bot/settings` persists a validated `adminGroups`; `GET` returns it; invalid values (non-array, negative, non-integer) are rejected/filtered. +- **Frontend:** `vue-tsc --noEmit` clean. + +## Non-goals (YAGNI) + +- No per-group capability map and no whole-bot allowlist (binary admin gate only). +- No per-command customization of the admin/public split in the UI (the set is a code constant; reclassifying is a one-line edit). +- No server-group picker UI (admin types IDs; a picker that lists the bot's visible groups is a possible future enhancement). +- No new chat *management* commands. +- No change to the WebUI authorization model or `executeCommand` semantics. + +## Key files touched + +Backend: `src/bot/commands.ts` (admin-set constant + `canRunCommand` helper, repurpose the dead sets; +test), `src/bot/instance.ts` (gate in `handleTextMessage`, denial reply, live `adminGroups`), `src/ts-protocol/client.ts` (surface `invokerGroups` on `TS3TextMessage`; possibly a `getClientInfo` wrapper for the fallback), `src/web/api/bot.ts` (settings read/write `adminGroups`; +test). Possibly `src/data/config.ts` (no schema change; `adminGroups` already exists). + +Frontend: `web/src/views/Settings.vue` (admin-only 命令权限 section). + +Docs: `README.md` (document the feature + how to find TS server-group IDs). From f98ce47c52135a8589d4e3d05de0ee3ca06dd5f6 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:32:45 +0800 Subject: [PATCH 02/12] feat(commands): add canRunCommand gate helper + admin-set source of truth --- src/bot/commands.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/bot/commands.ts | 34 +++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 7 deletions(-) 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))); +} From b090a8ec216127c3cd3e557856d2005c6933b735 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:35:31 +0800 Subject: [PATCH 03/12] feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper --- src/ts-protocol/client.ts | 27 +++++++++++------ src/ts-protocol/text-message.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 src/ts-protocol/text-message.test.ts diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index f743421..31b317e 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -61,6 +61,24 @@ export interface TS3TextMessage { invokerUid: string; message: string; targetMode: number; // 1=private, 2=channel, 3=server + invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache +} + +/** + * Map the library's TextMessage to our wrapper. Preserves invokerGroups (the + * sender's TS server groups), which the library populates only when the sender + * is in the bot's client-view cache; otherwise it is []. Used by the chat + * command permission gate. + */ +export function toTS3TextMessage(msg: TextMessage): TS3TextMessage { + return { + invokerName: msg.invokerName, + invokerId: String(msg.invokerID), + invokerUid: msg.invokerUID, + message: msg.message, + targetMode: msg.targetMode, + invokerGroups: msg.invokerGroups ?? [], + }; } export class TS3Client extends EventEmitter { @@ -203,14 +221,7 @@ export class TS3Client extends EventEmitter { }); this.client.on("textMessage", (msg: TextMessage) => { - const tsMsg: TS3TextMessage = { - invokerName: msg.invokerName, - invokerId: String(msg.invokerID), - invokerUid: msg.invokerUID, - message: msg.message, - targetMode: msg.targetMode, - }; - this.emit("textMessage", tsMsg); + this.emit("textMessage", toTS3TextMessage(msg)); }); this.client.on("disconnected", (err) => { diff --git a/src/ts-protocol/text-message.test.ts b/src/ts-protocol/text-message.test.ts new file mode 100644 index 0000000..e83e30b --- /dev/null +++ b/src/ts-protocol/text-message.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { toTS3TextMessage } from "./client.js"; +import type { TextMessage } from "@honeybbq/teamspeak-client"; + +function makeMsg(over: Partial = {}): TextMessage { + return { + invokerName: "Alice", + invokerUID: "uid-abc", + message: "!stop", + invokerGroups: ["6", "8"], + targetMode: 2, + targetID: 0n, + invokerID: 5, + ...over, + }; +} + +describe("toTS3TextMessage", () => { + it("maps core fields and stringifies invokerID", () => { + const r = toTS3TextMessage(makeMsg()); + expect(r.invokerName).toBe("Alice"); + expect(r.invokerId).toBe("5"); + expect(r.invokerUid).toBe("uid-abc"); + expect(r.message).toBe("!stop"); + expect(r.targetMode).toBe(2); + }); + + it("preserves the sender's server groups", () => { + expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]); + }); + + it("defaults missing invokerGroups to an empty array", () => { + const partial = { + invokerName: "Bob", + invokerUID: "u", + message: "!stop", + targetMode: 1, + targetID: 0n, + invokerID: 7, + } as unknown as TextMessage; + expect(toTS3TextMessage(partial).invokerGroups).toEqual([]); + }); +}); From 72ffd44f68e33cc1585510313c3dd4d4f529ab01 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:40:01 +0800 Subject: [PATCH 04/12] feat(bot): gate admin chat commands on adminGroups with fallback + deny reply --- src/bot/instance.test.ts | 86 +++++++++++++++++++++++++++++++++++++++- src/bot/instance.ts | 54 +++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 751116c..1e0df26 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { BotInstance } from "./instance.js"; +import { describe, it, expect, vi } from "vitest"; +import { BotInstance, COMMAND_DENIED_MESSAGE } from "./instance.js"; +import type { TS3TextMessage } from "../ts-protocol/client.js"; // Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer, // reads avatars, etc.), and runExclusive only touches a single private field @@ -110,3 +111,84 @@ describe("BotInstance.runExclusive — serialization", () => { ]); }); }); + +/** Minimal `this` carrying only what handleTextMessage's gate path touches. + * The gate methods live on the prototype and are attached here so calls like + * `this.isCommandAllowed(...)` resolve against this same object. */ +function makeGateCtx(opts: { + adminGroups?: number[]; + clients?: Array<{ id: number; serverGroups: string[] }>; +}) { + const ctx: any = { + config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] }, + logger: { info: vi.fn(), error: vi.fn() }, + tsClient: { + sendTextMessage: vi.fn(async () => {}), + getClientsInChannel: vi.fn(async () => opts.clients ?? []), + }, + executeCommand: vi.fn(async () => null), + isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed, + lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups, + }; + return ctx; +} + +function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage { + return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups }; +} + +const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as ( + this: unknown, + msg: TS3TextMessage, +) => Promise; + +describe("BotInstance.handleTextMessage — command permission gate", () => { + it("runs a public command even with enforcement on", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!play 晴天")); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("runs an admin command when enforcement is off (empty adminGroups)", async () => { + const ctx = makeGateCtx({ adminGroups: [] }); + await handleTextMessage.call(ctx, makeMsg("!stop")); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + }); + + it("runs an admin command when the event carried a matching group", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!stop", ["6"])); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); // no fallback needed + }); + + it("denies an admin command when known groups do not match (no fallback, with reply)", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!stop", ["8"])); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("falls back to a group lookup when the event carried no groups, and allows on match", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["6"] }] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.tsClient.getClientsInChannel).toHaveBeenCalledTimes(1); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + }); + + it("fails closed when the fallback finds the client but no matching group", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["8"] }] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("fails closed when the fallback cannot find the client at all", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index e83ec25..263beab 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -9,7 +9,7 @@ import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js"; import type { MusicProvider, Song } from "../music/provider.js"; import { parseCommand, - isAdminCommand, + canRunCommand, type ParsedCommand, } from "./commands.js"; import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; @@ -24,6 +24,9 @@ import { shouldResumeOnReturn, } from "./auto-pause.js"; +/** Reply sent when a non-admin invokes an admin-only chat command. */ +export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; + export interface BotInstanceOptions { id: string; name: string; @@ -322,8 +325,17 @@ export class BotInstance extends EventEmitter { ); if (!parsed) return; - if (isAdminCommand(parsed.name)) { - // TODO: Check if invoker is in adminGroups + if (!(await this.isCommandAllowed(parsed.name, msg))) { + this.logger.info( + { command: parsed.name, invoker: msg.invokerName }, + "Command denied: invoker not in adminGroups" + ); + try { + await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE); + } catch (sendErr) { + this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat"); + } + return; } this.logger.info( @@ -348,6 +360,42 @@ export class BotInstance extends EventEmitter { } } + /** + * Decide whether a chat command may run for this sender. Reads adminGroups + * live from this.config (the router mutates the same object). Only performs + * the async group lookup when the synchronous decision is "deny because the + * event carried no groups" — i.e. an admin command, enforcement on, and + * empty invokerGroups. Fails closed if groups remain undeterminable. + */ + private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise { + const adminGroups = this.config.adminGroups; + if (canRunCommand(commandName, msg.invokerGroups, adminGroups)) return true; + // Here: admin command, enforcement on, and the provided groups did not match. + // If the event actually carried groups, this is a genuine deny — no lookup. + if (msg.invokerGroups.length > 0) return false; + // Groups unknown (sender not in the view cache): one targeted lookup, then + // re-decide. canRunCommand([], …) is false ⇒ fail-closed when still unknown. + const groups = await this.lookupInvokerGroups(msg.invokerId); + return canRunCommand(commandName, groups, adminGroups); + } + + /** + * Best-effort lookup of a sender's server groups by client id, via the + * channel client list (whose entries already carry parsed serverGroups). + * Returns [] when the client can't be found or the query fails (→ deny). + */ + private async lookupInvokerGroups(invokerId: string): Promise { + const clid = Number(invokerId); + if (!Number.isFinite(clid) || clid <= 0) return []; + try { + const clients = await this.tsClient.getClientsInChannel(); + const match = clients.find((c) => c.id === clid); + return match?.serverGroups ?? []; + } catch { + return []; + } + } + async executeCommand( cmd: ParsedCommand, msg?: TS3TextMessage From 3346286ffd288e90a73e74dfac79dcb113fa8efd Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:44:29 +0800 Subject: [PATCH 05/12] feat(api): read/write adminGroups in bot settings endpoints --- src/web/api/bot.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/web/api/bot.ts | 11 ++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index 9dd17f3..fd5de27 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -158,6 +158,44 @@ describe("bot router /settings", () => { expect(bot.autoPauseCalls).toEqual([]); } }); + + it("GET /settings includes adminGroups reflecting config", async () => { + config.adminGroups = [6, 8]; + const res = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(res.status).toBe(200); + expect(res.body.adminGroups).toEqual([6, 8]); + }); + + it("POST /settings persists a validated adminGroups and GET returns it", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ adminGroups: [6, 8] }); + expect(res.status).toBe(200); + expect(res.body.adminGroups).toEqual([6, 8]); + expect(config.adminGroups).toEqual([6, 8]); + const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(followUp.body.adminGroups).toEqual([6, 8]); + }); + + it("POST /settings filters invalid adminGroups entries (negative, non-integer, non-number)", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ adminGroups: [6, -1, 2.5, "x", 8] }); + expect(res.status).toBe(200); + expect(config.adminGroups).toEqual([6, 8]); + }); + + it("POST /settings ignores a non-array adminGroups (leaves config unchanged)", async () => { + config.adminGroups = [6]; + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ adminGroups: "6" }); + expect(res.status).toBe(200); + expect(config.adminGroups).toEqual([6]); + }); }); describe("bot router /settings guest-mode gating + persistence", () => { diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index d6d8338..fa1e0ce 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -36,6 +36,7 @@ export function createBotRouter( res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, + adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, }); }); @@ -43,7 +44,7 @@ export function createBotRouter( // POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot // behavior is a bot.manage operation, consistent with PR #80's permission model) router.post("/settings", requirePermission("bot.manage"), (req, res) => { - const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode } = req.body; + const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body; const hasIdle = idleTimeoutMinutes !== undefined; if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) { @@ -74,6 +75,13 @@ export function createBotRouter( } } + if (Array.isArray(adminGroups)) { + config.adminGroups = adminGroups.filter( + (g: unknown): g is number => + typeof g === "number" && Number.isInteger(g) && g >= 0, + ); + } + saveConfig(configPath, config); // Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a @@ -92,6 +100,7 @@ export function createBotRouter( res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, + adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, }); }); From 215e328f170fb92e4cb4c006cf0de7ca53165c3a Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:47:34 +0800 Subject: [PATCH 06/12] feat(web): admin-only command-permission (adminGroups) settings section --- web/src/views/Settings.vue | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index bc1a0ff..6e04ea0 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -504,6 +504,23 @@ + +
+

命令权限

+

+ 限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。 + 填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。 +

+
+
+ + +
+
+
+

机器人 Profile(TeamSpeak 行为)

@@ -1027,6 +1044,7 @@ async function loadIdleTimeout() { idleTimeout.value = res.data.idleTimeoutMinutes ?? 0; autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false; applyGuestModeFromServer(res.data.guestMode); + applyAdminGroupsFromServer(res.data.adminGroups); } catch { /* ignore */ } } @@ -1092,6 +1110,35 @@ async function saveGuestMode() { } } +// --- Command permissions (admin only) --- +const adminGroupsText = ref(''); +const adminGroupsSaving = ref(false); + +function applyAdminGroupsFromServer(groups: unknown) { + if (Array.isArray(groups)) { + adminGroupsText.value = groups.filter((g) => typeof g === 'number').join(', '); + } +} + +function parseAdminGroups(text: string): number[] { + return text + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map((s) => Number(s)) + .filter((n) => Number.isInteger(n) && n >= 0); +} + +async function saveAdminGroups() { + adminGroupsSaving.value = true; + try { + const res = await axios.post('/api/bot/settings', { adminGroups: parseAdminGroups(adminGroupsText.value) }); + applyAdminGroupsFromServer(res.data?.adminGroups); + } catch { /* ignore */ } finally { + adminGroupsSaving.value = false; + } +} + // --- Bot Profile config --- interface ProfileConfig { avatarEnabled: boolean; From 10e29476f4d799f8a9a690c7328f3b8dc1bb0e04 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:51:08 +0800 Subject: [PATCH 07/12] docs: document TeamSpeak chat-command permission control --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 846e6c8..221a68e 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,27 @@ sudo systemctl start tsmusicbot > 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next` +### TeamSpeak 命令权限(管理类命令限制) + +默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行: + +- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode` +- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放 +- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容) + +**配置方式** + +- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。 +- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。 + +填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限」的提示。 + +> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。 + +**如何查看服务器组 ID** + +在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。 + ### 音质等级 | 等级 | 码率 | 格式 | 说明 | From 17ab477af61254d2474daf9fd48ed3931eac0ead Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:53:56 +0800 Subject: [PATCH 08/12] docs: correct stale adminGroups references now that the feature ships --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 221a68e..f8b8cc7 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='' WH **反向代理用户特别注意**:如果通过 nginx / Caddy / Cloudflare 暴露 WebUI,**必须**在 `config.json` 中设置 `"trustProxy": true`,否则 Cookie 不会带 `Secure` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。 -**旧版 `config.adminPassword` / `adminGroups`**:这两个配置项在旧版本中预留但从未实际启用(TS-side admin 命令权限的占位字段)。保留以避免破坏旧 `config.json`,但不再影响任何行为。可以放心忽略。 +**`config.adminGroups`(现已启用)**:用于限制管理类聊天命令(`stop`/`clear`/`remove`/`move`/`vol`/`mode`)只能由指定 TeamSpeak 服务器组的成员运行;为空时不做任何限制(向后兼容)。详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`config.adminPassword` 则是旧版预留字段,当前版本未使用,保留以兼容旧 `config.json`,可以放心忽略。 ### Windows 用户 @@ -536,7 +536,7 @@ pip install -U yt-dlp > **配置文件位置变更**:旧版本把 `config.json` 写在项目根目录(不在 Docker 挂载卷内,导致重启丢失、手动编辑不生效)。现在统一放在 `data/config.json`。升级时若检测到根目录存在旧的 `config.json`,会在首次启动时自动迁移到 `data/` 并保留你的设置,无需手动操作。 -> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。 +> **关于 `adminPassword` 和 `adminGroups`**:`adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制),详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`adminPassword` 仍为旧版预留字段、当前版本未使用——WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。 ### 反向代理部署注意事项 @@ -652,7 +652,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部 - **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。 - **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。 - **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。 -- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminPassword` / `adminGroups` 字段保留以兼容旧 `config.json`,但不再影响任何行为。 +- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。 ### v0.x — Bot Profile 自动更新与协议层升级 From b387d6581e54de8126882d3bcb7ca431188b7423 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 26 Jun 2026 20:57:39 +0800 Subject: [PATCH 09/12] docs: TS chat-command permission implementation plan --- .../2026-06-25-ts-command-permissions.md | 840 ++++++++++++++++++ 1 file changed, 840 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-ts-command-permissions.md diff --git a/docs/superpowers/plans/2026-06-25-ts-command-permissions.md b/docs/superpowers/plans/2026-06-25-ts-command-permissions.md new file mode 100644 index 0000000..f8d406d --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-ts-command-permissions.md @@ -0,0 +1,840 @@ +# TeamSpeak chat-command permission control — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Gate a fixed set of "admin" TeamSpeak chat commands (`stop`, `clear`, `remove`, `move`, `vol`, `mode`) behind configured TS server-group IDs, opt-in and backward-compatible, configurable from the WebUI and `config.json`. + +**Architecture:** A pure helper `canRunCommand(name, invokerGroups, adminGroups)` decides allow/deny. The chat handler `handleTextMessage` (NOT the WebUI-shared `executeCommand`) consults it before executing, performs a best-effort group lookup when the sender's groups weren't delivered with the event, fails closed, and replies on deny. The privileged groups live in the already-declared `config.adminGroups`, surfaced through the existing `GET/POST /api/bot/settings` endpoints and an admin-only Settings.vue section. + +**Tech Stack:** Node 20, TypeScript (ESM), Express 5, Vitest + supertest (backend), Vue 3 + `vue-tsc` (frontend), `@honeybbq/teamspeak-client`. + +## Global Constraints + +- **ESM import specifiers:** every relative import ends in `.js` even in `.ts` files (e.g. `import { canRunCommand } from "./commands.js"`). +- **Admin command set (exact, single source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. (Note: `follow` is intentionally NOT admin — it becomes public.) +- **Enforcement is opt-in / backward-compatible:** `config.adminGroups === []` (the default) ⇒ no enforcement; admin commands stay open to everyone exactly as today. +- **Fail closed:** an admin command, with enforcement on, whose sender groups cannot be determined (even after fallback) is **denied**. +- **Group-id normalization:** `invokerGroups` are strings, `adminGroups` are numbers — compare as the same type so `"6"` matches `6`. +- **Denial reply text (exact):** `⛔ 需要管理员权限(该命令仅限管理员服务器组)`. +- **`adminGroups` validation:** array of non-negative integers; filter out everything else; ignore a non-array value entirely. +- **Live config:** `BotInstance` shares the same `config` object the router mutates; the gate reads `this.config.adminGroups` live (no restart, no propagation call). +- **Per-task tests:** run `npx vitest run ` (targets `.ts` directly). Before any full `npm test`, run `rm -rf dist` first — a stale untracked `dist/` makes vitest double-run compiled `.test.js` copies (known environment quirk). The repo path contains spaces (`/c/Users/saopig1/Music/teamspeak music bot`) — quote it. +- **Frontend type-check:** `cd web && npx vue-tsc --noEmit` (must be clean). +- **TDD + frequent commits:** every task is red→green→commit. Keep project `tsc`/`vitest` green after each task. + +--- + +### Task 1: `canRunCommand` helper + admin-set as single source of truth + +**Files:** +- Modify: `src/bot/commands.ts` (lines 8-16 sets; line 59-61 `isAdminCommand`) +- Test: `src/bot/commands.test.ts` (append a new `describe` block) + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: + - `export const ADMIN_COMMANDS: Set` = `{stop, clear, remove, move, vol, mode}` + - `export function isAdminCommand(commandName: string): boolean` (unchanged signature) + - `export function canRunCommand(commandName: string, invokerGroups: readonly (string | number)[], adminGroups: readonly number[]): boolean` — consumed by Task 3. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/bot/commands.test.ts`: + +```ts +import { canRunCommand, isAdminCommand } from "./commands.js"; + +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); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run "src/bot/commands.test.ts"` +Expected: FAIL — `canRunCommand` is not exported / not a function. + +- [ ] **Step 3: Implement the helper and tighten the admin set** + +In `src/bot/commands.ts`, delete the dead `PUBLIC_COMMANDS` export (nothing imports it; the admin set is the sole source of truth), set `ADMIN_COMMANDS` to the exact spec set (drop `follow`), and add `canRunCommand`. The file becomes: + +```ts +export interface ParsedCommand { + name: string; + args: string; + rawArgs: string[]; + flags: Set; +} + +/** + * 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", "remove", "move", "vol", "mode", +]); + +export function parseCommand( + message: string, + prefix: string, + aliases: Record = {}, +): ParsedCommand | null { + const trimmed = message.trim(); + if (!trimmed.startsWith(prefix)) return null; + + const withoutPrefix = trimmed.slice(prefix.length); + if (!withoutPrefix) return null; + + const parts = withoutPrefix.split(/\s+/); + let name = parts[0].toLowerCase(); + + if (aliases[name]) { + name = aliases[name]; + } + + const flags = new Set(); + const argParts: string[] = []; + + for (let i = 1; i < parts.length; i++) { + if ( + parts[i].startsWith("-") && + parts[i].length === 2 && + /[a-zA-Z]/.test(parts[i][1]) + ) { + flags.add(parts[i][1].toLowerCase()); + } else { + argParts.push(parts[i]); + } + } + + return { + name, + args: argParts.join(" "), + rawArgs: argParts, + flags, + }; +} + +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))); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run "src/bot/commands.test.ts"` +Expected: PASS (parser tests + the new classification/canRunCommand tests). + +- [ ] **Step 5: Verify nothing else imported the deleted symbol** + +Run: `grep -rn "PUBLIC_COMMANDS" src/` +Expected: no matches (confirms the deletion is safe). + +- [ ] **Step 6: Commit** + +```bash +git add "src/bot/commands.ts" "src/bot/commands.test.ts" +git commit -m "feat(commands): add canRunCommand gate helper + admin-set source of truth" +``` + +--- + +### Task 2: Surface `invokerGroups` on `TS3TextMessage` + +**Files:** +- Modify: `src/ts-protocol/client.ts` (interface lines 58-64; mapping lines 205-214) +- Test: `src/ts-protocol/text-message.test.ts` (new) + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: + - `TS3TextMessage` gains `invokerGroups: string[]`. + - `export function toTS3TextMessage(msg: TextMessage): TS3TextMessage` — a pure mapper, used by the `textMessage` event handler and unit-testable. Consumed (the field) by Task 3. + +- [ ] **Step 1: Write the failing test** + +Create `src/ts-protocol/text-message.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { toTS3TextMessage } from "./client.js"; +import type { TextMessage } from "@honeybbq/teamspeak-client"; + +function makeMsg(over: Partial = {}): TextMessage { + return { + invokerName: "Alice", + invokerUID: "uid-abc", + message: "!stop", + invokerGroups: ["6", "8"], + targetMode: 2, + targetID: 0n, + invokerID: 5, + ...over, + }; +} + +describe("toTS3TextMessage", () => { + it("maps core fields and stringifies invokerID", () => { + const r = toTS3TextMessage(makeMsg()); + expect(r.invokerName).toBe("Alice"); + expect(r.invokerId).toBe("5"); + expect(r.invokerUid).toBe("uid-abc"); + expect(r.message).toBe("!stop"); + expect(r.targetMode).toBe(2); + }); + + it("preserves the sender's server groups", () => { + expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]); + }); + + it("defaults missing invokerGroups to an empty array", () => { + const partial = { + invokerName: "Bob", + invokerUID: "u", + message: "!stop", + targetMode: 1, + targetID: 0n, + invokerID: 7, + } as unknown as TextMessage; + expect(toTS3TextMessage(partial).invokerGroups).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run "src/ts-protocol/text-message.test.ts"` +Expected: FAIL — `toTS3TextMessage` is not exported. + +- [ ] **Step 3: Add the field and the pure mapper, and use it in the handler** + +In `src/ts-protocol/client.ts`, extend the interface (add `invokerGroups`): + +```ts +export interface TS3TextMessage { + invokerName: string; + invokerId: string; + invokerUid: string; + message: string; + targetMode: number; // 1=private, 2=channel, 3=server + invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache +} +``` + +Add the pure mapper just below the interface (still above the `TS3Client` class): + +```ts +/** + * Map the library's TextMessage to our wrapper. Preserves invokerGroups (the + * sender's TS server groups), which the library populates only when the sender + * is in the bot's client-view cache; otherwise it is []. Used by the chat + * command permission gate. + */ +export function toTS3TextMessage(msg: TextMessage): TS3TextMessage { + return { + invokerName: msg.invokerName, + invokerId: String(msg.invokerID), + invokerUid: msg.invokerUID, + message: msg.message, + targetMode: msg.targetMode, + invokerGroups: msg.invokerGroups ?? [], + }; +} +``` + +Replace the inline mapping inside `this.client.on("textMessage", ...)` (currently lines 205-214) with a call to the mapper: + +```ts + this.client.on("textMessage", (msg: TextMessage) => { + this.emit("textMessage", toTS3TextMessage(msg)); + }); +``` + +(`TextMessage` is already imported at the top of the file.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run "src/ts-protocol/text-message.test.ts"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add "src/ts-protocol/client.ts" "src/ts-protocol/text-message.test.ts" +git commit -m "feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper" +``` + +--- + +### Task 3: Permission gate in `handleTextMessage` (fallback lookup + fail-closed + denial reply) + +**Files:** +- Modify: `src/bot/instance.ts` (imports lines 10-14; add a module constant; `handleTextMessage` lines 317-349; add two private methods) +- Test: `src/bot/instance.test.ts` (append a new `describe` block) + +**Interfaces:** +- Consumes: + - `canRunCommand(commandName, invokerGroups, adminGroups)` from `./commands.js` (Task 1). + - `TS3TextMessage.invokerGroups: string[]` (Task 2). + - Existing `this.tsClient.getClientsInChannel(): Promise` where each `ClientInfo` has `id: number` and `serverGroups: string[]` (library already parses these). + - Existing `this.tsClient.sendTextMessage(message: string, targetMode?: number): Promise`. +- Produces: + - `export const COMMAND_DENIED_MESSAGE: string` (exported so the test can assert it). + - Private `isCommandAllowed(commandName, msg)` and `lookupInvokerGroups(invokerId)` (exercised via prototype in the test). + +- [ ] **Step 1: Write the failing tests** + +Append to `src/bot/instance.test.ts`: + +```ts +import { vi } from "vitest"; +import { COMMAND_DENIED_MESSAGE } from "./instance.js"; +import type { TS3TextMessage } from "../ts-protocol/client.js"; + +/** Minimal `this` carrying only what handleTextMessage's gate path touches. + * The gate methods live on the prototype and are attached here so calls like + * `this.isCommandAllowed(...)` resolve against this same object. */ +function makeGateCtx(opts: { + adminGroups?: number[]; + clients?: Array<{ id: number; serverGroups: string[] }>; +}) { + const ctx: any = { + config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] }, + logger: { info: vi.fn(), error: vi.fn() }, + tsClient: { + sendTextMessage: vi.fn(async () => {}), + getClientsInChannel: vi.fn(async () => opts.clients ?? []), + }, + executeCommand: vi.fn(async () => null), + isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed, + lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups, + }; + return ctx; +} + +function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage { + return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups }; +} + +const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as ( + this: unknown, + msg: TS3TextMessage, +) => Promise; + +describe("BotInstance.handleTextMessage — command permission gate", () => { + it("runs a public command even with enforcement on", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!play 晴天")); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("runs an admin command when enforcement is off (empty adminGroups)", async () => { + const ctx = makeGateCtx({ adminGroups: [] }); + await handleTextMessage.call(ctx, makeMsg("!stop")); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + }); + + it("runs an admin command when the event carried a matching group", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!stop", ["6"])); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); // no fallback needed + }); + + it("denies an admin command when known groups do not match (no fallback, with reply)", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!stop", ["8"])); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("falls back to a group lookup when the event carried no groups, and allows on match", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["6"] }] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.tsClient.getClientsInChannel).toHaveBeenCalledTimes(1); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + }); + + it("fails closed when the fallback finds the client but no matching group", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["8"] }] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("fails closed when the fallback cannot find the client at all", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run "src/bot/instance.test.ts"` +Expected: FAIL — `COMMAND_DENIED_MESSAGE` is not exported; `isCommandAllowed`/`lookupInvokerGroups` are undefined. + +- [ ] **Step 3: Implement the gate** + +In `src/bot/instance.ts`, change the commands import (lines 10-14) from `isAdminCommand` to `canRunCommand`: + +```ts +import { + parseCommand, + canRunCommand, + type ParsedCommand, +} from "./commands.js"; +``` + +Add a module-level constant just after the imports (above `export interface BotInstanceOptions`): + +```ts +/** Reply sent when a non-admin invokes an admin-only chat command. */ +export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; +``` + +Replace `handleTextMessage` (lines 317-349) so the dead stub becomes the real gate: + +```ts + private async handleTextMessage(msg: TS3TextMessage): Promise { + const parsed = parseCommand( + msg.message, + this.config.commandPrefix, + this.config.commandAliases + ); + if (!parsed) return; + + if (!(await this.isCommandAllowed(parsed.name, msg))) { + this.logger.info( + { command: parsed.name, invoker: msg.invokerName }, + "Command denied: invoker not in adminGroups" + ); + try { + await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE); + } catch (sendErr) { + this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat"); + } + return; + } + + this.logger.info( + { command: parsed.name, args: parsed.args, invoker: msg.invokerName }, + "Command received" + ); + + try { + const response = await this.executeCommand(parsed, msg); + if (response) { + await this.tsClient.sendTextMessage(response); + } + } catch (err) { + this.logger.error({ err, command: parsed.name }, "Command execution error"); + try { + await this.tsClient.sendTextMessage( + `Error: ${(err as Error).message}` + ); + } catch (sendErr) { + this.logger.error({ err: sendErr }, "Failed to send error message to chat"); + } + } + } + + /** + * Decide whether a chat command may run for this sender. Reads adminGroups + * live from this.config (the router mutates the same object). Only performs + * the async group lookup when the synchronous decision is "deny because the + * event carried no groups" — i.e. an admin command, enforcement on, and + * empty invokerGroups. Fails closed if groups remain undeterminable. + */ + private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise { + const adminGroups = this.config.adminGroups; + if (canRunCommand(commandName, msg.invokerGroups, adminGroups)) return true; + // Here: admin command, enforcement on, and the provided groups did not match. + // If the event actually carried groups, this is a genuine deny — no lookup. + if (msg.invokerGroups.length > 0) return false; + // Groups unknown (sender not in the view cache): one targeted lookup, then + // re-decide. canRunCommand([], …) is false ⇒ fail-closed when still unknown. + const groups = await this.lookupInvokerGroups(msg.invokerId); + return canRunCommand(commandName, groups, adminGroups); + } + + /** + * Best-effort lookup of a sender's server groups by client id, via the + * channel client list (whose entries already carry parsed serverGroups). + * Returns [] when the client can't be found or the query fails (→ deny). + */ + private async lookupInvokerGroups(invokerId: string): Promise { + const clid = Number(invokerId); + if (!Number.isFinite(clid) || clid <= 0) return []; + try { + const clients = await this.tsClient.getClientsInChannel(); + const match = clients.find((c) => c.id === clid); + return match?.serverGroups ?? []; + } catch { + return []; + } + } +``` + +- [ ] **Step 4: Run the gate tests to verify they pass** + +Run: `npx vitest run "src/bot/instance.test.ts"` +Expected: PASS (existing `runExclusive` tests + the 7 new gate tests). + +- [ ] **Step 5: Confirm the live-config invariant** + +Confirm `BotInstance` reads `adminGroups` from the shared, mutable config — not a copy. The constructor stores `this.config = options.config` (line 91 region) and the router (`src/web/api/bot.ts`) mutates that same object; no propagation call is needed. Quick check: + +Run: `grep -n "this.config = options.config\|this.config.adminGroups" "src/bot/instance.ts"` +Expected: shows the assignment and the gate read (proves the gate uses the live reference). + +- [ ] **Step 6: Commit** + +```bash +git add "src/bot/instance.ts" "src/bot/instance.test.ts" +git commit -m "feat(bot): gate admin chat commands on adminGroups with fallback + deny reply" +``` + +--- + +### Task 4: Read/write `adminGroups` in the settings endpoints + +**Files:** +- Modify: `src/web/api/bot.ts` (GET `/settings` lines 35-41; POST `/settings` lines 45-97) +- Test: `src/web/api/bot.test.ts` (append `it` cases to the first `describe("bot router /settings", …)` block) + +**Interfaces:** +- Consumes: existing `config.adminGroups: number[]` (already declared in `src/data/config.ts`, default `[]`). +- Produces: `GET /api/bot/settings` returns `adminGroups: number[]`; `POST /api/bot/settings` accepts, validates, persists, and echoes `adminGroups`. + +- [ ] **Step 1: Write the failing tests** + +Append these `it` cases inside the existing first `describe("bot router /settings", …)` block in `src/web/api/bot.test.ts` (it already wires `app`, `config`, and an admin `cookie`): + +```ts + it("GET /settings includes adminGroups reflecting config", async () => { + config.adminGroups = [6, 8]; + const res = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(res.status).toBe(200); + expect(res.body.adminGroups).toEqual([6, 8]); + }); + + it("POST /settings persists a validated adminGroups and GET returns it", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ adminGroups: [6, 8] }); + expect(res.status).toBe(200); + expect(res.body.adminGroups).toEqual([6, 8]); + expect(config.adminGroups).toEqual([6, 8]); + const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(followUp.body.adminGroups).toEqual([6, 8]); + }); + + it("POST /settings filters invalid adminGroups entries (negative, non-integer, non-number)", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ adminGroups: [6, -1, 2.5, "x", 8] }); + expect(res.status).toBe(200); + expect(config.adminGroups).toEqual([6, 8]); + }); + + it("POST /settings ignores a non-array adminGroups (leaves config unchanged)", async () => { + config.adminGroups = [6]; + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ adminGroups: "6" }); + expect(res.status).toBe(200); + expect(config.adminGroups).toEqual([6]); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run "src/web/api/bot.test.ts"` +Expected: FAIL — `res.body.adminGroups` is `undefined`; the POST does not persist `adminGroups`. + +- [ ] **Step 3: Extend the GET handler** + +In `src/web/api/bot.ts`, add `adminGroups` to the GET `/settings` response (the handler at lines 35-41): + +```ts + router.get("/settings", requireNotGuest, (_req, res) => { + res.json({ + idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, + autoPauseOnEmpty: config.autoPauseOnEmpty, + adminGroups: config.adminGroups ?? [], + guestMode: config.guestMode, + }); + }); +``` + +- [ ] **Step 4: Extend the POST handler** + +In the POST `/settings` handler: (a) pull `adminGroups` out of `req.body`; (b) validate + assign before `saveConfig`; (c) echo it in the response. Change the destructuring line (46): + +```ts + const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body; +``` + +Add this block just before `saveConfig(configPath, config);` (line 77): + +```ts + if (Array.isArray(adminGroups)) { + config.adminGroups = adminGroups.filter( + (g: unknown): g is number => + typeof g === "number" && Number.isInteger(g) && g >= 0, + ); + } +``` + +Add `adminGroups` to BOTH `res.json({ … })` bodies in this handler (the success response near line 92, and — if present — keep them consistent): + +```ts + res.json({ + idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, + autoPauseOnEmpty: config.autoPauseOnEmpty, + adminGroups: config.adminGroups ?? [], + guestMode: config.guestMode, + }); +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run "src/web/api/bot.test.ts"` +Expected: PASS (existing settings/guest-mode tests + the 4 new adminGroups tests). + +- [ ] **Step 6: Commit** + +```bash +git add "src/web/api/bot.ts" "src/web/api/bot.test.ts" +git commit -m "feat(api): read/write adminGroups in bot settings endpoints" +``` + +--- + +### Task 5: Admin-only "命令权限" section in Settings.vue + +**Files:** +- Modify: `web/src/views/Settings.vue` (template: add a section after the Guest Mode section, before the Bot Profile section ~line 506; script: add state + handlers near the guest-mode block ~line 1093; hydrate in `loadIdleTimeout` ~line 1024) + +**Interfaces:** +- Consumes: `GET /api/bot/settings` → `adminGroups: number[]`; `POST /api/bot/settings` with `{ adminGroups: number[] }` (Task 4). Existing `session.isAdmin.value`. +- Produces: UI only. + +- [ ] **Step 1: Add the template section** + +In `web/src/views/Settings.vue`, insert this `
` immediately AFTER the closing `
` of the Guest Mode block (the one whose title is `游客模式`, ends ~line 505) and BEFORE the `` section: + +```html + +
+

命令权限

+

+ 限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。 + 填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。 +

+
+
+ + +
+
+
+``` + +- [ ] **Step 2: Add the script state + handlers** + +In the `