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 index 6fac73d..a8317d6 100644 --- a/docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md +++ b/docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md @@ -99,3 +99,40 @@ track *we* auto-paused gets auto-resumed; a user-paused track stays paused when - 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. + +--- + +## Update (2026-06): occupancy is event-driven & server-wide, not channel-filtered + +Live testing against a real TS3 server (with `@honeybbq/teamspeak-client` 0.2.2) +invalidated two assumptions above. Recording the corrected model here so nobody +reintroduces the old design: + +- **Default is OFF**, not on. See `getDefaultConfig()` in `src/data/config.ts` + and the rationale comment there. +- **Query commands are unusable when others are present.** `clientlist`, + `channellist`, and `channelclientlist` ALL time out (~5–10s) whenever ≥2 + clients are connected to the **server** (verified even when the two clients + are in *different* channels). They succeed only when the bot is the sole + client on the whole server. So `getClientsInChannel()` returns `[]` exactly + when occupancy matters, and `occupancyFromClientList(0)` returns `null` + ("unknown") so callers skip the decision rather than mis-reading it as empty. +- **PAUSE** therefore only ever fires when the bot becomes alone on the server + (the one state where the query works). This is reliable and stays on the + query path (`refreshOccupancy()` + the 30s idle poller). +- **RESUME** is armed directly from the `clientEnter` push event + (`shouldResumeOnReturn()` + `_resumeIfReturning()` in `instance.ts`), NOT from + a query. Because the bot only auto-pauses while alone, the sole way occupancy + can return while `autoPaused` is set is a fresh connection — delivered as + `clientEnter`. The resume branch never pauses (userCount is always > 0). +- **Net semantics:** "pause when the server is empty (bot alone), resume when + someone connects." Channel granularity is **impossible** with this library: + `clientEnter`'s channel field is always `0` (library reads notify param `cid` + but enter-view carries `ctid`), and `clientMoved` delivery is flaky. Do NOT + attempt to layer `clientMoved.targetChannelID` channel-accuracy on top — it is + systematically wrong for direct-connect clients and reintroduces unreliability. + The correct path to true channel scoping is an upstream library fix. +- **Knock-on:** idle-disconnect shares the same signal and is likewise + server-wide. UI copy in `web/src/views/Settings.vue` was updated to say + "服务器" rather than "频道" to match. `cmdVote` was intentionally left on the + query path (out of scope; switching it would inherit the same timeout). diff --git a/src/bot/auto-pause.test.ts b/src/bot/auto-pause.test.ts index 6d383ac..742f99e 100644 --- a/src/bot/auto-pause.test.ts +++ b/src/bot/auto-pause.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { decideOccupancyAction, occupancyFromClientList } from "./auto-pause.js"; +import { + decideOccupancyAction, + occupancyFromClientList, + shouldResumeOnReturn, +} from "./auto-pause.js"; describe("decideOccupancyAction", () => { it("pauses when empty while playing and enabled", () => { @@ -45,3 +49,25 @@ describe("occupancyFromClientList", () => { expect(occupancyFromClientList(-3)).toBeNull(); }); }); + +describe("shouldResumeOnReturn (event-driven auto-resume)", () => { + it("resumes when we auto-paused and are still paused", () => { + // The reported gap: someone returns after an auto-pause. clientlist can't + // confirm it (it times out while they're present), so we resume from the + // clientEnter event alone. + expect(shouldResumeOnReturn(true, "paused")).toBe(true); + }); + it("does NOT resume a track the user paused by hand", () => { + expect(shouldResumeOnReturn(false, "paused")).toBe(false); + }); + it("does nothing if already playing (e.g. the bot's own enter at connect)", () => { + // autoPaused is cleared to false on connect, so the bot's own clientEnter + // is a no-op; this also covers the playing/auto-paused-flag-stale case. + expect(shouldResumeOnReturn(false, "playing")).toBe(false); + expect(shouldResumeOnReturn(true, "playing")).toBe(false); + }); + it("does nothing when idle (nothing to resume)", () => { + expect(shouldResumeOnReturn(true, "idle")).toBe(false); + expect(shouldResumeOnReturn(false, "idle")).toBe(false); + }); +}); diff --git a/src/bot/auto-pause.ts b/src/bot/auto-pause.ts index b33fe88..3fd01d6 100644 --- a/src/bot/auto-pause.ts +++ b/src/bot/auto-pause.ts @@ -40,3 +40,28 @@ export function decideOccupancyAction( if (autoPaused && playerState === "paused") return "resume"; return "none"; } + +/** + * Whether a client-presence push event (a `clientEnter`) should trigger an + * auto-resume, WITHOUT consulting a clientlist query. + * + * Why event-driven: the full-client `clientlist`/`channellist` commands time + * out whenever ≥2 clients are connected to the server (a library limitation) — + * which is exactly the moment a listener returns. So occupancy cannot be + * re-queried to confirm the return; we must act on the push event itself. + * This is sound because the bot only ever auto-pauses while it is alone on the + * server (the sole state in which the occupancy query succeeds and pause + * fires). Therefore, while `autoPaused` is true, the only way occupancy can + * return is a fresh connection — delivered reliably as `clientEnter`. + * + * Gating on `autoPaused` (not merely "paused") guarantees we never revive a + * track the user paused by hand, and makes the bot's own `clientEnter` at + * connect a no-op (autoPaused is cleared to false on connect). This predicate + * NEVER pauses — pause stays on the authoritative clientlist path. + */ +export function shouldResumeOnReturn( + autoPaused: boolean, + playerState: PlayerStateName, +): boolean { + return autoPaused && playerState === "paused"; +} diff --git a/src/bot/instance.ts b/src/bot/instance.ts index cdaecda..0e19128 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -18,7 +18,11 @@ 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, occupancyFromClientList } from "./auto-pause.js"; +import { + decideOccupancyAction, + occupancyFromClientList, + shouldResumeOnReturn, +} from "./auto-pause.js"; export interface BotInstanceOptions { id: string; @@ -166,11 +170,38 @@ export class BotInstance extends EventEmitter { // 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()); + // + // clientEnter additionally arms auto-RESUME directly from the event, + // because the occupancy query (clientlist) times out whenever another + // client is present — i.e. exactly when a listener returns — so it cannot + // be used to confirm the return. See _resumeIfReturning(). + this.tsClient.on("clientEnter", () => { + this._resumeIfReturning(); + void this.refreshOccupancy(); + }); this.tsClient.on("clientLeave", () => void this.refreshOccupancy()); this.tsClient.on("clientMoved", () => void this.refreshOccupancy()); } + /** + * Resume playback when a listener returns after an auto-pause, driven by the + * clientEnter push event rather than a (timing-out) occupancy query. + * + * We only auto-pause while alone on the server, so `autoPaused` is a reliable + * "paused because empty" flag; any client appearing while it's set means a + * listener returned. Delegating to handleOccupancy(1) routes through + * decideOccupancyAction (resume iff autoPaused && paused) and also cancels the + * idle-disconnect timer. This path NEVER pauses — userCount is always > 0 — + * so a spurious or unrelated enter can only (harmlessly) resume, never stop + * playback. Pause remains exclusively on the authoritative clientlist path. + */ + private _resumeIfReturning(): void { + if (!this.connected) return; + if (shouldResumeOnReturn(this.autoPaused, this.player.getState())) { + this.handleOccupancy(1); + } + } + private async refreshOccupancy(): Promise { if (!this.connected) return; try { diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 5704be8..e12585b 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -417,7 +417,7 @@
闲置自动退出
-
频道无人时,机器人自动断开的等待时间(0 = 不退出)
+
服务器上没有其他人时,机器人自动断开的等待时间(0 = 不退出)
@@ -435,8 +435,8 @@