mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34655e5f50 | ||
|
|
491bc53dec | ||
|
|
8f5bb26b3a | ||
|
|
4ba4b013b0 | ||
|
|
484202e90d | ||
|
|
9f0ac74fbc | ||
|
|
51c954993a |
No files matched your search
@@ -0,0 +1,196 @@
|
|||||||
|
# Auto-pause on Empty Channel — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
|
||||||
|
|
||||||
|
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
|
||||||
|
|
||||||
|
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Pure occupancy-decision function
|
||||||
|
|
||||||
|
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
describe("decideOccupancyAction", () => {
|
||||||
|
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
|
||||||
|
it("pauses when empty while playing and enabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||||
|
});
|
||||||
|
it("does not pause when the feature is disabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when idle (nothing playing)", () => {
|
||||||
|
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when already paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resumes when re-populated and we auto-paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||||
|
});
|
||||||
|
it("does NOT resume a user-paused track on re-population", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does nothing when re-populated and already playing", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||||
|
export type OccupancyAction = "pause" | "resume" | "none";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what auto-pause should do given the channel occupancy.
|
||||||
|
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||||
|
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||||
|
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||||
|
*/
|
||||||
|
export function decideOccupancyAction(
|
||||||
|
playerState: PlayerStateName,
|
||||||
|
autoPaused: boolean,
|
||||||
|
enabled: boolean,
|
||||||
|
userCount: number,
|
||||||
|
): OccupancyAction {
|
||||||
|
const empty = userCount <= 0;
|
||||||
|
if (empty) {
|
||||||
|
if (enabled && playerState === "playing") return "pause";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
if (autoPaused && playerState === "paused") return "resume";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
|
||||||
|
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
|
||||||
|
|
||||||
|
**Files:** Modify `src/bot/instance.ts`.
|
||||||
|
|
||||||
|
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
private handleOccupancy(userCount: number): void {
|
||||||
|
// idle-disconnect (unchanged behavior)
|
||||||
|
if (userCount <= 0) this._scheduleIdleCheck();
|
||||||
|
else this._cancelIdleTimer();
|
||||||
|
|
||||||
|
// auto-pause
|
||||||
|
const action = decideOccupancyAction(
|
||||||
|
this.player.getState() as "idle" | "playing" | "paused",
|
||||||
|
this.autoPaused,
|
||||||
|
this.config.autoPauseOnEmpty,
|
||||||
|
userCount,
|
||||||
|
);
|
||||||
|
if (action === "pause") {
|
||||||
|
this.player.pause();
|
||||||
|
this.autoPaused = true;
|
||||||
|
this.emit("stateChange");
|
||||||
|
} else if (action === "resume") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
|
||||||
|
|
||||||
|
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
updateAutoPause(enabled: boolean): void {
|
||||||
|
this.config.autoPauseOnEmpty = enabled;
|
||||||
|
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
|
||||||
|
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
|
||||||
|
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Re-emit TS member events for instant reaction
|
||||||
|
|
||||||
|
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
|
||||||
|
|
||||||
|
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
|
||||||
|
|
||||||
|
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
|
||||||
|
|
||||||
|
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
|
||||||
|
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: API wiring for the toggle
|
||||||
|
|
||||||
|
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
|
||||||
|
|
||||||
|
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
|
||||||
|
|
||||||
|
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
|
||||||
|
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend toggle in Settings
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
|
||||||
|
|
||||||
|
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
- [ ] `npx tsc --noEmit` → 0
|
||||||
|
- [ ] `npx vitest run src/` → all pass
|
||||||
|
- [ ] `cd web && npx vue-tsc --noEmit` → 0
|
||||||
|
- [ ] `npm run build` → succeeds
|
||||||
|
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Auto-pause on empty channel — design
|
||||||
|
|
||||||
|
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
|
||||||
|
**Date:** 2026-05-30
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
|
||||||
|
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
|
||||||
|
and resume when someone returns.
|
||||||
|
|
||||||
|
## Decisions (from brainstorm)
|
||||||
|
|
||||||
|
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
|
||||||
|
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
|
||||||
|
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
|
||||||
|
enter/leave/move events; keep the existing 30s idle poll as a fallback.
|
||||||
|
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
|
||||||
|
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
|
||||||
|
emptiness signal but act independently (pause immediately; disconnect after N minutes).
|
||||||
|
|
||||||
|
## Current state (verified)
|
||||||
|
|
||||||
|
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
|
||||||
|
bot*; callers compute "others" as `length - 1`. No persistent roster.
|
||||||
|
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
|
||||||
|
*logs* `clientEnter` and does not re-emit leave/moved.
|
||||||
|
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
|
||||||
|
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
|
||||||
|
idle-disconnect after `idleTimeoutMinutes`.
|
||||||
|
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
|
||||||
|
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
|
||||||
|
auto-vs-user-pause distinction today.
|
||||||
|
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Occupancy signal (shared)
|
||||||
|
Extract the idle poller's count into one method on `BotInstance`:
|
||||||
|
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
|
||||||
|
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
|
||||||
|
auto-pause logic below. It is called by:
|
||||||
|
1. the existing 30s poll (fallback), and
|
||||||
|
2. new TS event handlers.
|
||||||
|
|
||||||
|
### Event subscription
|
||||||
|
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
|
||||||
|
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
|
||||||
|
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
|
||||||
|
the poll remains as a safety net.
|
||||||
|
|
||||||
|
### Auto-pause logic (inside `checkChannelOccupancy`)
|
||||||
|
Add a private `autoPaused = false` flag to `BotInstance`.
|
||||||
|
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
|
||||||
|
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
|
||||||
|
scheduled as today.)
|
||||||
|
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
|
||||||
|
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
|
||||||
|
|
||||||
|
### `autoPaused` bookkeeping (so user pauses are respected)
|
||||||
|
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
|
||||||
|
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
|
||||||
|
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
|
||||||
|
|
||||||
|
### Config wiring
|
||||||
|
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
|
||||||
|
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
|
||||||
|
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
|
||||||
|
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
|
||||||
|
can be as simple as updating the stored config reference / a field the check reads.
|
||||||
|
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
|
||||||
|
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
|
||||||
|
it on save.
|
||||||
|
|
||||||
|
## Components / files
|
||||||
|
|
||||||
|
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
|
||||||
|
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
|
||||||
|
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
|
||||||
|
connect/disconnect; `updateAutoPause(enabled)`.
|
||||||
|
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
|
||||||
|
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
|
||||||
|
- `src/data/config.ts` — field already exists (no change beyond confirming default).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
|
||||||
|
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
|
||||||
|
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
|
||||||
|
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
|
||||||
|
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
|
||||||
|
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
|
||||||
|
persists, propagates).
|
||||||
|
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
|
||||||
|
- Reaction relies on events the bot can already see (same-channel members are always in view);
|
||||||
|
no extra channel subscription needed.
|
||||||
@@ -484,84 +484,4 @@ describe("PlayQueue", () => {
|
|||||||
expect(promoted?.id).toBe("x");
|
expect(promoted?.id).toBe("x");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
|
|
||||||
// songs repeated often while others were starved. It should behave like a
|
|
||||||
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
|
|
||||||
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
|
|
||||||
describe("random-loop shuffle bag (issue #70)", () => {
|
|
||||||
it("plays every song exactly once per cycle before repeating", () => {
|
|
||||||
queue.setMode(PlayMode.RandomLoop);
|
|
||||||
const N = 12;
|
|
||||||
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
|
||||||
queue.play();
|
|
||||||
|
|
||||||
const cycle1 = [queue.current()!.id];
|
|
||||||
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
|
|
||||||
const cycle2: string[] = [];
|
|
||||||
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
|
|
||||||
|
|
||||||
// Each cycle is a full permutation of all N songs — zero repeats within
|
|
||||||
// a cycle, and both cycles cover the same complete set.
|
|
||||||
expect(new Set(cycle1).size).toBe(N);
|
|
||||||
expect(new Set(cycle2).size).toBe(N);
|
|
||||||
expect(new Set(cycle1)).toEqual(new Set(cycle2));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
|
|
||||||
queue.setMode(PlayMode.RandomLoop);
|
|
||||||
const N = 6;
|
|
||||||
const CYCLES = 20;
|
|
||||||
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
|
||||||
queue.play();
|
|
||||||
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
counts.set(queue.current()!.id, 1);
|
|
||||||
for (let i = 0; i < CYCLES * N - 1; i++) {
|
|
||||||
const id = queue.next()!.id;
|
|
||||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shuffle bag => each song plays exactly CYCLES times. True random
|
|
||||||
// would skew heavily.
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
expect(counts.get(`s${i}`)).toBe(CYCLES);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not replay the same song across a cycle boundary", () => {
|
|
||||||
queue.setMode(PlayMode.RandomLoop);
|
|
||||||
const N = 5;
|
|
||||||
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
|
||||||
queue.play();
|
|
||||||
|
|
||||||
// Walk to the last song of cycle 1, then cross into cycle 2.
|
|
||||||
for (let i = 0; i < N - 1; i++) queue.next();
|
|
||||||
const lastOfCycle1 = queue.current()!.id;
|
|
||||||
const firstOfCycle2 = queue.next()!.id;
|
|
||||||
expect(firstOfCycle2).not.toBe(lastOfCycle1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes a song added mid-cycle within the current cycle", () => {
|
|
||||||
queue.setMode(PlayMode.RandomLoop);
|
|
||||||
queue.add(makeSong("A"));
|
|
||||||
queue.add(makeSong("B"));
|
|
||||||
queue.play(); // A
|
|
||||||
queue.next(); // B — both originals now played this cycle
|
|
||||||
queue.add(makeSong("C")); // added mid-cycle, still unplayed
|
|
||||||
// C is the only unplayed song, so it must come next (not a reshuffle).
|
|
||||||
expect(queue.next()?.id).toBe("C");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps looping forever with multiple songs (never returns null)", () => {
|
|
||||||
queue.setMode(PlayMode.RandomLoop);
|
|
||||||
queue.add(makeSong("A"));
|
|
||||||
queue.add(makeSong("B"));
|
|
||||||
queue.add(makeSong("C"));
|
|
||||||
queue.play();
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
expect(queue.next()).not.toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+17
-26
@@ -155,42 +155,33 @@ export class PlayQueue {
|
|||||||
return this.songs[target];
|
return this.songs[target];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 前进栈为空,走纯随机逻辑
|
||||||
// Shuffle bag: pick uniformly from the songs not yet played this
|
if (this.mode === PlayMode.Random) {
|
||||||
// cycle, so every song plays once before any repeats (NetEase/QQ
|
|
||||||
// style). Songs added mid-cycle aren't in playedIndices, so they're
|
|
||||||
// naturally eligible within the current cycle.
|
|
||||||
const unplayed: number[] = [];
|
const unplayed: number[] = [];
|
||||||
for (let i = 0; i < this.songs.length; i++) {
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||||
}
|
}
|
||||||
|
if (unplayed.length === 0) return null;
|
||||||
if (unplayed.length === 0) {
|
|
||||||
// Cycle complete.
|
|
||||||
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
|
|
||||||
// 随机循环:reshuffle and keep going forever.
|
|
||||||
if (this.songs.length === 1) {
|
|
||||||
this.pushHistory(this.currentIndex);
|
|
||||||
this.currentIndex = 0;
|
|
||||||
this.playedIndices = new Set([0]);
|
|
||||||
return this.songs[0];
|
|
||||||
}
|
|
||||||
// Start a fresh cycle: every song is eligible again, but exclude
|
|
||||||
// the song that just played from THIS pick only, so it doesn't
|
|
||||||
// repeat back-to-back across the boundary. It stays eligible for
|
|
||||||
// the rest of the new cycle, so every song still plays exactly once.
|
|
||||||
this.playedIndices = new Set();
|
|
||||||
for (let i = 0; i < this.songs.length; i++) {
|
|
||||||
if (i !== this.currentIndex) unplayed.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextIndex =
|
const nextIndex =
|
||||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||||
this.pushHistory(this.currentIndex);
|
this.pushHistory(this.currentIndex);
|
||||||
this.currentIndex = nextIndex;
|
this.currentIndex = nextIndex;
|
||||||
this.playedIndices.add(nextIndex);
|
this.playedIndices.add(nextIndex);
|
||||||
return this.songs[nextIndex];
|
return this.songs[nextIndex];
|
||||||
|
} else {
|
||||||
|
if (this.songs.length === 1) {
|
||||||
|
this.pushHistory(this.currentIndex);
|
||||||
|
this.currentIndex = 0;
|
||||||
|
return this.songs[0];
|
||||||
|
}
|
||||||
|
let idx: number;
|
||||||
|
do {
|
||||||
|
idx = Math.floor(Math.random() * this.songs.length);
|
||||||
|
} while (idx === this.currentIndex);
|
||||||
|
this.pushHistory(this.currentIndex);
|
||||||
|
this.currentIndex = idx;
|
||||||
|
return this.songs[idx];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
describe("decideOccupancyAction", () => {
|
||||||
|
it("pauses when empty while playing and enabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||||
|
});
|
||||||
|
it("does not pause when the feature is disabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when idle (nothing playing)", () => {
|
||||||
|
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when already paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resumes when re-populated and we auto-paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||||
|
});
|
||||||
|
it("does NOT resume a user-paused track on re-population", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does nothing when re-populated and already playing", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||||
|
export type OccupancyAction = "pause" | "resume" | "none";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what auto-pause should do given channel occupancy.
|
||||||
|
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||||
|
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||||
|
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||||
|
*/
|
||||||
|
export function decideOccupancyAction(
|
||||||
|
playerState: PlayerStateName,
|
||||||
|
autoPaused: boolean,
|
||||||
|
enabled: boolean,
|
||||||
|
userCount: number,
|
||||||
|
): OccupancyAction {
|
||||||
|
const empty = userCount <= 0;
|
||||||
|
if (empty) {
|
||||||
|
if (enabled && playerState === "playing") return "pause";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
if (autoPaused && playerState === "paused") return "resume";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
+63
-5
@@ -17,6 +17,7 @@ import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
|||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
import { BotProfileManager } from "./profile.js";
|
import { BotProfileManager } from "./profile.js";
|
||||||
import type { AvatarStore } from "../data/avatars.js";
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -66,6 +67,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private isAdvancing = false;
|
private isAdvancing = false;
|
||||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private channelUserCount = 0;
|
private channelUserCount = 0;
|
||||||
|
private autoPaused = false;
|
||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
private isFmMode = false;
|
private isFmMode = false;
|
||||||
|
|
||||||
@@ -143,6 +145,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
// 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
|
// Only emit externally once per lifecycle so clients don't see a
|
||||||
// duplicate "disconnected" after an explicit disconnect() call.
|
// duplicate "disconnected" after an explicit disconnect() call.
|
||||||
if (this.disconnectEmitted) return;
|
if (this.disconnectEmitted) return;
|
||||||
@@ -151,8 +155,26 @@ export class BotInstance extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.tsClient.on("connected", () => {
|
this.tsClient.on("connected", () => {
|
||||||
|
// Fresh connection — clear any stale auto-pause flag from a prior session.
|
||||||
|
this.autoPaused = false;
|
||||||
this._startIdlePoller();
|
this._startIdlePoller();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// React near-instantly to channel membership changes. The 30s idle
|
||||||
|
// poller remains the fallback if any of these events are missed.
|
||||||
|
this.tsClient.on("clientEnter", () => void this.refreshOccupancy());
|
||||||
|
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
|
||||||
|
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshOccupancy(): Promise<void> {
|
||||||
|
if (!this.connected) return;
|
||||||
|
try {
|
||||||
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
|
this.handleOccupancy(clients.length - 1);
|
||||||
|
} catch {
|
||||||
|
// ignore — the 30s poll is the fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
@@ -187,6 +209,16 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (minutes === 0) this._cancelIdleTimer();
|
if (minutes === 0) this._cancelIdleTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 外部更新 autoPauseOnEmpty(由 API 保存时调用) */
|
||||||
|
updateAutoPause(enabled: boolean): void {
|
||||||
|
this.config.autoPauseOnEmpty = enabled;
|
||||||
|
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _startIdlePoller(): void {
|
private _startIdlePoller(): void {
|
||||||
// 每 30 秒检查一次频道人数
|
// 每 30 秒检查一次频道人数
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@@ -194,17 +226,35 @@ export class BotInstance extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const clients = await this.tsClient.getClientsInChannel();
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
const userCount = clients.length - 1; // 排除 bot 自身
|
const userCount = clients.length - 1; // 排除 bot 自身
|
||||||
if (userCount <= 0) {
|
this.handleOccupancy(userCount);
|
||||||
this._scheduleIdleCheck();
|
|
||||||
} else {
|
|
||||||
this._cancelIdleTimer();
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
setTimeout(poll, 30_000);
|
setTimeout(poll, 30_000);
|
||||||
};
|
};
|
||||||
setTimeout(poll, 30_000);
|
setTimeout(poll, 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleOccupancy(userCount: number): void {
|
||||||
|
// idle-disconnect (unchanged behavior)
|
||||||
|
if (userCount <= 0) this._scheduleIdleCheck();
|
||||||
|
else this._cancelIdleTimer();
|
||||||
|
// auto-pause
|
||||||
|
const action = decideOccupancyAction(
|
||||||
|
this.player.getState(),
|
||||||
|
this.autoPaused,
|
||||||
|
this.config.autoPauseOnEmpty,
|
||||||
|
userCount,
|
||||||
|
);
|
||||||
|
if (action === "pause") {
|
||||||
|
this.player.pause();
|
||||||
|
this.autoPaused = true;
|
||||||
|
this.emit("stateChange");
|
||||||
|
} else if (action === "resume") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _scheduleIdleCheck(): void {
|
private _scheduleIdleCheck(): void {
|
||||||
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||||
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||||
@@ -381,6 +431,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
song.url = url;
|
song.url = url;
|
||||||
this.player.play(url, 0, song.duration);
|
this.player.play(url, 0, song.duration);
|
||||||
|
// 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;
|
||||||
this.database.addPlayHistory({
|
this.database.addPlayHistory({
|
||||||
botId: this.id,
|
botId: this.id,
|
||||||
songId: song.id,
|
songId: song.id,
|
||||||
@@ -483,18 +536,23 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private cmdPause(): string {
|
private cmdPause(): string {
|
||||||
this.player.pause();
|
this.player.pause();
|
||||||
|
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
|
||||||
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Paused";
|
return "Paused";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdResume(): string {
|
private cmdResume(): string {
|
||||||
this.player.resume();
|
this.player.resume();
|
||||||
|
// User-initiated resume — drop any auto-pause flag.
|
||||||
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Resumed";
|
return "Resumed";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.autoPaused = false;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.isFmMode = false;
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type Identity,
|
type Identity,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type ClientInfo,
|
type ClientInfo,
|
||||||
|
type ClientLeftViewEvent,
|
||||||
|
type ClientMovedEvent,
|
||||||
type FileUploadInfo,
|
type FileUploadInfo,
|
||||||
} from "@honeybbq/teamspeak-client";
|
} from "@honeybbq/teamspeak-client";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
@@ -221,6 +223,20 @@ export class TS3Client extends EventEmitter {
|
|||||||
{ nickname: info.nickname, id: info.id },
|
{ nickname: info.nickname, id: info.id },
|
||||||
"Client entered"
|
"Client entered"
|
||||||
);
|
);
|
||||||
|
this.emit("clientEnter", info);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
|
||||||
|
this.logger.debug({ id: ev.id }, "Client left");
|
||||||
|
this.emit("clientLeave", ev);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
|
||||||
|
this.logger.debug(
|
||||||
|
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
|
||||||
|
"Client moved"
|
||||||
|
);
|
||||||
|
this.emit("clientMoved", ev);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore } from "../../data/sessions.js";
|
||||||
|
import { createAvatarStore } from "../../data/avatars.js";
|
||||||
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
|
import { createBotRouter } from "./bot.js";
|
||||||
|
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
|
|
||||||
|
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
|
||||||
|
function makeFakeBot() {
|
||||||
|
return {
|
||||||
|
idleTimeoutCalls: [] as number[],
|
||||||
|
autoPauseCalls: [] as boolean[],
|
||||||
|
updateIdleTimeout(minutes: number) {
|
||||||
|
this.idleTimeoutCalls.push(minutes);
|
||||||
|
},
|
||||||
|
updateAutoPause(enabled: boolean) {
|
||||||
|
this.autoPauseCalls.push(enabled);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bot router /settings", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let app: express.Express;
|
||||||
|
let cookie: string;
|
||||||
|
let config: BotConfig;
|
||||||
|
let configPath: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
let fakeBots: ReturnType<typeof makeFakeBot>[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
const users = createUserStore(botDb.db);
|
||||||
|
const sessions = createSessionStore(botDb.db);
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||||
|
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-"));
|
||||||
|
configPath = join(tmpDir, "config.json");
|
||||||
|
config = { ...getDefaultConfig(), idleTimeoutMinutes: 15, autoPauseOnEmpty: true };
|
||||||
|
|
||||||
|
fakeBots = [makeFakeBot(), makeFakeBot()];
|
||||||
|
const fakeManager = {
|
||||||
|
getAllBots: () => fakeBots,
|
||||||
|
} as unknown as BotManager;
|
||||||
|
const avatarStore = createAvatarStore(tmpDir);
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use("/api", createRequireAuth(sessions));
|
||||||
|
app.use(
|
||||||
|
"/api/bot",
|
||||||
|
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
botDb.close();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires auth", async () => {
|
||||||
|
const res = await request(app).get("/api/bot/settings");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /settings includes autoPauseOnEmpty reflecting config", async () => {
|
||||||
|
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.idleTimeoutMinutes).toBe(15);
|
||||||
|
expect(res.body.autoPauseOnEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// in-memory config mutated
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(false);
|
||||||
|
|
||||||
|
// propagated to every live bot
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow-up GET reflects the new value
|
||||||
|
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(followUp.body.autoPauseOnEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings still handles idleTimeoutMinutes (no regression)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 42 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(42);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([42]);
|
||||||
|
}
|
||||||
|
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(followUp.body.idleTimeoutMinutes).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings handles both fields together", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 7, autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(7);
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(false);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([7]);
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings with only autoPauseOnEmpty does not touch idleTimeout bots", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([]);
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings ignores non-boolean autoPauseOnEmpty without 400", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 5, autoPauseOnEmpty: "yes" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// idleTimeout still applied
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(5);
|
||||||
|
// autoPause left at its prior value, not propagated
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(true);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.autoPauseCalls).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+37
-21
@@ -21,6 +21,43 @@ export function createBotRouter(
|
|||||||
res.json({ bots });
|
res.json({ bots });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||||
|
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route.
|
||||||
|
router.get("/settings", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/bot/settings — 保存全局 bot 行为设置
|
||||||
|
router.post("/settings", (req, res) => {
|
||||||
|
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
|
||||||
|
|
||||||
|
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||||
|
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
||||||
|
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
|
||||||
|
|
||||||
|
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||||
|
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||||
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
// 通知所有 bot 实例更新
|
||||||
|
for (const bot of botManager.getAllBots()) {
|
||||||
|
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
|
||||||
|
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/:id", (req, res) => {
|
router.get("/:id", (req, res) => {
|
||||||
const bot = botManager.getBot(req.params.id);
|
const bot = botManager.getBot(req.params.id);
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
@@ -186,26 +223,5 @@ export function createBotRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/bot/settings — 读取全局 bot 行为设置
|
|
||||||
router.get("/settings", (_req, res) => {
|
|
||||||
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/bot/settings — 保存全局 bot 行为设置
|
|
||||||
router.post("/settings", (req, res) => {
|
|
||||||
const { idleTimeoutMinutes } = req.body;
|
|
||||||
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
|
|
||||||
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
config.idleTimeoutMinutes = idleTimeoutMinutes;
|
|
||||||
saveConfig(configPath, config);
|
|
||||||
// 通知所有 bot 实例更新定时器
|
|
||||||
for (const bot of botManager.getAllBots()) {
|
|
||||||
bot.updateIdleTimeout(idleTimeoutMinutes);
|
|
||||||
}
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -55,19 +55,4 @@ describe("csrfOriginCheck middleware", () => {
|
|||||||
.set("Referer", "https://evil.com/some/path");
|
.set("Referer", "https://evil.com/some/path");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents the server side of the QR-login outage: a `no-referrer` document
|
|
||||||
// policy makes the browser send the literal `Origin: null` on same-origin
|
|
||||||
// POSTs, which this guard cannot parse a host from and therefore rejects.
|
|
||||||
// The fix lives in the frontend (referrer policy -> same-origin); this test
|
|
||||||
// pins the gate behavior so the interaction stays understood. See
|
|
||||||
// src/web/referrer-policy.test.ts.
|
|
||||||
it('rejects POST with the literal Origin: "null" (no-referrer downgrade)', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/")
|
|
||||||
.set("Host", "example.com")
|
|
||||||
.set("Origin", "null");
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
expect(res.body).toEqual({ error: "bad origin" });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression guard for the QR-login / cookie-save outage (and in fact every
|
|
||||||
* mutating WebUI action). On 2026-05-27 the WebUI-auth feature added the
|
|
||||||
* same-origin CSRF gate `app.use("/api", csrfOriginCheck)` in server.ts, and
|
|
||||||
* the same day a `<meta name="referrer" content="no-referrer">` was added to
|
|
||||||
* web/index.html so cross-origin CDN cover thumbnails would load.
|
|
||||||
*
|
|
||||||
* Those two changes conflict: per the WHATWG Fetch "Append a request Origin
|
|
||||||
* header" algorithm, the `no-referrer` policy sets the Origin header to the
|
|
||||||
* literal string "null" on same-origin non-GET requests. csrfOriginCheck then
|
|
||||||
* fails to parse a host (`new URL("null")` throws) and returns 403 "bad
|
|
||||||
* origin", so POST /api/auth/qrcode (and every other POST/PUT/DELETE under
|
|
||||||
* /api/* except /api/session/*) never reaches its handler.
|
|
||||||
*
|
|
||||||
* `same-origin` is the correct policy: it keeps the real Origin on same-origin
|
|
||||||
* requests (CSRF passes) while still sending no Referer cross-origin (CDN
|
|
||||||
* thumbnails keep loading). Never switch this back to `no-referrer`.
|
|
||||||
*/
|
|
||||||
describe("frontend referrer policy (CSRF / Origin-header regression)", () => {
|
|
||||||
const indexHtmlPath = path.resolve(
|
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
|
||||||
"../../web/index.html"
|
|
||||||
);
|
|
||||||
const html = fs.readFileSync(indexHtmlPath, "utf-8");
|
|
||||||
|
|
||||||
const referrerMeta = html.match(
|
|
||||||
/<meta\s+name=["']referrer["']\s+content=["']([^"']+)["']\s*\/?>/i
|
|
||||||
);
|
|
||||||
|
|
||||||
it("declares a referrer policy meta tag", () => {
|
|
||||||
expect(referrerMeta).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses same-origin (NOT no-referrer, which sends Origin: null and 403s every POST)", () => {
|
|
||||||
expect(referrerMeta?.[1]).toBe("same-origin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not contain no-referrer anywhere in the document head", () => {
|
|
||||||
expect(html).not.toMatch(/content=["']no-referrer["']/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+4
-12
@@ -3,18 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
|
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on their whitelist.
|
||||||
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
|
Setting no-referrer at the document level covers <img> tags AND CSS background-image fetches.
|
||||||
does exactly that: full Referer for our own requests, none for cross-origin
|
Our own /api/* CSRF check uses Origin (not Referer), so this doesn't break auth. -->
|
||||||
ones — so cover thumbnails (<img> AND CSS background-image) still load.
|
<meta name="referrer" content="no-referrer">
|
||||||
Do NOT switch this back to "no-referrer": per the WHATWG Fetch spec
|
|
||||||
("Append a request Origin header") no-referrer downgrades the Origin header
|
|
||||||
to the literal string "null" on same-origin non-GET requests. The /api/*
|
|
||||||
CSRF guard (src/web/middleware/csrf.ts) then can't parse a host from it and
|
|
||||||
responds 403 "bad origin", silently breaking EVERY POST/PUT/DELETE — QR
|
|
||||||
login, cookie save, playback controls, bot management, user admin, etc.
|
|
||||||
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
|
|
||||||
<meta name="referrer" content="same-origin">
|
|
||||||
<title>TSMusicBot</title>
|
<title>TSMusicBot</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -433,6 +433,18 @@
|
|||||||
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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="autoPauseOnEmpty"
|
||||||
|
type="checkbox"
|
||||||
|
class="profile-toggle-switch"
|
||||||
|
@change="saveIdleTimeout"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bot Profile (TeamSpeak Behavior) -->
|
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||||
@@ -885,17 +897,22 @@ async function savePrefix() {
|
|||||||
|
|
||||||
// Idle timeout
|
// Idle timeout
|
||||||
const idleTimeout = ref(0);
|
const idleTimeout = ref(0);
|
||||||
|
const autoPauseOnEmpty = ref(true);
|
||||||
|
|
||||||
async function loadIdleTimeout() {
|
async function loadIdleTimeout() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/bot/settings');
|
const res = await axios.get('/api/bot/settings');
|
||||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||||
|
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? true;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveIdleTimeout() {
|
async function saveIdleTimeout() {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
|
await axios.post('/api/bot/settings', {
|
||||||
|
idleTimeoutMinutes: idleTimeout.value,
|
||||||
|
autoPauseOnEmpty: autoPauseOnEmpty.value,
|
||||||
|
});
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1794,6 +1811,12 @@ onUnmounted(() => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone toggle inside 行为设置 (not part of a bordered list)
|
||||||
|
.behavior-toggle {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.profile-bot-header {
|
.profile-bot-header {
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
|
|||||||
Reference in new issue
Block a user