From 51c954993a55a26e8cde379af18a327fe10bdf3a Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 14:42:13 +0800 Subject: [PATCH 1/7] docs(spec): auto-pause on empty channel design (#79 item 3) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-05-30-autopause-empty-channel-design.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md diff --git a/docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md b/docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md new file mode 100644 index 0000000..6fac73d --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md @@ -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. From 9f0ac74fbc9339775a1a977d0b438c82c16104cc Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 14:43:38 +0800 Subject: [PATCH 2/7] docs(plan): auto-pause on empty channel implementation plan (#79 item 3) --- .../2026-05-30-autopause-empty-channel.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-autopause-empty-channel.md diff --git a/docs/superpowers/plans/2026-05-30-autopause-empty-channel.md b/docs/superpowers/plans/2026-05-30-autopause-empty-channel.md new file mode 100644 index 0000000..afffb79 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-autopause-empty-channel.md @@ -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 && 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. From 484202e90dc1b6d9cecf0d06eee66b2929caae9f Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 14:44:28 +0800 Subject: [PATCH 3/7] feat(autopause): pure occupancy-decision function --- src/bot/auto-pause.test.ts | 29 +++++++++++++++++++++++++++++ src/bot/auto-pause.ts | 23 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/bot/auto-pause.test.ts create mode 100644 src/bot/auto-pause.ts diff --git a/src/bot/auto-pause.test.ts b/src/bot/auto-pause.test.ts new file mode 100644 index 0000000..15ab59e --- /dev/null +++ b/src/bot/auto-pause.test.ts @@ -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"); + }); +}); diff --git a/src/bot/auto-pause.ts b/src/bot/auto-pause.ts new file mode 100644 index 0000000..60f1223 --- /dev/null +++ b/src/bot/auto-pause.ts @@ -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"; +} From 4ba4b013b0038869d7074531ec9328dae0346e68 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 14:46:50 +0800 Subject: [PATCH 4/7] feat(autopause): drive pause/resume from channel occupancy in BotInstance Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/instance.ts | 52 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 5d7201c..41c5e55 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -17,6 +17,7 @@ import type { BotDatabase, ProfileConfig } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; import { BotProfileManager } from "./profile.js"; import type { AvatarStore } from "../data/avatars.js"; +import { decideOccupancyAction } from "./auto-pause.js"; export interface BotInstanceOptions { id: string; @@ -66,6 +67,7 @@ export class BotInstance extends EventEmitter { private isAdvancing = false; private idleTimer: ReturnType | null = null; private channelUserCount = 0; + private autoPaused = false; private profileManager: BotProfileManager; private isFmMode = false; @@ -143,6 +145,8 @@ export class BotInstance extends EventEmitter { // short-circuited on !this.connected, leaving player stuck as "playing". this.connected = false; 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 // duplicate "disconnected" after an explicit disconnect() call. if (this.disconnectEmitted) return; @@ -151,6 +155,8 @@ export class BotInstance extends EventEmitter { }); this.tsClient.on("connected", () => { + // Fresh connection — clear any stale auto-pause flag from a prior session. + this.autoPaused = false; this._startIdlePoller(); }); } @@ -187,6 +193,16 @@ export class BotInstance extends EventEmitter { 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 { // 每 30 秒检查一次频道人数 const poll = async () => { @@ -194,17 +210,35 @@ export class BotInstance extends EventEmitter { try { const clients = await this.tsClient.getClientsInChannel(); const userCount = clients.length - 1; // 排除 bot 自身 - if (userCount <= 0) { - this._scheduleIdleCheck(); - } else { - this._cancelIdleTimer(); - } + this.handleOccupancy(userCount); } catch { /* ignore */ } 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 { if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建 const minutes = this.config.idleTimeoutMinutes ?? 0; @@ -381,6 +415,9 @@ export class BotInstance extends EventEmitter { } song.url = url; 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({ botId: this.id, songId: song.id, @@ -483,18 +520,23 @@ export class BotInstance extends EventEmitter { private cmdPause(): string { this.player.pause(); + // User-initiated pause — clear auto-pause so occupancy won't auto-resume it. + this.autoPaused = false; this.emit("stateChange"); return "Paused"; } private cmdResume(): string { this.player.resume(); + // User-initiated resume — drop any auto-pause flag. + this.autoPaused = false; this.emit("stateChange"); return "Resumed"; } private cmdStop(): string { this.player.stop(); + this.autoPaused = false; this.queue.clear(); this.isFmMode = false; this.profileManager.onSongChange(null).catch((err) => { From 8f5bb26b3a9679e1727bcecd4d3f682ecc4c0713 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 14:50:52 +0800 Subject: [PATCH 5/7] feat(autopause): re-emit client enter/leave/move for instant pause/resume --- src/bot/instance.ts | 16 ++++++++++++++++ src/ts-protocol/client.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 41c5e55..408c823 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -159,6 +159,22 @@ export class BotInstance extends EventEmitter { this.autoPaused = false; 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 { + 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 { diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 591507c..383c6fc 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -12,6 +12,8 @@ import { type Identity, type TextMessage, type ClientInfo, + type ClientLeftViewEvent, + type ClientMovedEvent, type FileUploadInfo, } from "@honeybbq/teamspeak-client"; import type { Logger } from "../logger.js"; @@ -221,6 +223,20 @@ export class TS3Client extends EventEmitter { { nickname: info.nickname, id: info.id }, "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(); From 491bc53dec027ff82ba3475a2e9cfa52fb115da6 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 14:55:18 +0800 Subject: [PATCH 6/7] feat(autopause): expose autoPauseOnEmpty via /api/bot/settings Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/api/bot.test.ts | 160 ++++++++++++++++++++++++++++++++++++++++ src/web/api/bot.ts | 58 +++++++++------ 2 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 src/web/api/bot.test.ts diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts new file mode 100644 index 0000000..0e3d60a --- /dev/null +++ b/src/web/api/bot.test.ts @@ -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[]; + + 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([]); + } + }); +}); diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index 51a417b..d08e3df 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -21,6 +21,43 @@ export function createBotRouter( 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) => { const bot = botManager.getBot(req.params.id); if (!bot) { @@ -185,27 +222,6 @@ export function createBotRouter( res.status(500).json({ error: (err as Error).message }); } }); - - // 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; } From 34655e5f503cd68e9173e748b7eff213f2ced0ed Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 14:58:35 +0800 Subject: [PATCH 7/7] feat(autopause): autoPauseOnEmpty toggle in Settings --- web/src/views/Settings.vue | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 863c267..ea0af56 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -433,6 +433,18 @@ + @@ -885,17 +897,22 @@ async function savePrefix() { // Idle timeout const idleTimeout = ref(0); +const autoPauseOnEmpty = 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 ?? true; } catch { /* ignore */ } } async function saveIdleTimeout() { try { - await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value }); + await axios.post('/api/bot/settings', { + idleTimeoutMinutes: idleTimeout.value, + autoPauseOnEmpty: autoPauseOnEmpty.value, + }); } catch { /* ignore */ } } @@ -1794,6 +1811,12 @@ onUnmounted(() => { 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) { .profile-bot-header { padding: 14px 12px;