mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #104 from ZHANGTIANYAO1/feat/ts-command-permissions
feat: TeamSpeak chat-command permission control (adminGroups)
This commit is contained in:
15 files changed
+1493
-25
No files matched your search
@@ -222,7 +222,7 @@ sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='<paste-hash-here>' 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 用户
|
||||
|
||||
@@ -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 填入上面的设置即可。
|
||||
|
||||
### 音质等级
|
||||
|
||||
| 等级 | 码率 | 格式 | 说明 |
|
||||
@@ -515,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` 中设置密码。
|
||||
|
||||
### 反向代理部署注意事项
|
||||
|
||||
@@ -631,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 自动更新与协议层升级
|
||||
|
||||
|
||||
@@ -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 <file>` (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<string>` = `{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<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> = {},
|
||||
): 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<string>();
|
||||
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> = {}): 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<ClientInfo[]>` where each `ClientInfo` has `id: number` and `serverGroups: string[]` (library already parses these).
|
||||
- Existing `this.tsClient.sendTextMessage(message: string, targetMode?: number): Promise<void>`.
|
||||
- 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<void>;
|
||||
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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 `<section>` immediately AFTER the closing `</section>` of the Guest Mode block (the one whose title is `游客模式`, ends ~line 505) and BEFORE the `<!-- Bot Profile … -->` section:
|
||||
|
||||
```html
|
||||
<!-- Command Permissions (admin only) -->
|
||||
<section v-if="session.isAdmin.value" class="settings-section">
|
||||
<h2 class="section-title">命令权限</h2>
|
||||
<p class="profile-section-hint">
|
||||
限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。
|
||||
填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。
|
||||
</p>
|
||||
<div class="setting-row">
|
||||
<div class="prefix-input-wrap">
|
||||
<input v-model="adminGroupsText" class="input input-sm" placeholder="如 6, 8" />
|
||||
<button class="btn-primary" :disabled="adminGroupsSaving" @click="saveAdminGroups">
|
||||
{{ adminGroupsSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the script state + handlers**
|
||||
|
||||
In the `<script setup>` block, add this just after the guest-mode block (after `saveGuestMode` closes, ~line 1093):
|
||||
|
||||
```ts
|
||||
// --- 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Hydrate on load**
|
||||
|
||||
In `loadIdleTimeout` (the existing function ~lines 1024-1031), add the hydrate call alongside `applyGuestModeFromServer`:
|
||||
|
||||
```ts
|
||||
async function loadIdleTimeout() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||
applyGuestModeFromServer(res.data.guestMode);
|
||||
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check the frontend**
|
||||
|
||||
Run: `cd "web" && npx vue-tsc --noEmit`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add "web/src/views/Settings.vue"
|
||||
git commit -m "feat(web): admin-only command-permission (adminGroups) settings section"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Document the feature in the README
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (docs).
|
||||
- Produces: user-facing documentation of the feature + how to find TS server-group IDs.
|
||||
|
||||
- [ ] **Step 1: Locate the insertion point**
|
||||
|
||||
Run: `grep -n "游客模式\|Guest\|权限\|adminGroups" "README.md"`
|
||||
Expected: shows the guest-mode / permissions area. Insert the new subsection immediately after the guest-mode documentation block (or, if there is a dedicated permissions/features section, at its end).
|
||||
|
||||
- [ ] **Step 2: Add the documentation block**
|
||||
|
||||
Insert this markdown at the chosen point:
|
||||
|
||||
```markdown
|
||||
### 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 填入上面的设置即可。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity-check the docs render**
|
||||
|
||||
Run: `grep -n "命令权限\|adminGroups" "README.md"`
|
||||
Expected: shows the newly added section.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add "README.md"
|
||||
git commit -m "docs: document TeamSpeak chat-command permission control"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (after all tasks)
|
||||
|
||||
- [ ] Remove stale compiled output, then run the full suite:
|
||||
|
||||
```bash
|
||||
rm -rf dist
|
||||
npm test
|
||||
```
|
||||
Expected: all tests pass (the new `canRunCommand`, `toTS3TextMessage`, gate, and `adminGroups` settings tests included).
|
||||
|
||||
- [ ] Full build (backend `tsc` + frontend `vue-tsc` + vite):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: SUCCESS (no type errors).
|
||||
@@ -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).
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCommand } from "./commands.js";
|
||||
import { parseCommand, canRunCommand, isAdminCommand } from "./commands.js";
|
||||
|
||||
describe("Command Parser", () => {
|
||||
it("parses simple command", () => {
|
||||
@@ -60,3 +60,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);
|
||||
});
|
||||
});
|
||||
+27
-7
@@ -5,14 +5,13 @@ export interface ParsedCommand {
|
||||
flags: Set<string>;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
+102
-2
@@ -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,102 @@ 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[];
|
||||
lookupGroups?: string[];
|
||||
lookupThrows?: boolean;
|
||||
}) {
|
||||
const ctx: any = {
|
||||
config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] },
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
tsClient: {
|
||||
sendTextMessage: vi.fn(async () => {}),
|
||||
getClientServerGroups: vi.fn(async () => {
|
||||
if (opts.lookupThrows) throw new Error("query failed");
|
||||
return opts.lookupGroups ?? [];
|
||||
}),
|
||||
},
|
||||
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<void>;
|
||||
|
||||
describe("BotInstance.handleTextMessage — command permission gate", () => {
|
||||
it("runs a public command with no group lookup, even under enforcement", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!play 晴天", ["6"]));
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("runs an admin command with no lookup when enforcement is off", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop"));
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows an enforced admin command when the live lookup returns a matching group", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop"));
|
||||
expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("denies an enforced admin command when the live lookup has no matching group", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop"));
|
||||
expect(ctx.executeCommand).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("fails closed when the live lookup returns no groups", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: [] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop"));
|
||||
expect(ctx.executeCommand).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("fails closed when the live lookup throws", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], lookupThrows: true });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop"));
|
||||
expect(ctx.executeCommand).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("ignores stale event groups: a demoted sender (cached match) is denied by the live lookup", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", ["6"]));
|
||||
expect(ctx.executeCommand).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("uses live groups, not stale event groups: a freshly-promoted sender is allowed", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", ["8"]));
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves out-of-channel senders server-wide: empty event groups but a matching live group → allowed", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
|
||||
expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+50
-3
@@ -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,41 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a chat command may run for this sender. Reads adminGroups
|
||||
* live from this.config. Public commands and the enforcement-off case are
|
||||
* allowed with NO query. For an ENFORCED admin command we resolve the
|
||||
* sender's CURRENT server groups with a targeted server-wide lookup rather
|
||||
* than trusting the text event's cached groups — those are empty for
|
||||
* out-of-channel senders and stale after a live promotion/demotion. Fails
|
||||
* closed when the groups can't be determined.
|
||||
*/
|
||||
private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise<boolean> {
|
||||
const adminGroups = this.config.adminGroups;
|
||||
// Public command, or enforcement off → allow without any lookup.
|
||||
// (canRunCommand with empty groups is true iff the command is public OR
|
||||
// adminGroups is empty.)
|
||||
if (canRunCommand(commandName, [], adminGroups)) return true;
|
||||
// Enforced admin command: authoritative decision uses freshly-resolved,
|
||||
// server-wide groups. Fail closed if they can't be determined.
|
||||
const groups = await this.lookupInvokerGroups(msg.invokerId);
|
||||
return canRunCommand(commandName, groups, adminGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the sender's current server groups by client id, server-wide.
|
||||
* Returns [] on a bad id or query failure (→ fail-closed deny upstream).
|
||||
*/
|
||||
private async lookupInvokerGroups(invokerId: string): Promise<string[]> {
|
||||
const clid = Number(invokerId);
|
||||
if (!Number.isFinite(clid) || clid <= 0) return [];
|
||||
try {
|
||||
return await this.tsClient.getClientServerGroups(clid);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async executeCommand(
|
||||
cmd: ParsedCommand,
|
||||
msg?: TS3TextMessage
|
||||
|
||||
@@ -177,3 +177,29 @@ describe("guestMode config", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminGroups normalization", () => {
|
||||
function loadAdminGroups(raw: unknown) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(p, JSON.stringify(raw));
|
||||
try {
|
||||
return loadConfig(p).adminGroups;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("defaults to [] when absent", () => {
|
||||
expect(loadAdminGroups({})).toEqual([]);
|
||||
});
|
||||
it("keeps valid non-negative integers", () => {
|
||||
expect(loadAdminGroups({ adminGroups: [6, 8] })).toEqual([6, 8]);
|
||||
});
|
||||
it("filters out negatives, non-integers and non-numbers", () => {
|
||||
expect(loadAdminGroups({ adminGroups: [6, -1, 2.5, "8", null] })).toEqual([6]);
|
||||
});
|
||||
it("a non-array value falls back to the default [] (no crash)", () => {
|
||||
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -104,9 +104,20 @@ export function loadConfig(path: string): BotConfig {
|
||||
gm.permissions[f] = gm.permissions[f] === true;
|
||||
}
|
||||
|
||||
// Sanitize adminGroups on load too: the WebUI write path filters it, but a
|
||||
// hand-edited / legacy / corrupt config.json reaches the command gate
|
||||
// directly. Keep only non-negative integers; a non-array falls back to the
|
||||
// default []. Mirrors the guestMode sanitization above.
|
||||
const adminGroups = Array.isArray(partial.adminGroups)
|
||||
? partial.adminGroups.filter(
|
||||
(g): g is number => typeof g === "number" && Number.isInteger(g) && g >= 0,
|
||||
)
|
||||
: defaults.adminGroups;
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...partial,
|
||||
adminGroups,
|
||||
guestMode: gm,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import pino from "pino";
|
||||
import { TS3Client } from "./client.js";
|
||||
|
||||
/**
|
||||
* Integration "smoke test" for the admin-command gate's group resolution.
|
||||
*
|
||||
* It drives the REAL TS3Client.getClientServerGroups → library getClientInfo
|
||||
* path against a stubbed underlying client, so it exercises the actual
|
||||
* `clientinfo clid=<id>` query string and the real `client_servergroups`
|
||||
* parsing — the pieces that were previously only verified by reading the code.
|
||||
*
|
||||
* What this CANNOT cover (inherently server-side, needs a live TS server):
|
||||
* whether a real server returns groups for a client in a DIFFERENT channel.
|
||||
* The stub models the server-wide answer (groups returned regardless of
|
||||
* channel); the failure modes below confirm we fail closed when it doesn't.
|
||||
*/
|
||||
function makeClient(): TS3Client {
|
||||
return new TS3Client(
|
||||
{ host: "localhost", port: 9987, queryPort: 10011, nickname: "TestBot" },
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Inject a fake low-level client carrying a canned clientinfo response. */
|
||||
function withFakeClient(
|
||||
ts: TS3Client,
|
||||
respond: (cmd: string) => Record<string, string>[] | Promise<Record<string, string>[]>,
|
||||
): string[] {
|
||||
const calls: string[] = [];
|
||||
const fake = {
|
||||
execCommandWithResponse: vi.fn(async (cmd: string) => {
|
||||
calls.push(cmd);
|
||||
return respond(cmd);
|
||||
}),
|
||||
};
|
||||
(ts as unknown as { client: unknown }).client = fake;
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("TS3Client.getClientServerGroups — live query + parse smoke test", () => {
|
||||
it("issues `clientinfo clid=<id>` and parses comma-separated client_servergroups", async () => {
|
||||
const ts = makeClient();
|
||||
const calls = withFakeClient(ts, () => [
|
||||
{ client_nickname: "Alice", cid: "99", client_servergroups: "6,8" },
|
||||
]);
|
||||
|
||||
const groups = await ts.getClientServerGroups(5);
|
||||
|
||||
expect(groups).toEqual(["6", "8"]);
|
||||
// Exact query the bot sends to resolve a sender's groups, by client id.
|
||||
expect(calls[0]).toBe("clientinfo clid=5");
|
||||
});
|
||||
|
||||
it("parses a single-group response", async () => {
|
||||
const ts = makeClient();
|
||||
withFakeClient(ts, () => [{ client_servergroups: "6" }]);
|
||||
expect(await ts.getClientServerGroups(5)).toEqual(["6"]);
|
||||
});
|
||||
|
||||
it("returns [] when the client carries no server groups (empty field)", async () => {
|
||||
const ts = makeClient();
|
||||
withFakeClient(ts, () => [{ client_nickname: "Bob", client_servergroups: "" }]);
|
||||
expect(await ts.getClientServerGroups(7)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when the server-groups field is absent", async () => {
|
||||
const ts = makeClient();
|
||||
withFakeClient(ts, () => [{ client_nickname: "Carol" }]);
|
||||
expect(await ts.getClientServerGroups(7)).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails closed (returns []) when the query throws / client id is unknown", async () => {
|
||||
const ts = makeClient();
|
||||
withFakeClient(ts, () => {
|
||||
throw new Error("invalid clientID");
|
||||
});
|
||||
expect(await ts.getClientServerGroups(999)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when not connected (no underlying client)", async () => {
|
||||
const ts = makeClient();
|
||||
expect(await ts.getClientServerGroups(5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
listChannels,
|
||||
listClients,
|
||||
clientMove,
|
||||
getClientInfo,
|
||||
fileTransferDeleteFile,
|
||||
type Identity,
|
||||
type TextMessage,
|
||||
@@ -61,6 +62,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 +222,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) => {
|
||||
@@ -322,6 +334,26 @@ export class TS3Client extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client's CURRENT server groups by client id, server-wide (works
|
||||
* regardless of channel/view) via a targeted `clientinfo` query. The raw
|
||||
* `client_servergroups` field is a comma-separated list (same field
|
||||
* `listClients` parses). Returns [] if the client can't be resolved or the
|
||||
* query fails, so callers fail closed.
|
||||
*/
|
||||
async getClientServerGroups(clid: number): Promise<string[]> {
|
||||
if (!this.client) return [];
|
||||
try {
|
||||
const info = await getClientInfo(this.client, clid);
|
||||
// `client_servergroups`: comma-separated server-group ids (verified in
|
||||
// @honeybbq/teamspeak-client dist/index.mjs; listClients parses the same).
|
||||
const raw = info.client_servergroups ?? "";
|
||||
return raw ? raw.split(",") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Raw command & file transfer pass-through ---
|
||||
|
||||
async execCommand(cmd: string): Promise<void> {
|
||||
|
||||
@@ -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> = {}): 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
+10
-1
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,6 +504,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Command Permissions (admin only) -->
|
||||
<section v-if="session.isAdmin.value" class="settings-section">
|
||||
<h2 class="section-title">命令权限</h2>
|
||||
<p class="profile-section-hint">
|
||||
限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。
|
||||
填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。
|
||||
</p>
|
||||
<div class="setting-row">
|
||||
<div class="prefix-input-wrap">
|
||||
<input v-model="adminGroupsText" class="input input-sm" placeholder="如 6, 8" />
|
||||
<button class="btn-primary" :disabled="adminGroupsSaving" @click="saveAdminGroups">
|
||||
{{ adminGroupsSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||
<section v-if="can('bot.manage')" class="settings-section">
|
||||
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user