# 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 `