mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge PR #81: feat(autopause) pause when bot channel empties
This commit is contained in:
commit
c57cd35f09
9 files changed
+649
-27
No files matched your search
@@ -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 { 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<typeof setTimeout> | 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,8 +155,26 @@ 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();
|
||||
});
|
||||
|
||||
// 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> {
|
||||
@@ -187,6 +209,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 +226,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 +431,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 +536,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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in new issue
Block a user