mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f5d236c1 | ||
|
|
ea6820204d | ||
|
|
e849db2286 | ||
|
|
e12cbf8863 | ||
|
|
4e148302fc | ||
|
|
9c861f487d | ||
|
|
70c0273ae7 | ||
|
|
e2fa288f48 | ||
|
|
59a9e742c8 | ||
|
|
31d3830791 | ||
|
|
d1bd010260 | ||
|
|
fbb127a86d | ||
|
|
7b2bd0ea6a | ||
|
|
8e5e9c810e | ||
|
|
e104093614 | ||
|
|
b387d6581e | ||
|
|
17ab477af6 | ||
|
|
10e29476f4 | ||
|
|
215e328f17 | ||
|
|
3346286ffd | ||
|
|
72ffd44f68 | ||
|
|
b090a8ec21 | ||
|
|
f98ce47c52 | ||
|
|
0c7f7e128b | ||
|
|
0cc77fdee0 | ||
|
|
253c0a46a1 |
No files matched your search
@@ -24,8 +24,9 @@
|
||||
## 功能特性
|
||||
|
||||
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
||||
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(7 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
|
||||
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
|
||||
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
|
||||
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
||||
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
|
||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||
@@ -188,7 +189,7 @@ sudo ./scripts/install.sh
|
||||
让访客**无需账号密码**即可进入 WebUI 点歌,同时严格限制其可用能力。该功能**默认关闭**,只有管理员能开启。
|
||||
|
||||
- **开启方式**:管理员在 **设置 → 游客模式** 打开「允许游客访问」(仅管理员可见此区块)。开启后登录页会出现 **「以游客身份进入」** 按钮,访客点击即可创建游客会话,无需任何凭据。游客共享同一匿名身份、会话有效期较短(约 1 天)。关闭游客模式(或缩小机器人作用域)后立即生效,所有在线游客会话——包括正在连接的实时 WebSocket——会被立刻断开 / 重新限制。
|
||||
- **逐项权限(7 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。
|
||||
- **逐项权限(8 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。
|
||||
|
||||
| 开关 | 字段 | 默认 |
|
||||
|------|------|------|
|
||||
@@ -199,9 +200,10 @@ sudo ./scripts/install.sh
|
||||
| 暂停/继续/进度/音量 | `transport` | 关 |
|
||||
| 移除/清空队列 | `removeClear` | 关 |
|
||||
| 切换播放模式 / FM | `playMode` | 关 |
|
||||
| 播放整个歌单/专辑 | `playCollection` | 关 |
|
||||
|
||||
- **按机器人授权(游客作用域)**:可选择「全部机器人」或指定一份机器人白名单。作用域之外的机器人对游客**不可见、不可控**。
|
||||
- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质、收藏歌单、修改密码、访问用户管理与操作审计,以及读取机器人主人的私人歌单 / 私人 FM / 每日推荐等平台账号数据。这些限制不受上面 7 个开关影响,**永远锁死**。
|
||||
- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质、收藏歌单、修改密码、访问用户管理与操作审计,以及读取机器人主人的私人歌单 / 私人 FM / 每日推荐等平台账号数据。这些限制不受上面 8 个开关影响,**永远锁死**。
|
||||
- **复现 issue #83 的「下一首 only」需求**:在 **设置 → 游客模式** 中关闭「添加到队列末尾」并打开「添加到下一首」,游客便只能把歌曲加到下一首播放。
|
||||
|
||||
**如何重置忘记的管理员密码**:
|
||||
@@ -222,7 +224,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 +349,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 +538,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` 中设置密码。
|
||||
|
||||
### 反向代理部署注意事项
|
||||
|
||||
@@ -607,10 +630,11 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
|
||||
### 最新版本
|
||||
|
||||
**功能增强:细粒度权限 / 本地收藏 / 专属链接 / 自动暂停 / QQ 雷达 FM**
|
||||
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
|
||||
|
||||
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
|
||||
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
|
||||
- **本地音频上传播放**:搜索页支持拖拽 / 选择本地音频上传(保存到 `data/local-audio`),上传后可像普通歌曲一样播放、下一首播放或加入队列;设置 → 行为设置 中新增「本地音频播放」开关,关闭后拒绝新的本地上传和本地歌曲播放请求。播放结束或停止 / 清空 / 替换队列时会从服务端删除已接收文件并更新索引。
|
||||
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
|
||||
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
|
||||
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
|
||||
@@ -631,7 +655,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
-1
@@ -10,7 +10,7 @@ export interface QueuedSong {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+135
-9
@@ -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;
|
||||
@@ -32,6 +35,7 @@ export interface BotInstanceOptions {
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
youtubeProvider: MusicProvider;
|
||||
localProvider?: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
@@ -49,6 +53,8 @@ export interface BotStatus {
|
||||
volume: number;
|
||||
playMode: PlayMode;
|
||||
elapsed: number; // ground truth elapsed seconds from frame count
|
||||
/** 当前曲实际播放时长(秒)。试听片段=试听秒数;完整曲=duration。缺失时前端回退 currentSong.duration。 */
|
||||
effectiveDuration?: number;
|
||||
}
|
||||
|
||||
export class BotInstance extends EventEmitter {
|
||||
@@ -62,6 +68,7 @@ export class BotInstance extends EventEmitter {
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -78,6 +85,8 @@ export class BotInstance extends EventEmitter {
|
||||
private fmProvider: MusicProvider | null = null;
|
||||
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
||||
private lastSearchResults: Song[] = [];
|
||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||
private effectiveDuration: number | undefined;
|
||||
private playGate: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
@@ -88,6 +97,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.qqProvider = options.qqProvider;
|
||||
this.bilibiliProvider = options.bilibiliProvider;
|
||||
this.youtubeProvider = options.youtubeProvider;
|
||||
this.localProvider = options.localProvider ?? options.neteaseProvider;
|
||||
this.database = options.database;
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ botId: this.id });
|
||||
@@ -140,6 +150,41 @@ export class BotInstance extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
isLocalAudioEnabled(): boolean {
|
||||
return this.config.localAudioEnabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference-aware cleanup of uploaded local audio files. Delegates to the
|
||||
* local provider, which deletes a file only when it has been played AND is
|
||||
* no longer referenced by ANY bot's queue — so loop replays, prev, the song
|
||||
* being re-started, and the same upload queued on another bot are all safe.
|
||||
* Call this AFTER the queue mutation, so released songs are unreferenced
|
||||
* (and deleted) while songs that remain queued are preserved.
|
||||
*/
|
||||
cleanupQueuedLocalSongs(reason: string): void {
|
||||
this.sweepLocalAudio(reason);
|
||||
}
|
||||
|
||||
private sweepLocalAudio(reason: string): void {
|
||||
const provider = this.localProvider as MusicProvider & {
|
||||
sweepUnreferenced?: () => string[];
|
||||
};
|
||||
if (typeof provider.sweepUnreferenced !== "function") return;
|
||||
try {
|
||||
const deleted = provider.sweepUnreferenced();
|
||||
if (deleted.length) {
|
||||
this.logger.info({ count: deleted.length, reason }, "Cleaned up local audio files");
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn({ err, reason }, "Local audio cleanup failed");
|
||||
}
|
||||
}
|
||||
|
||||
private isSameSong(a: QueuedSong | Song | null | undefined, b: QueuedSong | Song | null | undefined): boolean {
|
||||
return !!a && !!b && a.platform === b.platform && a.id === b.id;
|
||||
}
|
||||
|
||||
private setupTsEvents(): void {
|
||||
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
|
||||
this.handleTextMessage(msg).catch((err) => {
|
||||
@@ -154,6 +199,8 @@ export class BotInstance extends EventEmitter {
|
||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||
this.connected = false;
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("disconnected");
|
||||
// A lifecycle change must not leave a stale auto-resume armed.
|
||||
this.autoPaused = false;
|
||||
// Only emit externally once per lifecycle so clients don't see a
|
||||
@@ -235,6 +282,8 @@ export class BotInstance extends EventEmitter {
|
||||
disconnect(): void {
|
||||
this._cancelIdleTimer();
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("disconnected");
|
||||
this.connected = false;
|
||||
if (!this.disconnectEmitted) {
|
||||
this.disconnectEmitted = true;
|
||||
@@ -322,8 +371,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 +406,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
|
||||
@@ -431,9 +524,10 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local"): MusicProvider {
|
||||
if (platform === "bilibili") return this.bilibiliProvider;
|
||||
if (platform === "youtube") return this.youtubeProvider;
|
||||
if (platform === "local") return this.localProvider;
|
||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -455,14 +549,18 @@ export class BotInstance extends EventEmitter {
|
||||
this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
|
||||
return false;
|
||||
}
|
||||
if (song.platform === "local" && !this.isLocalAudioEnabled()) {
|
||||
this.logger.warn({ songId: song.id, name: song.name }, "Local audio playback disabled — refusing track");
|
||||
return false;
|
||||
}
|
||||
// Clear any accumulated skip votes — every fresh track starts with a
|
||||
// clean slate, regardless of which code path loaded it (cmdPlay,
|
||||
// cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.).
|
||||
this.voteSkipUsers.clear();
|
||||
const provider = this.getProviderFor(song.platform);
|
||||
try {
|
||||
const url = await provider.getSongUrl(song.id);
|
||||
if (!url) {
|
||||
const result = await provider.getSongUrl(song.id);
|
||||
if (!result?.url) {
|
||||
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
||||
return false;
|
||||
}
|
||||
@@ -478,8 +576,10 @@ export class BotInstance extends EventEmitter {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
song.url = url;
|
||||
this.player.play(url, 0, song.duration);
|
||||
song.url = result.url;
|
||||
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
|
||||
this.effectiveDuration = result.trialDuration ?? song.duration;
|
||||
this.player.play(result.url, 0, this.effectiveDuration);
|
||||
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
||||
// change won't try to "resume" a track the user already restarted.
|
||||
this.autoPaused = false;
|
||||
@@ -568,6 +668,10 @@ export class BotInstance extends EventEmitter {
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
const song0 = song!;
|
||||
const previous = this.queue.current();
|
||||
if (previous && !this.isSameSong(previous, song0)) {
|
||||
this.player.stop();
|
||||
}
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
this.queue.add({ ...song0 });
|
||||
@@ -576,6 +680,10 @@ export class BotInstance extends EventEmitter {
|
||||
// Reset failure counter on user-initiated play
|
||||
this.player.resetFailures();
|
||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||
// Sweep AFTER the new song is queued+resolved: the replaced songs are no
|
||||
// longer referenced (and get deleted), but song0 — if it is the same local
|
||||
// upload that was already playing — stays referenced and is preserved.
|
||||
this.sweepLocalAudio("replaced");
|
||||
if (!ok) return `Cannot play: ${song0.name}`;
|
||||
return `Now playing: ${song0.name} - ${song0.artist}`;
|
||||
}
|
||||
@@ -655,6 +763,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.player.stop();
|
||||
this.autoPaused = false;
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("stopped");
|
||||
this.disableFmMode();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||
@@ -713,6 +822,7 @@ export class BotInstance extends EventEmitter {
|
||||
private cmdClear(): string {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("queue_cleared");
|
||||
this.disableFmMode();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||
@@ -726,6 +836,9 @@ export class BotInstance extends EventEmitter {
|
||||
if (isNaN(index) || index < 0) return "Usage: !remove <number>";
|
||||
const removed = this.queue.remove(index);
|
||||
if (!removed) return "Invalid position";
|
||||
// Sweep after the entry is gone — the file is deleted only if no other
|
||||
// queue position (or bot) still references this upload.
|
||||
this.sweepLocalAudio("removed_from_queue");
|
||||
this.emit("stateChange");
|
||||
return `Removed: ${removed.name}`;
|
||||
}
|
||||
@@ -785,6 +898,7 @@ export class BotInstance extends EventEmitter {
|
||||
const songs = await provider.getPlaylistSongs(playlistId);
|
||||
if (songs.length === 0) return "Playlist is empty or not found";
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
for (const song of songs) {
|
||||
@@ -792,6 +906,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
@@ -820,6 +935,7 @@ export class BotInstance extends EventEmitter {
|
||||
const songs = await provider.getAlbumSongs(albumId);
|
||||
if (songs.length === 0) return "Album is empty or not found";
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
for (const song of songs) {
|
||||
@@ -827,6 +943,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
@@ -849,6 +966,7 @@ export class BotInstance extends EventEmitter {
|
||||
if (songs.length === 0)
|
||||
return "No FM songs available (need to login first)";
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
@@ -860,6 +978,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
|
||||
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||
@@ -882,6 +1001,7 @@ export class BotInstance extends EventEmitter {
|
||||
filtered = result.songs.slice(0, 20);
|
||||
}
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
for (const song of filtered) {
|
||||
@@ -892,6 +1012,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
@@ -997,10 +1118,10 @@ export class BotInstance extends EventEmitter {
|
||||
async playNext(maxRetries = 3): Promise<boolean> {
|
||||
if (this.isAdvancing || !this.connected) return false;
|
||||
this.isAdvancing = true;
|
||||
let started = false;
|
||||
try {
|
||||
this.voteSkipUsers.clear();
|
||||
const next = this.queue.next();
|
||||
let started = false;
|
||||
if (next) {
|
||||
started = await this.resolveAndPlay(next);
|
||||
if (!started) {
|
||||
@@ -1040,6 +1161,10 @@ export class BotInstance extends EventEmitter {
|
||||
this.emit("stateChange");
|
||||
return started;
|
||||
} finally {
|
||||
// Reference-aware sweep: a finished local song that still sits in the
|
||||
// queue (sequential history, loop/repeat, or queued on another bot) is
|
||||
// preserved; only uploads no longer referenced anywhere are deleted.
|
||||
this.sweepLocalAudio("playback_finished");
|
||||
this.isAdvancing = false;
|
||||
}
|
||||
}
|
||||
@@ -1072,6 +1197,7 @@ export class BotInstance extends EventEmitter {
|
||||
volume: this.player.getVolume(),
|
||||
playMode: this.queue.getMode(),
|
||||
elapsed: this.player.getElapsed(),
|
||||
effectiveDuration: this.effectiveDuration,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+25
-1
@@ -74,6 +74,7 @@ export class BotManager extends EventEmitter {
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -90,13 +91,21 @@ export class BotManager extends EventEmitter {
|
||||
logger: Logger,
|
||||
avatarStore: AvatarStore,
|
||||
permissions: PermissionStore,
|
||||
configPath: string
|
||||
configPath: string,
|
||||
localProvider?: MusicProvider
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
this.qqProvider = qqProvider;
|
||||
this.bilibiliProvider = bilibiliProvider;
|
||||
this.youtubeProvider = new YouTubeProvider();
|
||||
this.localProvider = localProvider ?? neteaseProvider;
|
||||
// Let the local provider see which uploads are still referenced by any
|
||||
// bot's queue, so it never deletes a file another queue/bot still needs.
|
||||
const referenceable = this.localProvider as Partial<{
|
||||
setInUseResolver: (resolver: () => Set<string>) => void;
|
||||
}>;
|
||||
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
|
||||
this.database = database;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
@@ -127,6 +136,7 @@ export class BotManager extends EventEmitter {
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -210,6 +220,18 @@ export class BotManager extends EventEmitter {
|
||||
return Array.from(this.bots.values());
|
||||
}
|
||||
|
||||
/** Local upload ids still referenced by any bot's queue. The local provider
|
||||
* uses this to avoid deleting a file another queue/bot is still using. */
|
||||
getReferencedLocalSongIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const bot of this.bots.values()) {
|
||||
for (const song of bot.getQueueManager().list()) {
|
||||
if (song.platform === "local") ids.add(song.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async startBot(id: string): Promise<void> {
|
||||
const oldBot = this.bots.get(id);
|
||||
if (!oldBot) throw new Error(`Bot ${id} not found`);
|
||||
@@ -253,6 +275,7 @@ export class BotManager extends EventEmitter {
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -305,6 +328,7 @@ export class BotManager extends EventEmitter {
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
|
||||
+29
-1
@@ -115,6 +115,7 @@ describe("guestMode config", () => {
|
||||
expect(c.guestMode.permissions).toEqual({
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,13 +168,40 @@ describe("guestMode config", () => {
|
||||
});
|
||||
it("a string permissions value yields defaults with no numeric index keys", () => {
|
||||
const gm = loadGuestMode({ guestMode: { permissions: "hacked" } });
|
||||
// 7 known flags present at their defaults
|
||||
// all known flags present at their defaults
|
||||
expect(gm.permissions).toEqual({
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
});
|
||||
// no garbage index keys leaked from spreading a string
|
||||
expect((gm.permissions as unknown as Record<string, unknown>)["0"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ export interface BotConfig {
|
||||
autoReturnDelay: number;
|
||||
autoPauseOnEmpty: boolean;
|
||||
idleTimeoutMinutes: number;
|
||||
/** Enable uploading and playback of server-stored local audio files. */
|
||||
localAudioEnabled: boolean;
|
||||
// Public base URL used when generating share links (e.g. the bot专属链接).
|
||||
// Leave empty to use the browser's current origin. Example:
|
||||
// "https://music.example.com" or "http://1.2.3.4:3000"
|
||||
@@ -50,6 +52,7 @@ export function getDefaultConfig(): BotConfig {
|
||||
// clients are present). Users can opt in from the web UI.
|
||||
autoPauseOnEmpty: false,
|
||||
idleTimeoutMinutes: 0,
|
||||
localAudioEnabled: true,
|
||||
publicUrl: "",
|
||||
trustProxy: false,
|
||||
guestMode: {
|
||||
@@ -63,6 +66,7 @@ export function getDefaultConfig(): BotConfig {
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -104,9 +108,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 {
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("resolvePermissionContext guest branch", () => {
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: true, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
});
|
||||
expect([...ctx.capabilities]).toEqual([]);
|
||||
@@ -120,14 +121,15 @@ describe("resolvePermissionContext guest branch", () => {
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
});
|
||||
expect(ctx.bots).toBe("all");
|
||||
});
|
||||
|
||||
it("exposes the 7 canonical flags", () => {
|
||||
it("exposes the 8 canonical flags", () => {
|
||||
expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual(
|
||||
["addToQueue", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
|
||||
["addToQueue", "playCollection", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,8 @@ export interface GuestPermissions {
|
||||
transport: boolean;
|
||||
removeClear: boolean;
|
||||
playMode: boolean;
|
||||
/** Load + play an entire playlist/album (clears the queue). Issue #103. */
|
||||
playCollection: boolean;
|
||||
}
|
||||
|
||||
export const GUEST_PERMISSION_FLAGS = [
|
||||
@@ -39,6 +41,7 @@ export const GUEST_PERMISSION_FLAGS = [
|
||||
"transport",
|
||||
"removeClear",
|
||||
"playMode",
|
||||
"playCollection",
|
||||
] as const;
|
||||
export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number];
|
||||
|
||||
|
||||
+6
-1
@@ -7,6 +7,7 @@ import { createApiServerManager } from "./music/api-server.js";
|
||||
import { NeteaseProvider } from "./music/netease.js";
|
||||
import { QQMusicProvider } from "./music/qq.js";
|
||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||
import { LocalMusicProvider } from "./music/local.js";
|
||||
import { createCookieStore } from "./music/auth.js";
|
||||
import { createAvatarStore } from "./data/avatars.js";
|
||||
import { createPermissionStore } from "./data/permissions.js";
|
||||
@@ -25,6 +26,7 @@ const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
|
||||
const LOG_DIR = path.join(DATA_DIR, "logs");
|
||||
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
||||
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
||||
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
|
||||
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
||||
|
||||
async function main() {
|
||||
@@ -54,6 +56,7 @@ async function main() {
|
||||
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
|
||||
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
|
||||
const bilibiliProvider = new BiliBiliProvider();
|
||||
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
|
||||
|
||||
const cookieStore = createCookieStore(COOKIE_DIR);
|
||||
const avatarStore = createAvatarStore(AVATAR_DIR);
|
||||
@@ -75,7 +78,8 @@ async function main() {
|
||||
logger,
|
||||
avatarStore,
|
||||
permissions,
|
||||
CONFIG_PATH
|
||||
CONFIG_PATH,
|
||||
localProvider
|
||||
);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
@@ -85,6 +89,7 @@ async function main() {
|
||||
neteaseProvider,
|
||||
qqProvider,
|
||||
bilibiliProvider,
|
||||
localProvider,
|
||||
database: db,
|
||||
avatarStore,
|
||||
config,
|
||||
|
||||
@@ -3,6 +3,7 @@ import axios, { type AxiosInstance } from "axios";
|
||||
import type {
|
||||
MusicProvider,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
Playlist,
|
||||
LyricLine,
|
||||
SearchResult,
|
||||
@@ -231,7 +232,7 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
return this.cidCache.get(bvid) ?? null;
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
|
||||
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
|
||||
const cid = await this.getCid(songId);
|
||||
if (!cid) return null;
|
||||
|
||||
@@ -253,7 +254,8 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
||||
);
|
||||
|
||||
return best.baseUrl ?? best.base_url ?? null;
|
||||
const biliUrl = best.baseUrl ?? best.base_url;
|
||||
return biliUrl ? { url: biliUrl } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { LocalMusicProvider } from "./local.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "local-audio-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Seed real files + an index.json so we can exercise the cleanup lifecycle
|
||||
// without invoking the ffmpeg duration probe that uploadAudio runs.
|
||||
function makeRecord(id: string, bytes = 16) {
|
||||
const filePath = join(dir, `${id}.mp3`);
|
||||
writeFileSync(filePath, Buffer.alloc(bytes, 1));
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
artist: "本地上传",
|
||||
album: "本地音乐",
|
||||
duration: 0,
|
||||
coverUrl: "",
|
||||
platform: "local" as const,
|
||||
filePath,
|
||||
originalName: `${id}.mp3`,
|
||||
uploadedAt: "1970-01-01T00:00:00.000Z",
|
||||
size: bytes,
|
||||
mimeType: "audio/mpeg",
|
||||
};
|
||||
}
|
||||
|
||||
function seed(records: ReturnType<typeof makeRecord>[]) {
|
||||
writeFileSync(join(dir, "index.json"), JSON.stringify(records), "utf8");
|
||||
}
|
||||
|
||||
describe("LocalMusicProvider cleanup lifecycle", () => {
|
||||
it("sweep keeps referenced and never-played files, deletes only played+unreferenced", async () => {
|
||||
const a = makeRecord("a");
|
||||
const b = makeRecord("b");
|
||||
const c = makeRecord("c");
|
||||
seed([a, b, c]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const refs = new Set<string>(["a"]); // "a" still sits in a queue somewhere
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a"); // played, but referenced
|
||||
await p.getSongUrl("b"); // played and unreferenced
|
||||
// "c" was never played (e.g. uploaded but not queued)
|
||||
|
||||
const deleted = p.sweepUnreferenced();
|
||||
|
||||
expect(deleted).toEqual(["b"]);
|
||||
expect(existsSync(a.filePath)).toBe(true); // referenced → kept
|
||||
expect(existsSync(b.filePath)).toBe(false); // played + unreferenced → deleted
|
||||
expect(existsSync(c.filePath)).toBe(true); // never played → kept
|
||||
});
|
||||
|
||||
it("a played song still in the queue survives the sweep and stays replayable (loop / prev)", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const refs = new Set<string>(["a"]); // loop queue still references it
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a"); // first pass plays it
|
||||
p.sweepUnreferenced(); // "playback_finished" sweep
|
||||
|
||||
expect(existsSync(a.filePath)).toBe(true);
|
||||
expect((await p.getSongUrl("a"))?.url).toBe(a.filePath); // next loop pass works
|
||||
});
|
||||
|
||||
it("re-playing a queued local song does not delete it (play-song order)", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
// Mirror the fixed endpoint order: the song is (re)added to the queue
|
||||
// BEFORE the sweep runs, so it is referenced when we sweep.
|
||||
const refs = new Set<string>(["a"]);
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a"); // played once
|
||||
p.sweepUnreferenced(); // sweep fired after the replay re-queued it
|
||||
expect(existsSync(a.filePath)).toBe(true);
|
||||
expect(await p.getSongUrl("a")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a played file once it leaves every queue", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
let refs = new Set<string>(["a"]);
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a");
|
||||
p.sweepUnreferenced();
|
||||
expect(existsSync(a.filePath)).toBe(true); // still queued
|
||||
|
||||
refs = new Set<string>(); // queue cleared
|
||||
p.sweepUnreferenced();
|
||||
expect(existsSync(a.filePath)).toBe(false); // now removed
|
||||
expect(await p.getSongUrl("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("never deletes anything when the reference resolver throws", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
p.setInUseResolver(() => {
|
||||
throw new Error("manager unavailable");
|
||||
});
|
||||
await p.getSongUrl("a");
|
||||
expect(p.sweepUnreferenced()).toEqual([]);
|
||||
expect(existsSync(a.filePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider upload validation", () => {
|
||||
it("rejects a spoofed Content-Type with a non-audio extension", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({
|
||||
buffer: Buffer.from("malicious"),
|
||||
originalName: "evil.exe",
|
||||
mimeType: "application/octet-stream",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unknown extension even when the mime claims audio", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({
|
||||
buffer: Buffer.from("x"),
|
||||
originalName: "evil.html",
|
||||
mimeType: "audio/mpeg",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty file", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider quota", () => {
|
||||
it("evicts oldest unreferenced uploads beyond maxFiles", async () => {
|
||||
const a = makeRecord("a");
|
||||
const b = makeRecord("b");
|
||||
seed([b, a]); // newest-first: b newer than a
|
||||
const p = new LocalMusicProvider(dir, { maxFiles: 2 });
|
||||
p.setInUseResolver(() => new Set<string>());
|
||||
|
||||
// Upload a third valid file → over the 2-file cap → evict the oldest ("a").
|
||||
await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 7),
|
||||
originalName: "c.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
|
||||
expect(existsSync(a.filePath)).toBe(false); // oldest evicted
|
||||
expect(existsSync(b.filePath)).toBe(true);
|
||||
const result = await p.search("");
|
||||
expect(result.songs.map((s) => s.id).sort()).not.toContain("a");
|
||||
});
|
||||
|
||||
it("does not evict a referenced upload even when over the cap", async () => {
|
||||
const a = makeRecord("a");
|
||||
const b = makeRecord("b");
|
||||
seed([b, a]);
|
||||
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
|
||||
p.setInUseResolver(() => new Set<string>(["a"])); // "a" is queued
|
||||
|
||||
await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 7),
|
||||
originalName: "c.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
|
||||
expect(existsSync(a.filePath)).toBe(true); // protected: still queued
|
||||
});
|
||||
|
||||
it("never evicts the just-uploaded file, even when every older file is referenced", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
|
||||
p.setInUseResolver(() => new Set<string>(["a"])); // the only older file is queued
|
||||
|
||||
const song = await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 7),
|
||||
originalName: "c.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
|
||||
// The returned song must actually exist and be playable — not a phantom.
|
||||
expect(await p.getSongUrl(song.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider filename handling", () => {
|
||||
it("accepts a long filename without dropping its extension", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const longName = "x".repeat(300) + ".mp3";
|
||||
// Must not throw the "unsupported format" error — the extension survives.
|
||||
const song = await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 1),
|
||||
originalName: longName,
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
expect(song.id).toBeTruthy();
|
||||
expect(await p.getSongUrl(song.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import type {
|
||||
Album,
|
||||
AuthStatus,
|
||||
LyricLine,
|
||||
MusicProvider,
|
||||
Playlist,
|
||||
PlaylistDetail,
|
||||
QrCodeResult,
|
||||
SearchResult,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
} from "./provider.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ffmpegPath: string | null = require("ffmpeg-static");
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([
|
||||
".mp3",
|
||||
".flac",
|
||||
".wav",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".webm",
|
||||
".wma",
|
||||
".alac",
|
||||
".aiff",
|
||||
".ape",
|
||||
]);
|
||||
|
||||
const DEFAULT_MAX_FILES = 200;
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
|
||||
|
||||
export interface LocalMusicProviderOptions {
|
||||
/** Max number of uploaded files kept on disk (oldest unreferenced evicted). */
|
||||
maxFiles?: number;
|
||||
/** Max total bytes of uploaded files kept on disk. */
|
||||
maxTotalBytes?: number;
|
||||
}
|
||||
|
||||
interface LocalSongRecord extends Song {
|
||||
filePath: string;
|
||||
originalName: string;
|
||||
uploadedAt: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function safeFileName(name: string): string {
|
||||
const base = path.basename(name || "audio")
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!base) return "audio";
|
||||
// Cap the total length but ALWAYS preserve the extension — truncating the
|
||||
// whole string would drop a trailing ".mp3" on a long filename and make the
|
||||
// file fail extension validation.
|
||||
const ext = path.extname(base);
|
||||
const stem = ext ? base.slice(0, base.length - ext.length) : base;
|
||||
const safeStem = stem.slice(0, Math.max(1, 160 - ext.length)) || "audio";
|
||||
return `${safeStem}${ext}`;
|
||||
}
|
||||
|
||||
function titleFromFileName(name: string): string {
|
||||
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
|
||||
}
|
||||
|
||||
async function probeDurationSeconds(filePath: string): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
ffmpeg.kill("SIGKILL");
|
||||
resolve(0);
|
||||
}, 5000);
|
||||
ffmpeg.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
ffmpeg.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(0);
|
||||
});
|
||||
ffmpeg.on("close", () => {
|
||||
clearTimeout(timeout);
|
||||
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
||||
if (!match) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
const seconds = Number(match[3]);
|
||||
const total = hours * 3600 + minutes * 60 + seconds;
|
||||
resolve(Number.isFinite(total) ? Math.round(total) : 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalMusicProvider implements MusicProvider {
|
||||
readonly platform = "local" as const;
|
||||
private readonly uploadDir: string;
|
||||
private readonly indexPath: string;
|
||||
private records: LocalSongRecord[] = [];
|
||||
private readonly maxFiles: number;
|
||||
private readonly maxTotalBytes: number;
|
||||
/** Ids that have been resolved for playback at least once; only these are
|
||||
* eligible for reference-aware cleanup, so freshly uploaded files that are
|
||||
* not yet queued/played survive in the search list. */
|
||||
private playedIds = new Set<string>();
|
||||
/** Returns the set of local song ids still referenced by any bot's queue.
|
||||
* Deletion never removes a file whose id this set contains. */
|
||||
private inUseResolver: () => Set<string> = () => new Set<string>();
|
||||
/** Ids with an in-flight retry-delete scheduled (file briefly locked, e.g.
|
||||
* ffmpeg on Windows still releasing a just-stopped track). */
|
||||
private retrying = new Set<string>();
|
||||
|
||||
constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) {
|
||||
this.uploadDir = uploadDir;
|
||||
this.indexPath = path.join(uploadDir, "index.json");
|
||||
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
||||
this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
this.loadIndex();
|
||||
}
|
||||
|
||||
/** Wire the resolver the BotManager uses to report which uploads are still
|
||||
* queued anywhere. Must be set before any cleanup can delete files. */
|
||||
setInUseResolver(resolver: () => Set<string>): void {
|
||||
this.inUseResolver = resolver;
|
||||
}
|
||||
|
||||
private referencedIds(): Set<string> | null {
|
||||
try {
|
||||
return this.inUseResolver() ?? new Set<string>();
|
||||
} catch {
|
||||
// Resolver failure → references unknown → refuse to delete anything.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadIndex(): void {
|
||||
try {
|
||||
const raw = readFileSync(this.indexPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as LocalSongRecord[];
|
||||
this.records = Array.isArray(parsed)
|
||||
? parsed.filter((r) => r && typeof r.id === "string" && typeof r.filePath === "string")
|
||||
: [];
|
||||
} catch {
|
||||
this.records = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveIndex(): void {
|
||||
writeFileSync(this.indexPath, JSON.stringify(this.records, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async uploadAudio(input: {
|
||||
buffer: Buffer;
|
||||
originalName: string;
|
||||
mimeType?: string;
|
||||
}): Promise<Song> {
|
||||
const originalName = safeFileName(input.originalName || "audio");
|
||||
const ext = path.extname(originalName).toLowerCase();
|
||||
// Validate by the (sanitised) file extension only — never trust the
|
||||
// client-supplied Content-Type. This also guarantees the STORED extension
|
||||
// is one of the known audio types, so a spoofed header cannot persist an
|
||||
// arbitrary-extension blob on disk.
|
||||
if (!AUDIO_EXTENSIONS.has(ext)) {
|
||||
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
|
||||
}
|
||||
if (!input.buffer || input.buffer.length === 0) {
|
||||
throw new Error("上传文件为空");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const storedName = `${id}${ext}`;
|
||||
const filePath = path.join(this.uploadDir, storedName);
|
||||
writeFileSync(filePath, input.buffer);
|
||||
|
||||
const duration = await probeDurationSeconds(filePath);
|
||||
const song: LocalSongRecord = {
|
||||
id,
|
||||
name: titleFromFileName(originalName),
|
||||
artist: "本地上传",
|
||||
album: "本地音乐",
|
||||
duration,
|
||||
coverUrl: "",
|
||||
platform: "local",
|
||||
filePath,
|
||||
originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
size: input.buffer.length,
|
||||
mimeType: input.mimeType || "application/octet-stream",
|
||||
};
|
||||
|
||||
this.records.unshift(song);
|
||||
this.saveIndex();
|
||||
// Never evict the file we just accepted, even if every older file is still
|
||||
// queued — returning success for a file we deleted would be a phantom entry.
|
||||
this.enforceQuota(id);
|
||||
return this.toSong(song);
|
||||
}
|
||||
|
||||
private toSong(record: LocalSongRecord): Song {
|
||||
const { filePath: _filePath, originalName: _originalName, uploadedAt: _uploadedAt, size: _size, mimeType: _mimeType, ...song } = record;
|
||||
return song;
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
const q = query.trim().toLowerCase();
|
||||
const songs = this.records
|
||||
.filter((r) => existsSync(r.filePath))
|
||||
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
||||
.slice(0, limit)
|
||||
.map((r) => this.toSong(r));
|
||||
return { songs, playlists: [], albums: [] };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
|
||||
const record = this.records.find((r) => r.id === songId);
|
||||
if (!record || !existsSync(record.filePath)) return null;
|
||||
// A song that is actually resolved for playback becomes eligible for
|
||||
// cleanup once it is no longer referenced by any queue.
|
||||
this.playedIds.add(songId);
|
||||
return { url: record.filePath };
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const record = this.records.find((r) => r.id === songId);
|
||||
return record && existsSync(record.filePath) ? this.toSong(record) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference-aware cleanup: delete only files that have been played at least
|
||||
* once AND are no longer referenced by any bot's queue. Safe to call after
|
||||
* any queue mutation — a file still queued anywhere (loop replay, prev,
|
||||
* the song being re-started, the same upload queued on another bot) is kept.
|
||||
* Returns the ids that were deleted.
|
||||
*/
|
||||
sweepUnreferenced(): string[] {
|
||||
const inUse = this.referencedIds();
|
||||
if (!inUse) return [];
|
||||
const deleted: string[] = [];
|
||||
for (let i = this.records.length - 1; i >= 0; i--) {
|
||||
const r = this.records[i];
|
||||
if (!this.playedIds.has(r.id) || inUse.has(r.id)) continue;
|
||||
if (this.unlinkRecordAt(i)) {
|
||||
deleted.push(r.id);
|
||||
} else {
|
||||
// File still locked (e.g. ffmpeg just-stopped on Windows) — keep the
|
||||
// record and retry shortly; never orphan it or abort the rest.
|
||||
this.scheduleRetry(r.id);
|
||||
}
|
||||
}
|
||||
if (deleted.length) this.saveIndex();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Evict oldest, never-referenced uploads until under the file-count and
|
||||
* total-byte caps. Bounds disk use from uploads that are never played.
|
||||
* `protectId` is never evicted (the file just uploaded in this same call). */
|
||||
private enforceQuota(protectId?: string): void {
|
||||
if (this.records.length <= this.maxFiles &&
|
||||
this.totalBytes() <= this.maxTotalBytes) {
|
||||
return;
|
||||
}
|
||||
const inUse = this.referencedIds();
|
||||
if (!inUse) return; // can't safely evict without knowing references
|
||||
let count = this.records.length;
|
||||
let bytes = this.totalBytes();
|
||||
let changed = false;
|
||||
for (let i = this.records.length - 1;
|
||||
i >= 0 && (count > this.maxFiles || bytes > this.maxTotalBytes);
|
||||
i--) {
|
||||
const r = this.records[i];
|
||||
if (inUse.has(r.id) || r.id === protectId) continue; // never evict these
|
||||
const size = r.size || 0;
|
||||
if (this.unlinkRecordAt(i)) {
|
||||
count--;
|
||||
bytes -= size;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.saveIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the backing file for records[index] and drop the record from memory.
|
||||
* Deletes the FILE FIRST, then mutates state only on success, so a failed
|
||||
* unlink leaves the record intact (file + index stay consistent) instead of
|
||||
* orphaning the file. Returns true if the file is gone (deleted or already
|
||||
* absent), false if it is still present (locked). Never throws; does NOT
|
||||
* persist the index — callers batch saveIndex().
|
||||
*/
|
||||
private unlinkRecordAt(index: number): boolean {
|
||||
const r = this.records[index];
|
||||
try {
|
||||
rmSync(r.filePath, { force: true });
|
||||
} catch {
|
||||
// rmSync force:true only swallows ENOENT; EBUSY/EPERM/EACCES throw. If
|
||||
// the file genuinely vanished anyway, fall through and drop the record.
|
||||
if (existsSync(r.filePath)) return false;
|
||||
}
|
||||
this.records.splice(index, 1);
|
||||
this.playedIds.delete(r.id);
|
||||
this.retrying.delete(r.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Schedule a bounded, non-blocking retry to delete a briefly-locked file.
|
||||
* Uses unref'd timers so it never keeps the process alive. */
|
||||
private scheduleRetry(id: string, attempt = 1): void {
|
||||
if (attempt === 1 && this.retrying.has(id)) return;
|
||||
this.retrying.add(id);
|
||||
const MAX_ATTEMPTS = 6;
|
||||
const timer = setTimeout(() => {
|
||||
const index = this.records.findIndex((r) => r.id === id);
|
||||
if (index < 0) { this.retrying.delete(id); return; } // already removed
|
||||
const inUse = this.referencedIds();
|
||||
if (!inUse || inUse.has(id)) { this.retrying.delete(id); return; } // unknown or re-queued
|
||||
if (this.unlinkRecordAt(index)) {
|
||||
this.saveIndex();
|
||||
} else if (attempt < MAX_ATTEMPTS) {
|
||||
this.scheduleRetry(id, attempt + 1);
|
||||
} else {
|
||||
this.retrying.delete(id); // give up; next sweep/quota will retry
|
||||
}
|
||||
}, 500 * attempt);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
}
|
||||
|
||||
private totalBytes(): number {
|
||||
return this.records.reduce((n, r) => n + (r.size || 0), 0);
|
||||
}
|
||||
|
||||
setQuality(_quality: string): void {
|
||||
// 本地文件按原始音质播放。
|
||||
}
|
||||
|
||||
getQuality(): string {
|
||||
return "original";
|
||||
}
|
||||
|
||||
async getPlaylistSongs(_playlistId: string): Promise<Song[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getAlbumSongs(_albumId: string): Promise<Song[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getLyrics(_songId: string): Promise<LyricLine[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
throw new Error("Local music does not require login");
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(_key: string): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
setCookie(_cookie: string): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
getCookie(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
return { loggedIn: true, nickname: "本地音乐" };
|
||||
}
|
||||
|
||||
async getPlaylistDetail(_playlistId: string): Promise<PlaylistDetail | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
|
||||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
|
||||
|
||||
describe("NetEase adapter", () => {
|
||||
it("parses LRC format lyrics", () => {
|
||||
@@ -56,4 +56,40 @@ describe("NetEase adapter", () => {
|
||||
expect(mapNeteaseAlbums(null as any)).toEqual([]);
|
||||
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it("mapNeteaseSongs maps fee to vip flag (1/4 = vip, 0/8 = free)", () => {
|
||||
const raw = [
|
||||
{ id: 1, name: "VIP", ar: [{ name: "A" }], al: { name: "Al", picUrl: "p" }, dt: 180000, fee: 1 },
|
||||
{ id: 2, name: "Album-only", ar: [], al: { name: "Al", picUrl: "" }, dt: 0, fee: 4 },
|
||||
{ id: 3, name: "Free", ar: [], al: {}, dt: 0, fee: 0 },
|
||||
{ id: 4, name: "Free low-quality", ar: [], al: {}, dt: 0, fee: 8 },
|
||||
];
|
||||
const out = mapNeteaseSongs(raw);
|
||||
expect(out[0].vip).toBe(true);
|
||||
expect(out[1].vip).toBe(true);
|
||||
expect(out[2].vip).toBe(false);
|
||||
expect(out[3].vip).toBe(false); // fee=8 plays in full (low quality), NOT vip
|
||||
});
|
||||
|
||||
it("mapNeteaseSongs accepts artists/album/duration aliases (personal_fm shape)", () => {
|
||||
const out = mapNeteaseSongs([
|
||||
{ id: 9, name: "FM", artists: [{ name: "B" }], album: { name: "Al2", picUrl: "p2" }, duration: 200000, fee: 0 },
|
||||
]);
|
||||
expect(out[0]).toMatchObject({ artist: "B", album: "Al2", coverUrl: "p2", vip: false });
|
||||
});
|
||||
|
||||
it("parseNeteaseTrial maps freeTrialInfo to trial seconds", () => {
|
||||
// 无试听(VIP/免费)
|
||||
expect(parseNeteaseTrial({})).toBeUndefined();
|
||||
expect(parseNeteaseTrial({ freeTrialInfo: null })).toBeUndefined();
|
||||
// 标准秒
|
||||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30 } })).toBe(30);
|
||||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 5, end: 35 } })).toBe(30);
|
||||
// 别名容忍 begin/trialBegin
|
||||
expect(parseNeteaseTrial({ freeTrialInfo: { begin: 0, end: 30 } })).toBe(30);
|
||||
// 毫秒兜底(end>1000)
|
||||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30000 } })).toBe(30);
|
||||
// 异常 end<=start
|
||||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+39
-60
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios";
|
||||
import type {
|
||||
MusicProvider,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
Playlist,
|
||||
PlaylistDetail,
|
||||
LyricLine,
|
||||
@@ -68,6 +69,33 @@ export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? s.artists ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.al?.name ?? s.album?.name ?? "",
|
||||
duration: Math.round((s.dt ?? s.duration ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? s.album?.picUrl ?? "",
|
||||
platform: "netease",
|
||||
// fee: 0=free, 1=VIP, 4=album-only, 8=free low-quality (plays in full, NOT vip)
|
||||
vip: s.fee === 1 || s.fee === 4,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 解析网易云 freeTrialInfo → 试听秒数;无片段(VIP/免费)返回 undefined。
|
||||
* 真实字段 {start,end} 单位秒;容忍 begin/trialBegin 别名 + 毫秒兜底(end>1000)。 */
|
||||
export function parseNeteaseTrial(item: any): number | undefined {
|
||||
const t = item?.freeTrialInfo;
|
||||
if (!t || typeof t !== "object") return undefined;
|
||||
const start = Number(t.start ?? t.begin ?? t.trialBegin ?? 0);
|
||||
const end = Number(t.end ?? t.trialEnd);
|
||||
if (!Number.isFinite(end) || end <= start) return undefined;
|
||||
const secs = end > 1000 ? (end - start) / 1000 : end - start;
|
||||
return Math.round(secs);
|
||||
}
|
||||
|
||||
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
|
||||
export const NETEASE_QUALITY_LEVELS = [
|
||||
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
|
||||
@@ -121,17 +149,7 @@ export class NeteaseProvider implements MusicProvider {
|
||||
}),
|
||||
]);
|
||||
|
||||
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
|
||||
(s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.al?.name ?? "",
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? "",
|
||||
platform: "netease",
|
||||
})
|
||||
);
|
||||
const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs);
|
||||
|
||||
const playlists: Playlist[] = (
|
||||
playlistRes.data?.result?.playlists ?? []
|
||||
@@ -148,44 +166,29 @@ export class NeteaseProvider implements MusicProvider {
|
||||
return { songs, playlists, albums };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
||||
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||
const level = quality ?? this.quality;
|
||||
const res = await this.api.get("/song/url/v1", {
|
||||
params: { id: songId, level, ...this.cookieParams },
|
||||
});
|
||||
return res.data?.data?.[0]?.url ?? null;
|
||||
const item = res.data?.data?.[0];
|
||||
const url = item?.url;
|
||||
if (!url) return null;
|
||||
return { url, trialDuration: parseNeteaseTrial(item) };
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const res = await this.api.get("/song/detail", {
|
||||
params: { ids: songId, ...this.cookieParams },
|
||||
});
|
||||
const s = res.data?.songs?.[0];
|
||||
if (!s) return null;
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.al?.name ?? "",
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? "",
|
||||
platform: "netease",
|
||||
};
|
||||
return mapNeteaseSongs(res.data?.songs)[0] ?? null;
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
const res = await this.api.get("/playlist/track/all", {
|
||||
params: { id: playlistId, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.songs ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.al?.name ?? "",
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? "",
|
||||
platform: "netease",
|
||||
}));
|
||||
return mapNeteaseSongs(res.data?.songs);
|
||||
}
|
||||
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
@@ -205,15 +208,7 @@ export class NeteaseProvider implements MusicProvider {
|
||||
const res = await this.api.get("/album", {
|
||||
params: { id: albumId, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.songs ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.al?.name ?? "",
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? "",
|
||||
platform: "netease",
|
||||
}));
|
||||
return mapNeteaseSongs(res.data?.songs);
|
||||
}
|
||||
|
||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||
@@ -312,30 +307,14 @@ export class NeteaseProvider implements MusicProvider {
|
||||
const res = await this.api.get("/personal_fm", {
|
||||
params: { ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.data ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.artists ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.album?.name ?? "",
|
||||
duration: Math.round((s.duration ?? 0) / 1000),
|
||||
coverUrl: s.album?.picUrl ?? "",
|
||||
platform: "netease",
|
||||
}));
|
||||
return mapNeteaseSongs(res.data?.data);
|
||||
}
|
||||
|
||||
async getDailyRecommendSongs(): Promise<Song[]> {
|
||||
const res = await this.api.get("/recommend/songs", {
|
||||
params: { ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.data?.dailySongs ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.al?.name ?? "",
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? "",
|
||||
platform: "netease",
|
||||
}));
|
||||
return mapNeteaseSongs(res.data?.data?.dailySongs);
|
||||
}
|
||||
|
||||
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||
|
||||
+15
-5
@@ -5,19 +5,29 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number; // seconds
|
||||
coverUrl: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
|
||||
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
|
||||
vip?: boolean;
|
||||
}
|
||||
|
||||
export interface SongWithUrl extends Song {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** getSongUrl 解析结果。trialDuration 缺省 = 完整可播放(VIP 账号 / 免费曲)。 */
|
||||
export interface SongUrlResult {
|
||||
url: string;
|
||||
/** 试听片段时长(秒)。VIP/免费曲为 undefined → 调用方回退完整 duration。 */
|
||||
trialDuration?: number;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
@@ -34,7 +44,7 @@ export interface Album {
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
@@ -62,10 +72,10 @@ export interface AuthStatus {
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
|
||||
search(query: string, limit?: number): Promise<SearchResult>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<string | null>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||
setQuality(quality: string): void;
|
||||
getQuality(): string;
|
||||
getSongDetail(songId: string): Promise<Song | null>;
|
||||
|
||||
+28
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapQqAlbums, mapQqSongs } from "./qq.js";
|
||||
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
|
||||
|
||||
describe("QQ adapter", () => {
|
||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||
@@ -22,10 +22,37 @@ describe("QQ adapter", () => {
|
||||
duration: 243,
|
||||
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
|
||||
platform: "qq",
|
||||
vip: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("mapQqSongs maps pay field to vip flag", () => {
|
||||
const out = mapQqSongs([
|
||||
{ mid: "v1", name: "VIP playplay", singer: [], album: {}, interval: 100, pay: { payplay: 1, paytrackprice: 0 } },
|
||||
{ mid: "v2", name: "VIP trackprice", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 1 } },
|
||||
{ mid: "f1", name: "Free", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 0 } },
|
||||
{ mid: "f2", name: "No pay field", singer: [], album: {}, interval: 100 },
|
||||
]);
|
||||
expect(out[0].vip).toBe(true);
|
||||
expect(out[1].vip).toBe(true);
|
||||
expect(out[2].vip).toBe(false);
|
||||
expect(out[3].vip).toBe(false);
|
||||
});
|
||||
|
||||
it("parseQqTrial maps isTryout/tryout to trial seconds", () => {
|
||||
// 非试听(VIP/免费)
|
||||
expect(parseQqTrial({ isTryout: 0 })).toBeUndefined();
|
||||
expect(parseQqTrial({})).toBeUndefined();
|
||||
// 试听(秒)
|
||||
expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30 })).toBe(30);
|
||||
expect(parseQqTrial({ tryout: true, begin: 0, end: 45 })).toBe(45);
|
||||
// 毫秒兜底
|
||||
expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30000 })).toBe(30);
|
||||
// 异常
|
||||
expect(parseQqTrial({ isTryout: 1, tryEnd: 0 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
||||
const raw = [
|
||||
{
|
||||
|
||||
+18
-3
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios";
|
||||
import type {
|
||||
MusicProvider,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
Playlist,
|
||||
PlaylistDetail,
|
||||
LyricLine,
|
||||
@@ -53,10 +54,23 @@ export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
||||
: "",
|
||||
platform: "qq" as const,
|
||||
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
|
||||
};
|
||||
}).filter((s) => s.id);
|
||||
}
|
||||
|
||||
/** 解析 QQ 试听标记 → 试听秒数;非试听(VIP/免费)返回 undefined。
|
||||
* 字段 isTryout===1 / tryout===true + tryBegin/tryEnd;容忍 begin/start 别名 + 毫秒兜底。 */
|
||||
export function parseQqTrial(playUrl: any): number | undefined {
|
||||
if (!playUrl || typeof playUrl !== "object") return undefined;
|
||||
if (playUrl.isTryout !== 1 && playUrl.tryout !== true) return undefined;
|
||||
const begin = Number(playUrl.tryBegin ?? playUrl.begin ?? playUrl.start ?? 0);
|
||||
const end = Number(playUrl.tryEnd ?? playUrl.end);
|
||||
if (!Number.isFinite(end) || end <= begin) return undefined;
|
||||
const secs = end > 1000 ? (end - begin) / 1000 : end - begin;
|
||||
return Math.round(secs);
|
||||
}
|
||||
|
||||
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((a) => {
|
||||
@@ -247,13 +261,13 @@ export class QQMusicProvider implements MusicProvider {
|
||||
return { songs, playlists: [], albums };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
||||
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||
try {
|
||||
const res = await this.api.get("/getMusicPlay", {
|
||||
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
||||
});
|
||||
const playUrl = res.data?.data?.playUrl?.[songId];
|
||||
if (playUrl?.url) return playUrl.url;
|
||||
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
|
||||
} catch {
|
||||
// try with songid
|
||||
try {
|
||||
@@ -261,7 +275,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
||||
});
|
||||
const playUrl = res.data?.data?.playUrl?.[songId];
|
||||
if (playUrl?.url) return playUrl.url;
|
||||
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -520,6 +534,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||
: "",
|
||||
platform: "qq",
|
||||
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
MusicProvider,
|
||||
Song,
|
||||
SongWithUrl,
|
||||
SongUrlResult,
|
||||
Playlist,
|
||||
Album,
|
||||
SearchResult,
|
||||
@@ -134,7 +135,7 @@ export class YouTubeProvider implements MusicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string): Promise<string | null> {
|
||||
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
|
||||
try {
|
||||
const url = `https://www.youtube.com/watch?v=${songId}`;
|
||||
const raw = await runYtDlp([
|
||||
@@ -146,7 +147,7 @@ export class YouTubeProvider implements MusicProvider {
|
||||
"--quiet",
|
||||
], 45_000);
|
||||
const audioUrl = raw.trim().split("\n")[0];
|
||||
return audioUrl || null;
|
||||
return audioUrl ? { url: audioUrl } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+14
-1
@@ -36,6 +36,8 @@ export function createBotRouter(
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
localAudioEnabled: config.localAudioEnabled,
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
});
|
||||
@@ -43,7 +45,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, localAudioEnabled, guestMode, adminGroups } = req.body;
|
||||
|
||||
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
||||
@@ -52,9 +54,11 @@ export function createBotRouter(
|
||||
}
|
||||
|
||||
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
|
||||
const hasLocalAudioEnabled = typeof localAudioEnabled === "boolean";
|
||||
|
||||
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
|
||||
|
||||
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
|
||||
if (hasGuestMode) {
|
||||
@@ -74,6 +78,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 +103,8 @@ export function createBotRouter(
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
localAudioEnabled: config.localAudioEnabled,
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
});
|
||||
|
||||
+74
-3
@@ -1,25 +1,85 @@
|
||||
import { Router } from "express";
|
||||
import express, { Router } from "express";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { BotConfig } from "../../data/config.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
|
||||
export function createMusicRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
qqProvider: MusicProvider,
|
||||
bilibiliProvider: MusicProvider,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
localProvider?: MusicProvider,
|
||||
config?: BotConfig
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
|
||||
function isLocalAudioEnabled(): boolean {
|
||||
return config?.localAudioEnabled !== false;
|
||||
}
|
||||
|
||||
function getProvider(platform?: string): MusicProvider {
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "local" && localProvider) return localProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/local/upload",
|
||||
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
|
||||
(_req, res, next) => {
|
||||
if (!isLocalAudioEnabled()) {
|
||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
express.raw({
|
||||
type: ["audio/*", "video/webm", "application/octet-stream"],
|
||||
limit: "200mb",
|
||||
}),
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (!localProvider) {
|
||||
res.status(501).json({ error: "Local upload is not configured" });
|
||||
return;
|
||||
}
|
||||
const uploadCapable = localProvider as MusicProvider & {
|
||||
uploadAudio?: (input: { buffer: Buffer; originalName: string; mimeType?: string }) => Promise<unknown>;
|
||||
};
|
||||
if (typeof uploadCapable.uploadAudio !== "function") {
|
||||
res.status(501).json({ error: "Local upload is not supported" });
|
||||
return;
|
||||
}
|
||||
if (!Buffer.isBuffer(req.body)) {
|
||||
res.status(400).json({ error: "raw audio body is required" });
|
||||
return;
|
||||
}
|
||||
const headerName = req.header("x-filename") || req.header("x-file-name") || "audio";
|
||||
let originalName = headerName;
|
||||
try {
|
||||
originalName = decodeURIComponent(headerName);
|
||||
} catch {
|
||||
// Keep the raw header value if it is not URI encoded.
|
||||
}
|
||||
const song = await uploadCapable.uploadAudio({
|
||||
buffer: req.body,
|
||||
originalName,
|
||||
mimeType: req.header("content-type") || undefined,
|
||||
});
|
||||
res.json({ song });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Local audio upload failed");
|
||||
res.status(400).json({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/search", async (req, res) => {
|
||||
try {
|
||||
const { q, platform, limit } = req.query;
|
||||
@@ -27,6 +87,10 @@ export function createMusicRouter(
|
||||
res.status(400).json({ error: "q (query) is required" });
|
||||
return;
|
||||
}
|
||||
if (platform === "local" && !isLocalAudioEnabled()) {
|
||||
res.json({ songs: [], playlists: [], albums: [] });
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(platform as string);
|
||||
const result = await provider.search(
|
||||
q as string,
|
||||
@@ -47,16 +111,18 @@ export function createMusicRouter(
|
||||
return;
|
||||
}
|
||||
const parsedLimit = parseInt(limit as string) || 20;
|
||||
const [neteaseResult, qqResult, bilibiliResult] = await Promise.allSettled([
|
||||
const [neteaseResult, qqResult, bilibiliResult, localResult] = await Promise.allSettled([
|
||||
neteaseProvider.search(q as string, parsedLimit),
|
||||
qqProvider.search(q as string, parsedLimit),
|
||||
bilibiliProvider.search(q as string, parsedLimit),
|
||||
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
]);
|
||||
|
||||
const songs = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
|
||||
];
|
||||
const albums = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||
@@ -76,6 +142,10 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/song/:id", async (req, res) => {
|
||||
try {
|
||||
if (req.query.platform === "local" && !isLocalAudioEnabled()) {
|
||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const song = await provider.getSongDetail(req.params.id);
|
||||
if (!song) {
|
||||
@@ -215,6 +285,7 @@ export function createMusicRouter(
|
||||
netease: neteaseProvider.getQuality(),
|
||||
qq: qqProvider.getQuality(),
|
||||
bilibili: bilibiliProvider.getQuality(),
|
||||
local: localProvider?.getQuality() ?? "original",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@ const guest = (perms: Partial<Record<string, boolean>> = {}) => ({
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
playCollection: false,
|
||||
...perms,
|
||||
},
|
||||
});
|
||||
@@ -373,7 +374,19 @@ describe("guest enforcement on player routes", () => {
|
||||
expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /play-playlist, /play-album, /playlist, /profile even with ALL flags on", async () => {
|
||||
it("playCollection flag gates /play-playlist, /play-album (issue #103)", async () => {
|
||||
const allow = mountGuest({ playCollection: true });
|
||||
const deny = mountGuest({ playCollection: false });
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).not.toBe(403);
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).not.toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
|
||||
// playCollection does NOT leak into the destructive single-song / queue ops.
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /playlist, /profile even with ALL flags on", async () => {
|
||||
const all = mountGuest({
|
||||
addToQueue: true,
|
||||
playNext: true,
|
||||
@@ -382,14 +395,13 @@ describe("guest enforcement on player routes", () => {
|
||||
transport: true,
|
||||
removeClear: true,
|
||||
playMode: true,
|
||||
playCollection: true,
|
||||
});
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/prev`)).status).toBe(403);
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/stop`)).status).toBe(403);
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-at`).send({ index: 0 })).status).toBe(403);
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
|
||||
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/playlist`).send({ playlistId: "1" })).status).toBe(403);
|
||||
expect((await request(all).put(`/api/player/${ALLOWED_BOT}/profile`).send({})).status).toBe(403);
|
||||
});
|
||||
|
||||
+58
-6
@@ -42,6 +42,16 @@ export function createPlayerRouter(
|
||||
return "";
|
||||
};
|
||||
|
||||
function isLocalAudioDisabled(bot: any, platform: unknown): boolean {
|
||||
return platform === "local" &&
|
||||
typeof bot.isLocalAudioEnabled === "function" &&
|
||||
!bot.isLocalAudioEnabled();
|
||||
}
|
||||
|
||||
function rejectDisabledLocalAudio(res: any): void {
|
||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||
}
|
||||
|
||||
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
@@ -100,8 +110,12 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -265,14 +279,18 @@ export function createPlayerRouter(
|
||||
|
||||
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
||||
// Respects current play mode (random = pick random first song)
|
||||
router.post("/:botId/play-playlist", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
router.post("/:botId/play-playlist", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Use the bot's own provider lookup — it already knows about youtube,
|
||||
// which the router's constructor params did not.
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -315,6 +333,10 @@ export function createPlayerRouter(
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
|
||||
// released and deleted, but an empty/failed playlist (early return above)
|
||||
// leaves the previous queue — and its files — intact.
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
|
||||
// Use queue.play() for sequential, or pick random index for random modes
|
||||
const mode = queue.getMode();
|
||||
@@ -352,12 +374,16 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
||||
router.post("/:botId/play-album", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
router.post("/:botId/play-album", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { albumId, platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -393,6 +419,8 @@ export function createPlayerRouter(
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
|
||||
const mode = queue.getMode();
|
||||
let first;
|
||||
@@ -432,13 +460,21 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const queue = bot.getQueueManager();
|
||||
bot.getPlayer().stop();
|
||||
queue.clear();
|
||||
queue.add(song);
|
||||
queue.play();
|
||||
|
||||
bot.getPlayer().resetFailures();
|
||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||
// Sweep AFTER the new song is queued+resolved, so replaying a local song
|
||||
// that was still in the queue doesn't delete the file we're about to play.
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
if (!ok) {
|
||||
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
|
||||
return;
|
||||
@@ -460,6 +496,10 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Serialize the queue mutation + playback so concurrent requests can't
|
||||
// interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
@@ -504,6 +544,10 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Serialize the insert-after-current + promote + playback so concurrent
|
||||
// requests can't interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
@@ -533,6 +577,10 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Serialize the queue mutation + (possible) playback so concurrent
|
||||
// requests can't interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
@@ -561,8 +609,12 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { songId, platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
@@ -217,6 +217,7 @@ describe("session router — guest mode", () => {
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
guestBots: "all",
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ describe("requireAuth middleware", () => {
|
||||
transport: true,
|
||||
removeClear: true,
|
||||
playMode: true,
|
||||
playCollection: true,
|
||||
},
|
||||
}))
|
||||
);
|
||||
@@ -100,7 +101,7 @@ describe("requireAuth middleware", () => {
|
||||
it("attaches guest permissions when guest mode is enabled", () => {
|
||||
const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) };
|
||||
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
|
||||
const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false };
|
||||
const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, playCollection: false };
|
||||
const getGuestConfig = () => ({ enabled: true, bots: ["bot1"], permissions: perms });
|
||||
const mw = createRequireAuth(sessions, permissions, getGuestConfig);
|
||||
const req: any = { headers: { cookie: "tsmb_session=x" }, secure: false };
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ export interface WebServerOptions {
|
||||
neteaseProvider: MusicProvider;
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
localProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
configPath: string;
|
||||
@@ -123,7 +124,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger)
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config)
|
||||
);
|
||||
app.use("/api/player", createPlayerRouter(
|
||||
options.botManager, logger, options.database,
|
||||
|
||||
+3
-1
@@ -118,8 +118,10 @@ let mobileRaf: number | null = null;
|
||||
|
||||
function updateMobileProgress() {
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
|
||||
// leave the mobile bar frozen between server pushes (#107).
|
||||
mobileProgressPct.value = duration > 0
|
||||
? Math.min((playerStore.elapsed / duration) * 100, 100)
|
||||
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
||||
: 0;
|
||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||
}
|
||||
|
||||
@@ -135,8 +135,9 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
// Use store.elapsed which interpolates from server ground truth
|
||||
currentElapsed.value = store.elapsed;
|
||||
// liveElapsed() (an action, not the cached `elapsed` getter) re-interpolates
|
||||
// from the server anchor on every frame so the clock ticks each second (#107).
|
||||
currentElapsed.value = store.liveElapsed();
|
||||
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
progressPercent.value = duration > 0
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span
|
||||
class="platform-badge"
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : '网易云' }}</span>
|
||||
</div>
|
||||
<div class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
@@ -135,6 +135,11 @@ function formatDuration(seconds: number): string {
|
||||
color: var(--brand-youtube);
|
||||
}
|
||||
|
||||
.badge-local {
|
||||
background: var(--color-primary-10);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { interpolateElapsed, type TimingState } from "./player.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function timing(partial: Partial<TimingState>): TimingState {
|
||||
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false, ...partial };
|
||||
}
|
||||
|
||||
describe("interpolateElapsed", () => {
|
||||
it("returns serverElapsed before playback has a sync anchor", () => {
|
||||
expect(interpolateElapsed(timing({ serverElapsed: 12, wasPlaying: false }), false, Infinity)).toBe(12);
|
||||
// wasPlaying but no sync time yet
|
||||
expect(interpolateElapsed(timing({ serverElapsed: 5, wasPlaying: true, serverSyncTime: 0 }), false, Infinity)).toBe(5);
|
||||
});
|
||||
|
||||
it("advances with wall-clock time while playing (regression: must not be frozen)", () => {
|
||||
const spy = vi.spyOn(Date, "now");
|
||||
const t = timing({ serverElapsed: 30, serverSyncTime: 10_000, wasPlaying: true });
|
||||
|
||||
spy.mockReturnValue(10_000);
|
||||
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(30, 5);
|
||||
|
||||
spy.mockReturnValue(11_000); // +1s
|
||||
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(31, 5);
|
||||
|
||||
spy.mockReturnValue(13_500); // +3.5s — distinct from the 1s reading
|
||||
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(33.5, 5);
|
||||
});
|
||||
|
||||
it("freezes at serverElapsed while paused", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(99_000);
|
||||
const t = timing({ serverElapsed: 42, serverSyncTime: 10_000, wasPlaying: true });
|
||||
expect(interpolateElapsed(t, true, Infinity)).toBe(42);
|
||||
});
|
||||
|
||||
it("clamps to maxDuration", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
|
||||
const t = timing({ serverElapsed: 100, serverSyncTime: 1_000, wasPlaying: true });
|
||||
expect(interpolateElapsed(t, false, 180)).toBe(180);
|
||||
});
|
||||
});
|
||||
+75
-18
@@ -10,7 +10,7 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local';
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq';
|
||||
@@ -47,7 +47,7 @@ export interface FavoritePlaylist {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TimingState {
|
||||
export interface TimingState {
|
||||
serverElapsed: number;
|
||||
serverSyncTime: number;
|
||||
wasPlaying: boolean;
|
||||
@@ -59,6 +59,33 @@ function defaultTiming(): TimingState {
|
||||
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate the live elapsed seconds from the last server anchor.
|
||||
*
|
||||
* This is a PURE function (its only time source is `Date.now()`), deliberately
|
||||
* kept OUT of the Pinia getter so it can be called fresh every animation frame.
|
||||
* The `elapsed` getter is a Vue `computed` and caches its result until a
|
||||
* REACTIVE dependency changes — but `Date.now()` is not reactive, so a getter
|
||||
* only re-runs on a WebSocket push / server poll (every few seconds). Reading
|
||||
* the getter from a requestAnimationFrame loop therefore returns a frozen value
|
||||
* and the clock appears to jump ~3s at a time (issue #107). Per-frame consumers
|
||||
* must call this helper (via the `liveElapsed` action) instead.
|
||||
*/
|
||||
export function interpolateElapsed(
|
||||
timing: TimingState,
|
||||
isPaused: boolean,
|
||||
maxDuration: number,
|
||||
): number {
|
||||
// No live anchor yet, or paused: report the frozen server position.
|
||||
if (!timing.wasPlaying || timing.serverSyncTime === 0 || isPaused) {
|
||||
return Math.min(timing.serverElapsed, maxDuration);
|
||||
}
|
||||
return Math.min(
|
||||
timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000,
|
||||
maxDuration,
|
||||
);
|
||||
}
|
||||
|
||||
export const usePlayerStore = defineStore('player', {
|
||||
state: () => ({
|
||||
bots: [] as BotStatus[],
|
||||
@@ -111,15 +138,19 @@ export const usePlayerStore = defineStore('player', {
|
||||
if (!botId) return [];
|
||||
return this.queues[botId] ?? [];
|
||||
},
|
||||
/** Interpolated elapsed for the active bot */
|
||||
/**
|
||||
* Interpolated elapsed for the active bot. NOTE: as a Pinia getter this is
|
||||
* a Vue `computed` and is CACHED — it only re-runs when a reactive
|
||||
* dependency changes, so it does NOT tick every second on its own. Use it
|
||||
* for one-off reactive reads; per-frame consumers (progress bar, lyrics)
|
||||
* must call the `liveElapsed` action so the clock advances smoothly (#107).
|
||||
*/
|
||||
elapsed(): number {
|
||||
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||
const timing = this.timings[botId] ?? defaultTiming();
|
||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration);
|
||||
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
|
||||
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
/** Sources that are currently logged in. Order: netease before qq. */
|
||||
availableSources(): Source[] {
|
||||
@@ -131,6 +162,21 @@ export const usePlayerStore = defineStore('player', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Live elapsed seconds for the active bot, recomputed on every call. Unlike
|
||||
* the `elapsed` getter (a cached computed), this is an action, so it is NOT
|
||||
* memoised — call it from requestAnimationFrame / interval loops so the
|
||||
* progress bar and lyrics advance every frame instead of jumping on each
|
||||
* server push (#107).
|
||||
*/
|
||||
liveElapsed(): number {
|
||||
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||
const timing = this.timings[botId] ?? defaultTiming();
|
||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
|
||||
_getTiming(botId: string): TimingState {
|
||||
if (!this.timings[botId]) {
|
||||
this.timings[botId] = defaultTiming();
|
||||
@@ -373,29 +419,40 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||
if (!this.activeBotId) return;
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
try {
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
}
|
||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||
this._syncAfterAction();
|
||||
} catch (e: any) {
|
||||
// A 403 here means a guest lacks the "play entire collection" permission
|
||||
// (issue #103) — surface it instead of failing silently.
|
||||
this.notify(e?.response?.status === 403 ? '没有权限播放整个歌单' : '播放歌单失败', 'error');
|
||||
}
|
||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||
this._syncAfterAction();
|
||||
},
|
||||
|
||||
async playAlbum(albumId: string, platform = 'netease') {
|
||||
if (!this.activeBotId) return;
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
try {
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
}
|
||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||
this._syncAfterAction();
|
||||
} catch (e: any) {
|
||||
this.notify(e?.response?.status === 403 ? '没有权限播放整个专辑' : '播放专辑失败', 'error');
|
||||
}
|
||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||
this._syncAfterAction();
|
||||
},
|
||||
|
||||
async pause() {
|
||||
if (!this.activeBotId) return;
|
||||
// Freeze elapsed at current interpolated value
|
||||
// Freeze elapsed at the current LIVE interpolated value. Using the cached
|
||||
// `elapsed` getter here could snapshot a value up to a few seconds stale.
|
||||
this._setTiming(this.activeBotId, {
|
||||
serverElapsed: this.elapsed,
|
||||
serverElapsed: this.liveElapsed(),
|
||||
wasPlaying: false,
|
||||
});
|
||||
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
||||
|
||||
@@ -136,7 +136,9 @@ function scrollToActiveLine(idx: number) {
|
||||
|
||||
function syncLyrics() {
|
||||
if (!store.isPlaying || lines.value.length === 0) return;
|
||||
const elapsed = store.elapsed;
|
||||
// liveElapsed() (action) is recomputed now; the cached `elapsed` getter only
|
||||
// refreshed on server pushes, leaving highlights ~half a line behind (#107).
|
||||
const elapsed = store.liveElapsed();
|
||||
const idx = findActiveLine(elapsed);
|
||||
// Only update when the active line actually changes
|
||||
if (idx !== activeLine.value && idx >= 0) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{ songs.length }} 首歌曲
|
||||
</div>
|
||||
<div class="playlist-actions">
|
||||
<button class="play-all-btn" @click="playAll">
|
||||
<button v-if="canPlayAll" class="play-all-btn" @click="playAll">
|
||||
<Icon icon="mdi:play" />
|
||||
播放全部
|
||||
</button>
|
||||
@@ -54,16 +54,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
const { can, guestCan } = useSession();
|
||||
|
||||
// "Play all" loads + plays the whole collection (clears the queue). Members
|
||||
// need player.control; guests need the playCollection flag (issue #103).
|
||||
const canPlayAll = computed(() => can('player.control') || guestCan('playCollection'));
|
||||
|
||||
import { Song } from '../stores/player.js';
|
||||
|
||||
|
||||
+230
-7
@@ -16,6 +16,41 @@
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="localAudioEnabled"
|
||||
class="local-upload"
|
||||
:class="{ dragging: isDragging, uploading }"
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
|
||||
<div class="upload-copy">
|
||||
<div class="upload-title">拖拽本地音频到这里上传</div>
|
||||
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
|
||||
</div>
|
||||
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
|
||||
{{ uploading ? '上传中...' : '选择音频' }}
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="local-upload disabled">
|
||||
<Icon icon="mdi:music-off" class="upload-icon" />
|
||||
<div class="upload-copy">
|
||||
<div class="upload-title">本地音频播放已关闭</div>
|
||||
<div class="upload-subtitle">管理员可在「设置 → 行为设置 → 本地音频播放」中开启。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadMessage" class="upload-message" :class="uploadMessageType">{{ uploadMessage }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
@@ -37,6 +72,12 @@
|
||||
:class="{ active: selectedSource === 'bilibili' }"
|
||||
@click="selectedSource = 'bilibili'"
|
||||
>B站</button>
|
||||
<button
|
||||
v-if="hasLocalSongs"
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'local' }"
|
||||
@click="selectedSource = 'local'"
|
||||
>本地</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
@@ -48,7 +89,7 @@
|
||||
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili'"
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'albums' }"
|
||||
@click="activeTab = 'albums'"
|
||||
@@ -56,7 +97,7 @@
|
||||
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili'"
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'playlists' }"
|
||||
@click="activeTab = 'playlists'"
|
||||
@@ -141,17 +182,19 @@ const router = useRouter();
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'search-source';
|
||||
|
||||
function loadSource(): 'netease' | 'qq' | 'bilibili' {
|
||||
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local';
|
||||
|
||||
function loadSource(): SearchSource {
|
||||
try {
|
||||
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local') return stored;
|
||||
} catch { /* localStorage blocked */ }
|
||||
return 'netease';
|
||||
}
|
||||
|
||||
const query = ref((route.query.q as string) || '');
|
||||
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
||||
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
|
||||
const selectedSource = ref<SearchSource>(loadSource());
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
@@ -161,6 +204,12 @@ const allAlbums = ref<Album[]>([]);
|
||||
const allPlaylists = ref<Playlist[]>([]);
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
const uploading = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const uploadMessage = ref('');
|
||||
const uploadMessageType = ref<'info' | 'error'>('info');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const localAudioEnabled = ref(true);
|
||||
|
||||
const filteredSongs = computed(() =>
|
||||
allSongs.value.filter((s) => s.platform === selectedSource.value)
|
||||
@@ -174,14 +223,16 @@ const filteredPlaylists = computed(() =>
|
||||
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||
|
||||
// Persist source preference
|
||||
watch(selectedSource, (src) => {
|
||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// B站 has no albums/playlists — force songs tab when switching to B站
|
||||
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
|
||||
watch(selectedSource, (src) => {
|
||||
if (src === 'bilibili' && activeTab.value !== 'songs') {
|
||||
if ((src === 'bilibili' || src === 'local') && activeTab.value !== 'songs') {
|
||||
activeTab.value = 'songs';
|
||||
}
|
||||
});
|
||||
@@ -223,10 +274,83 @@ async function doSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function isAudioFile(file: File): boolean {
|
||||
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
|
||||
}
|
||||
|
||||
async function uploadLocalFiles(fileList: File[]) {
|
||||
if (!localAudioEnabled.value) {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = '本地音频播放已关闭';
|
||||
return;
|
||||
}
|
||||
const files = fileList.filter(isAudioFile);
|
||||
if (files.length === 0) {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = '没有找到可上传的音频文件';
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
uploadMessageType.value = 'info';
|
||||
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
|
||||
|
||||
const uploaded: Song[] = [];
|
||||
const failed: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const res = await axios.post('/api/music/local/upload', file, {
|
||||
headers: {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
});
|
||||
if (res.data?.song) uploaded.push(res.data.song as Song);
|
||||
} catch (err: any) {
|
||||
failed.push(`${file.name}: ${err?.response?.data?.error || '上传失败'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded.length > 0) {
|
||||
const uploadedKeys = new Set(uploaded.map((s) => `${s.platform}-${s.id}`));
|
||||
allSongs.value = [
|
||||
...uploaded,
|
||||
...allSongs.value.filter((s) => !uploadedKeys.has(`${s.platform}-${s.id}`)),
|
||||
];
|
||||
selectedSource.value = 'local';
|
||||
activeTab.value = 'songs';
|
||||
searched.value = true;
|
||||
uploadMessageType.value = failed.length ? 'error' : 'info';
|
||||
uploadMessage.value = failed.length
|
||||
? `已上传 ${uploaded.length} 个,失败 ${failed.length} 个:${failed[0]}`
|
||||
: `已上传 ${uploaded.length} 个本地音频`;
|
||||
} else {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = failed[0] || '上传失败';
|
||||
}
|
||||
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragging.value = false;
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
uploadLocalFiles(files);
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
uploadLocalFiles(Array.from(input.files ?? []));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function badgeLabel(platform: string): string {
|
||||
if (platform === 'qq') return 'QQ';
|
||||
if (platform === 'bilibili') return 'B站';
|
||||
if (platform === 'youtube') return 'YouTube';
|
||||
if (platform === 'local') return '本地';
|
||||
return '网易云';
|
||||
}
|
||||
|
||||
@@ -234,10 +358,24 @@ function badgeClass(platform: string): string {
|
||||
if (platform === 'qq') return 'badge-qq';
|
||||
if (platform === 'bilibili') return 'badge-bilibili';
|
||||
if (platform === 'youtube') return 'badge-youtube';
|
||||
if (platform === 'local') return 'badge-local';
|
||||
return 'badge-netease';
|
||||
}
|
||||
|
||||
async function loadLocalAudioSetting() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
|
||||
if (!localAudioEnabled.value && selectedSource.value === 'local') {
|
||||
selectedSource.value = 'netease';
|
||||
}
|
||||
} catch {
|
||||
// Guests may not be allowed to read settings; backend still enforces the switch.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLocalAudioSetting();
|
||||
if (query.value) doSearch();
|
||||
});
|
||||
</script>
|
||||
@@ -258,6 +396,86 @@ onMounted(() => {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.local-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast), transform var(--transition-fast);
|
||||
|
||||
&.dragging {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 28px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.upload-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-size: 14px;
|
||||
font-weight: var(--fw-semi);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-subtitle {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: var(--fw-semi);
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.local-upload.disabled {
|
||||
opacity: 0.65;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.upload-message {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -422,6 +640,11 @@ onMounted(() => {
|
||||
color: var(--brand-youtube);
|
||||
}
|
||||
|
||||
.badge-local {
|
||||
background: var(--color-primary-10);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fav-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
@@ -453,6 +453,19 @@
|
||||
@change="saveAutoPause"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="profile-toggle behavior-toggle">
|
||||
<div class="profile-toggle-text">
|
||||
<div class="profile-toggle-label">本地音频播放</div>
|
||||
<div class="profile-toggle-hint">开启后允许在搜索页拖拽/选择本地音频上传并播放;关闭后会拒绝新的本地上传和本地歌曲播放请求。</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="localAudioEnabled"
|
||||
type="checkbox"
|
||||
class="profile-toggle-switch"
|
||||
@change="saveLocalAudioEnabled"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Guest Mode (admin only) -->
|
||||
@@ -504,6 +517,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>
|
||||
@@ -1020,13 +1050,16 @@ async function savePrefix() {
|
||||
const idleTimeout = ref(0);
|
||||
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
|
||||
const autoPauseOnEmpty = ref(false);
|
||||
const localAudioEnabled = ref(true);
|
||||
|
||||
async function loadIdleTimeout() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
|
||||
applyGuestModeFromServer(res.data.guestMode);
|
||||
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1042,6 +1075,13 @@ async function saveAutoPause() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveLocalAudioEnabled() {
|
||||
try {
|
||||
const res = await axios.post('/api/bot/settings', { localAudioEnabled: localAudioEnabled.value });
|
||||
localAudioEnabled.value = res.data.localAudioEnabled ?? localAudioEnabled.value;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Guest mode (admin only) ---
|
||||
const GUEST_FLAGS: { token: string; label: string }[] = [
|
||||
{ token: 'addToQueue', label: '添加到队列末尾' },
|
||||
@@ -1051,12 +1091,13 @@ const GUEST_FLAGS: { token: string; label: string }[] = [
|
||||
{ token: 'transport', label: '暂停/继续/进度/音量' },
|
||||
{ token: 'removeClear', label: '移除/清空队列' },
|
||||
{ token: 'playMode', label: '切换播放模式 / FM' },
|
||||
{ token: 'playCollection', label: '播放整个歌单/专辑' },
|
||||
];
|
||||
const guestMode = reactive<{ enabled: boolean; botsAll: boolean; selectedBotIds: string[]; permissions: Record<string, boolean> }>({
|
||||
enabled: false,
|
||||
botsAll: true,
|
||||
selectedBotIds: [],
|
||||
permissions: { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false },
|
||||
permissions: { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, playCollection: false },
|
||||
});
|
||||
const guestSaving = ref(false);
|
||||
|
||||
@@ -1092,6 +1133,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