mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Auto-pause within the first seconds of playback (and re-pause after a manual
play) whenever a listener is actually in the channel.
Root cause (confirmed live against a TS3 server): the full-client
`clientlist -uid -away -voice -groups` command TIMES OUT when other clients are
present in the bot's channel. `getClientsInChannel()` catches the error and
returns `[]`, so the occupancy callers computed `userCount = [].length - 1 = -1`,
which `decideOccupancyAction` reads as `-1 <= 0` → "channel empty" → pause. With
the bot alone, clientlist succeeds (returns just the bot), so the bug only
surfaced when someone was listening — exactly the report.
Fix: a connected bot is always a member of its own channel, so a valid query
returns >= 1 (itself). A length of 0 therefore means the query FAILED, not that
the channel is empty. New pure helper `occupancyFromClientList()` maps a
0-length result to `null` ("occupancy unknown"); `refreshOccupancy()` and the
30s idle poller skip the auto-pause / idle-disconnect decision when the count is
unknown instead of mis-reading it as empty. This also removes a latent
false-positive idle-disconnect on the same failed query.
Also default `autoPauseOnEmpty` to OFF (occupancy detection is unreliable on
some servers); users can opt in from Settings.
Verified live with two clients in one channel: clientlist returns 0 → helper
returns null → no false pause (control: bot alone returns 1 → 0 others, normal).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import { join } from "node:path";
|
|
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
|
|
|
|
describe("config", () => {
|
|
const dirs: string[] = [];
|
|
|
|
function makeTmpDir(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-test-"));
|
|
dirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const d of dirs) {
|
|
rmSync(d, { recursive: true, force: true });
|
|
}
|
|
dirs.length = 0;
|
|
});
|
|
|
|
it("returns default config when file does not exist", () => {
|
|
const config = loadConfig("/nonexistent/path/config.json");
|
|
expect(config).toEqual(getDefaultConfig());
|
|
});
|
|
|
|
it("creates config file on save", () => {
|
|
const dir = makeTmpDir();
|
|
const path = join(dir, "sub", "config.json");
|
|
const config = getDefaultConfig();
|
|
saveConfig(path, config);
|
|
|
|
const loaded = loadConfig(path);
|
|
expect(loaded).toEqual(config);
|
|
});
|
|
|
|
it("merges partial config with defaults", () => {
|
|
const dir = makeTmpDir();
|
|
const path = join(dir, "config.json");
|
|
|
|
// Save a partial config by writing only some fields
|
|
const partial = { webPort: 8080, locale: "en" };
|
|
writeFileSync(path, JSON.stringify(partial), "utf-8");
|
|
|
|
const loaded = loadConfig(path);
|
|
expect(loaded.webPort).toBe(8080);
|
|
expect(loaded.locale).toBe("en");
|
|
// defaults should fill in the rest
|
|
expect(loaded.theme).toBe("dark");
|
|
expect(loaded.commandPrefix).toBe("!");
|
|
// auto-pause defaults OFF (occupancy detection is unreliable on some servers)
|
|
expect(loaded.autoPauseOnEmpty).toBe(false);
|
|
});
|
|
|
|
// --- #86: config.json must live under (and be created in) the persisted data dir ---
|
|
|
|
it("first run writes config.json into the data dir and reads it back", () => {
|
|
const root = makeTmpDir();
|
|
const dataDir = join(root, "data");
|
|
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
|
|
|
|
// Boot sequence: load (missing -> defaults) then save.
|
|
const config = loadConfig(configPath);
|
|
saveConfig(configPath, config);
|
|
|
|
expect(existsSync(configPath)).toBe(true);
|
|
// A subsequent hand-edited file under the SAME persisted path is honored.
|
|
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
|
|
expect(loadConfig(configPath).webPort).toBe(9999);
|
|
});
|
|
|
|
it("migrates a legacy root config into the data dir, preserving values", () => {
|
|
const root = makeTmpDir();
|
|
const legacyPath = join(root, "config.json");
|
|
const newPath = join(root, "data", "config.json");
|
|
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
|
|
|
|
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
|
|
|
expect(migrated).toBe(true);
|
|
expect(existsSync(newPath)).toBe(true);
|
|
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
|
|
const loaded = loadConfig(newPath);
|
|
expect(loaded.webPort).toBe(4242);
|
|
expect(loaded.publicUrl).toBe("http://x");
|
|
});
|
|
|
|
it("does NOT overwrite an existing data-dir config during migration", () => {
|
|
const root = makeTmpDir();
|
|
const legacyPath = join(root, "config.json");
|
|
const newPath = join(root, "data", "config.json");
|
|
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
|
|
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
|
|
|
|
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
|
|
|
expect(migrated).toBe(false); // new location wins, untouched
|
|
expect(loadConfig(newPath).webPort).toBe(2222);
|
|
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
|
|
});
|
|
|
|
it("migration is a no-op when there is no legacy config", () => {
|
|
const root = makeTmpDir();
|
|
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
|
|
expect(migrated).toBe(false);
|
|
});
|
|
});
|