mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Merge remote-tracking branch 'upstream/main' into feat/song-vip-flag
# Conflicts: # src/bot/instance.ts
This commit is contained in:
commit
d1bd010260
49 files changed
+4361
-174
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { BotInstance } from "./instance.js";
|
||||
|
||||
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
|
||||
// reads avatars, etc.), and runExclusive only touches a single private field
|
||||
// (`playGate`). So we exercise the ACTUAL shipped method via its prototype,
|
||||
// bound to a minimal object carrying just that field. This proves the real
|
||||
// serializer logic without standing up a full bot.
|
||||
type Gate = { playGate: Promise<unknown> };
|
||||
const runExclusive = BotInstance.prototype.runExclusive as <T>(
|
||||
this: Gate,
|
||||
fn: () => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
function makeGate(): Gate {
|
||||
return { playGate: Promise.resolve() };
|
||||
}
|
||||
|
||||
/** An explicit, timer-free deferred so ordering is deterministic. */
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("BotInstance.runExclusive — serialization", () => {
|
||||
it("does not start fnB until fnA settles", async () => {
|
||||
const gate = makeGate();
|
||||
const order: string[] = [];
|
||||
const gateA = deferred();
|
||||
|
||||
const pA = runExclusive.call(gate, async () => {
|
||||
order.push("A-start");
|
||||
await gateA.promise; // suspend A until we explicitly release it
|
||||
order.push("A-end");
|
||||
});
|
||||
|
||||
const pB = runExclusive.call(gate, async () => {
|
||||
order.push("B-start");
|
||||
order.push("B-end");
|
||||
});
|
||||
|
||||
// Give the microtask queue a chance: B must NOT have started while A is
|
||||
// still suspended on gateA.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["A-start"]);
|
||||
|
||||
gateA.resolve();
|
||||
await pA;
|
||||
await pB;
|
||||
|
||||
expect(order).toEqual(["A-start", "A-end", "B-start", "B-end"]);
|
||||
});
|
||||
|
||||
it("runs fnB even if fnA rejects (chain survives rejection)", async () => {
|
||||
const gate = makeGate();
|
||||
const order: string[] = [];
|
||||
const gateA = deferred();
|
||||
|
||||
const pA = runExclusive.call(gate, async () => {
|
||||
order.push("A-start");
|
||||
await gateA.promise;
|
||||
throw new Error("A blew up");
|
||||
});
|
||||
|
||||
const pB = runExclusive.call(gate, async () => {
|
||||
order.push("B-start");
|
||||
order.push("B-end");
|
||||
return "B-result";
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["A-start"]);
|
||||
|
||||
gateA.reject(new Error("A blew up"));
|
||||
await expect(pA).rejects.toThrow("A blew up");
|
||||
|
||||
// B still runs, only after A has fully settled.
|
||||
await expect(pB).resolves.toBe("B-result");
|
||||
expect(order).toEqual(["A-start", "B-start", "B-end"]);
|
||||
});
|
||||
|
||||
it("preserves call order across three serialized tasks", async () => {
|
||||
const gate = makeGate();
|
||||
const order: string[] = [];
|
||||
const tasks = ["X", "Y", "Z"];
|
||||
const promises = tasks.map((t) =>
|
||||
runExclusive.call(gate, async () => {
|
||||
order.push(`${t}-start`);
|
||||
await Promise.resolve();
|
||||
order.push(`${t}-end`);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(order).toEqual([
|
||||
"X-start",
|
||||
"X-end",
|
||||
"Y-start",
|
||||
"Y-end",
|
||||
"Z-start",
|
||||
"Z-end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@ export class BotInstance extends EventEmitter {
|
||||
private lastSearchResults: Song[] = [];
|
||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||
private effectiveDuration: number | undefined;
|
||||
private playGate: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
@@ -1057,6 +1058,14 @@ export class BotInstance extends EventEmitter {
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Serialize queue-mutation + play sequences so concurrent requests can't
|
||||
* interleave (audible track must match queue.currentIndex). */
|
||||
runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.playGate.then(fn, fn);
|
||||
this.playGate = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
getStatus(): BotStatus {
|
||||
return {
|
||||
id: this.id,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { BotManager } from "./manager.js";
|
||||
import { createDatabase, type BotDatabase } from "../data/database.js";
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
|
||||
// removeBot only calls logger.info; provide the full shape it could touch.
|
||||
const stubLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
child() {
|
||||
return stubLogger;
|
||||
},
|
||||
} as unknown as Logger;
|
||||
|
||||
describe("BotManager.removeBot — guest scope pruning", () => {
|
||||
const dirs: string[] = [];
|
||||
let db: BotDatabase;
|
||||
|
||||
function makeTmpConfigPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-manager-test-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "config.json");
|
||||
}
|
||||
|
||||
function makeManager(config: BotConfig, configPath: string): BotManager {
|
||||
db = createDatabase(":memory:");
|
||||
const permissions = createPermissionStore(db.db);
|
||||
saveConfig(configPath, config);
|
||||
return new BotManager(
|
||||
{} as unknown as MusicProvider,
|
||||
{} as unknown as MusicProvider,
|
||||
{} as unknown as MusicProvider,
|
||||
db,
|
||||
config,
|
||||
stubLogger,
|
||||
{} as unknown as AvatarStore,
|
||||
permissions,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it("prunes a deleted bot from guestMode.bots (array) and persists", async () => {
|
||||
const configPath = makeTmpConfigPath();
|
||||
const config = getDefaultConfig();
|
||||
config.guestMode.bots = ["botA", "botB"];
|
||||
const manager = makeManager(config, configPath);
|
||||
|
||||
await manager.removeBot("botA");
|
||||
|
||||
expect(config.guestMode.bots).toEqual(["botB"]);
|
||||
// Persisted file must also reflect the prune.
|
||||
expect(loadConfig(configPath).guestMode.bots).toEqual(["botB"]);
|
||||
});
|
||||
|
||||
it('leaves guestMode.bots === "all" unchanged (no crash, no change)', async () => {
|
||||
const configPath = makeTmpConfigPath();
|
||||
const config = getDefaultConfig();
|
||||
config.guestMode.bots = "all";
|
||||
const manager = makeManager(config, configPath);
|
||||
|
||||
await manager.removeBot("botA");
|
||||
|
||||
expect(config.guestMode.bots).toBe("all");
|
||||
expect(loadConfig(configPath).guestMode.bots).toBe("all");
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -7,7 +7,7 @@ import {
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import { YouTubeProvider } from "../music/youtube.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import { saveConfig, type BotConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
import type { ServerProtocol } from "../ts-protocol/client.js";
|
||||
@@ -79,6 +79,7 @@ export class BotManager extends EventEmitter {
|
||||
private logger: Logger;
|
||||
private avatarStore: AvatarStore;
|
||||
private permissions: PermissionStore;
|
||||
private configPath: string;
|
||||
|
||||
constructor(
|
||||
neteaseProvider: MusicProvider,
|
||||
@@ -88,7 +89,8 @@ export class BotManager extends EventEmitter {
|
||||
config: BotConfig,
|
||||
logger: Logger,
|
||||
avatarStore: AvatarStore,
|
||||
permissions: PermissionStore
|
||||
permissions: PermissionStore,
|
||||
configPath: string
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
@@ -100,6 +102,7 @@ export class BotManager extends EventEmitter {
|
||||
this.logger = logger;
|
||||
this.avatarStore = avatarStore;
|
||||
this.permissions = permissions;
|
||||
this.configPath = configPath;
|
||||
}
|
||||
|
||||
async createBot(params: CreateBotParams): Promise<BotInstance> {
|
||||
@@ -160,6 +163,11 @@ export class BotManager extends EventEmitter {
|
||||
}
|
||||
this.database.deleteBotInstance(id);
|
||||
this.permissions.pruneBot(id);
|
||||
// Prune the deleted bot from the guest scope allow-list (mirrors permissions.pruneBot).
|
||||
if (Array.isArray(this.config.guestMode.bots) && this.config.guestMode.bots.includes(id)) {
|
||||
this.config.guestMode.bots = this.config.guestMode.bots.filter((b) => b !== id);
|
||||
saveConfig(this.configPath, this.config);
|
||||
}
|
||||
this.emit("botInstanceRemoved", id);
|
||||
this.logger.info({ botId: id }, "Bot instance removed");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user