Merge remote-tracking branch 'upstream/main' into feat/song-vip-flag

# Conflicts:
#	src/bot/instance.ts
This commit is contained in:
Slldyd2077 committed 2026-06-28 21:31:17 +08:00
commit d1bd010260
49 files changed
+4361 -174

No files matched your search

+112
View File
@@ -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",
]);
});
});
+9
View File
@@ -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,
+87
View File
@@ -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
View File
@@ -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");
}