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` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。
|
**反向代理用户特别注意**:如果通过 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 用户
|
### Windows 用户
|
||||||
|
|
||||||
@@ -347,6 +347,27 @@ sudo systemctl start tsmusicbot
|
|||||||
|
|
||||||
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
|
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!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/` 并保留你的设置,无需手动操作。
|
> **配置文件位置变更**:旧版本把 `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 个并发会话(超出剔除最旧)。
|
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
|
||||||
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
|
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
|
||||||
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
|
- **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 自动更新与协议层升级
|
### 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 { describe, it, expect } from "vitest";
|
||||||
import { parseCommand } from "./commands.js";
|
import { parseCommand, canRunCommand, isAdminCommand } from "./commands.js";
|
||||||
|
|
||||||
describe("Command Parser", () => {
|
describe("Command Parser", () => {
|
||||||
it("parses simple command", () => {
|
it("parses simple command", () => {
|
||||||
@@ -60,3 +60,36 @@ describe("Command Parser", () => {
|
|||||||
expect(result!.args).toBe("3");
|
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>;
|
flags: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PUBLIC_COMMANDS = new Set([
|
/**
|
||||||
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
|
||||||
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
* for which commands the permission gate restricts; reclassifying a command is
|
||||||
"artist",
|
* a one-line edit here. Everything not in this set is public.
|
||||||
]);
|
*/
|
||||||
|
|
||||||
export const ADMIN_COMMANDS = new Set([
|
export const ADMIN_COMMANDS = new Set([
|
||||||
"stop", "clear", "move", "vol", "mode", "follow", "remove",
|
"stop", "clear", "remove", "move", "vol", "mode",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function parseCommand(
|
export function parseCommand(
|
||||||
@@ -59,3 +58,24 @@ export function parseCommand(
|
|||||||
export function isAdminCommand(commandName: string): boolean {
|
export function isAdminCommand(commandName: string): boolean {
|
||||||
return ADMIN_COMMANDS.has(commandName);
|
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 { describe, it, expect, vi } from "vitest";
|
||||||
import { BotInstance } from "./instance.js";
|
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,
|
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
|
||||||
// reads avatars, etc.), and runExclusive only touches a single private field
|
// 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 type { MusicProvider, Song } from "../music/provider.js";
|
||||||
import {
|
import {
|
||||||
parseCommand,
|
parseCommand,
|
||||||
isAdminCommand,
|
canRunCommand,
|
||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
shouldResumeOnReturn,
|
shouldResumeOnReturn,
|
||||||
} from "./auto-pause.js";
|
} from "./auto-pause.js";
|
||||||
|
|
||||||
|
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||||
|
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -322,8 +325,17 @@ export class BotInstance extends EventEmitter {
|
|||||||
);
|
);
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
|
|
||||||
if (isAdminCommand(parsed.name)) {
|
if (!(await this.isCommandAllowed(parsed.name, msg))) {
|
||||||
// TODO: Check if invoker is in adminGroups
|
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(
|
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(
|
async executeCommand(
|
||||||
cmd: ParsedCommand,
|
cmd: ParsedCommand,
|
||||||
msg?: TS3TextMessage
|
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;
|
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 {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...partial,
|
...partial,
|
||||||
|
adminGroups,
|
||||||
guestMode: gm,
|
guestMode: gm,
|
||||||
};
|
};
|
||||||
} catch {
|
} 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,
|
listChannels,
|
||||||
listClients,
|
listClients,
|
||||||
clientMove,
|
clientMove,
|
||||||
|
getClientInfo,
|
||||||
fileTransferDeleteFile,
|
fileTransferDeleteFile,
|
||||||
type Identity,
|
type Identity,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
@@ -61,6 +62,24 @@ export interface TS3TextMessage {
|
|||||||
invokerUid: string;
|
invokerUid: string;
|
||||||
message: string;
|
message: string;
|
||||||
targetMode: number; // 1=private, 2=channel, 3=server
|
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 {
|
export class TS3Client extends EventEmitter {
|
||||||
@@ -203,14 +222,7 @@ export class TS3Client extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.client.on("textMessage", (msg: TextMessage) => {
|
this.client.on("textMessage", (msg: TextMessage) => {
|
||||||
const tsMsg: TS3TextMessage = {
|
this.emit("textMessage", toTS3TextMessage(msg));
|
||||||
invokerName: msg.invokerName,
|
|
||||||
invokerId: String(msg.invokerID),
|
|
||||||
invokerUid: msg.invokerUID,
|
|
||||||
message: msg.message,
|
|
||||||
targetMode: msg.targetMode,
|
|
||||||
};
|
|
||||||
this.emit("textMessage", tsMsg);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on("disconnected", (err) => {
|
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 ---
|
// --- Raw command & file transfer pass-through ---
|
||||||
|
|
||||||
async execCommand(cmd: string): Promise<void> {
|
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([]);
|
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", () => {
|
describe("bot router /settings guest-mode gating + persistence", () => {
|
||||||
|
|||||||
+10
-1
@@ -36,6 +36,7 @@ export function createBotRouter(
|
|||||||
res.json({
|
res.json({
|
||||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -43,7 +44,7 @@ export function createBotRouter(
|
|||||||
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
|
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
|
||||||
// behavior is a bot.manage operation, consistent with PR #80's permission model)
|
// behavior is a bot.manage operation, consistent with PR #80's permission model)
|
||||||
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
|
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;
|
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||||
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
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);
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
|
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
|
||||||
@@ -92,6 +100,7 @@ export function createBotRouter(
|
|||||||
res.json({
|
res.json({
|
||||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -504,6 +504,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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) -->
|
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||||
<section v-if="can('bot.manage')" class="settings-section">
|
<section v-if="can('bot.manage')" class="settings-section">
|
||||||
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
|
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
|
||||||
@@ -1027,6 +1044,7 @@ async function loadIdleTimeout() {
|
|||||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||||
applyGuestModeFromServer(res.data.guestMode);
|
applyGuestModeFromServer(res.data.guestMode);
|
||||||
|
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||||
} catch { /* ignore */ }
|
} 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 ---
|
// --- Bot Profile config ---
|
||||||
interface ProfileConfig {
|
interface ProfileConfig {
|
||||||
avatarEnabled: boolean;
|
avatarEnabled: boolean;
|
||||||
|
|||||||
Reference in new issue
Block a user