diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 9768e21..02f43c3 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -85,6 +85,8 @@ export class BotManager { defaultChannel: params.defaultChannel ?? "", channelPassword: params.channelPassword ?? "", autoStart: params.autoStart ?? false, + serverProtocol: params.serverProtocol ?? "", + ts6ApiKey: params.ts6ApiKey ?? "", }); this.logger.info({ botId: id, name: params.name }, "Bot instance created"); @@ -114,6 +116,8 @@ export class BotManager { nickname: params.nickname ?? existing.nickname, defaultChannel: params.defaultChannel ?? existing.defaultChannel, channelPassword: params.channelPassword ?? existing.channelPassword, + serverProtocol: params.serverProtocol ?? existing.serverProtocol, + ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey, }); // Update in-memory name immediately (other fields need reconnect) const bot = this.bots.get(id); @@ -150,17 +154,19 @@ export class BotManager { async loadSavedBots(): Promise { const savedInstances = this.database.getBotInstances(); for (const saved of savedInstances) { + const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined; const bot = new BotInstance({ id: saved.id, name: saved.name, tsOptions: { host: saved.serverAddress, port: saved.serverPort, - queryPort: 10011, // Will be overridden by auto-detection for TS6 + queryPort: proto === "ts6" ? 10080 : 10011, nickname: saved.nickname, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, - // Protocol will be auto-detected on connect + serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, + ts6ApiKey: saved.ts6ApiKey || undefined, }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 51da01a..8807797 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -60,6 +60,8 @@ describe("database", () => { defaultChannel: "Music", channelPassword: "", autoStart: true, + serverProtocol: "", + ts6ApiKey: "", }; botDb.saveBotInstance(instance); @@ -86,6 +88,8 @@ describe("database", () => { defaultChannel: "Music", channelPassword: "", autoStart: false, + serverProtocol: "", + ts6ApiKey: "", }); expect(botDb.deleteBotInstance("bot1")).toBe(true); diff --git a/src/data/database.ts b/src/data/database.ts index 8f043ba..5a3ad84 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -24,6 +24,10 @@ export interface BotInstance { defaultChannel: string; channelPassword: string; autoStart: boolean; + /** "ts3" | "ts6" | "" (empty = auto-detect) */ + serverProtocol: string; + /** API key for TS6 HTTP Query */ + ts6ApiKey: string; } export interface BotDatabase { @@ -58,7 +62,9 @@ function initTables(db: Database.Database): void { nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL, channelPassword TEXT NOT NULL, - autoStart INTEGER NOT NULL DEFAULT 0 + autoStart INTEGER NOT NULL DEFAULT 0, + serverProtocol TEXT NOT NULL DEFAULT '', + ts6ApiKey TEXT NOT NULL DEFAULT '' ); `); } @@ -78,8 +84,8 @@ export function createDatabase(dbPath: string): BotDatabase { `); const upsertInstance = db.prepare(` - INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart) - VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart) + INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey) + VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey) ON CONFLICT(id) DO UPDATE SET name = excluded.name, serverAddress = excluded.serverAddress, @@ -87,7 +93,9 @@ export function createDatabase(dbPath: string): BotDatabase { nickname = excluded.nickname, defaultChannel = excluded.defaultChannel, channelPassword = excluded.channelPassword, - autoStart = excluded.autoStart + autoStart = excluded.autoStart, + serverProtocol = excluded.serverProtocol, + ts6ApiKey = excluded.ts6ApiKey `); const selectInstances = db.prepare(`SELECT * FROM bot_instances`); diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 5bded0d..bf574e6 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -53,6 +53,7 @@ export class TS3Client extends EventEmitter { private disconnecting = false; private detectedProtocol: ServerProtocol = "unknown"; private httpQuery: TS6HttpQuery | null = null; + private udpErrorTimer: ReturnType | null = null; constructor(private options: TS3ClientOptions, logger: Logger) { super(); @@ -118,6 +119,14 @@ export class TS3Client extends EventEmitter { }); } + // Guard against calling connect() while already connected + if (this.client) { + this.logger.warn("connect() called while already connected, disconnecting first"); + this.disconnect(); + // Give the old client a moment to tear down + await new Promise((r) => setTimeout(r, 100)); + } + this.logger.info( { addr, protocol: this.detectedProtocol }, "Connecting to TeamSpeak server (full client protocol)", @@ -125,19 +134,18 @@ export class TS3Client extends EventEmitter { // Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks) let udpErrorCount = 0; - let udpErrorTimer: ReturnType | null = null; const throttledWarn = (msg: string, ...args: unknown[]) => { if (typeof msg === "string" && msg.includes("udp send error")) { udpErrorCount++; if (udpErrorCount === 1) { this.logger.warn(msg); // After 2 seconds, log a summary and reset - udpErrorTimer = setTimeout(() => { + this.udpErrorTimer = setTimeout(() => { if (udpErrorCount > 1) { this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`); } udpErrorCount = 0; - udpErrorTimer = null; + this.udpErrorTimer = null; }, 2000); } return; @@ -285,6 +293,10 @@ export class TS3Client extends EventEmitter { this.clientId = 0; this.httpQuery = null; this.detectedProtocol = "unknown"; + if (this.udpErrorTimer) { + clearTimeout(this.udpErrorTimer); + this.udpErrorTimer = null; + } this.logger.info("Disconnected from TeamSpeak server"); } } diff --git a/src/ts-protocol/http-query.ts b/src/ts-protocol/http-query.ts index d483265..a0af0cd 100644 --- a/src/ts-protocol/http-query.ts +++ b/src/ts-protocol/http-query.ts @@ -1,6 +1,5 @@ import http from "node:http"; import https from "node:https"; -import { EventEmitter } from "node:events"; export interface HttpQueryOptions { host: string; @@ -31,11 +30,10 @@ export interface HttpQueryResult { * GET /1/channellist?sid={sid} → list channels * POST /1/clientupdate → update client properties */ -export class TS6HttpQuery extends EventEmitter { +export class TS6HttpQuery { private options: Required; constructor(options: HttpQueryOptions) { - super(); this.options = { host: options.host, port: options.port, @@ -68,6 +66,13 @@ export class TS6HttpQuery extends EventEmitter { } return new Promise((resolve, reject) => { + let settled = false; + const fail = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; + const req = transport.request( { hostname: host, @@ -82,8 +87,10 @@ export class TS6HttpQuery extends EventEmitter { let data = ""; res.setEncoding("utf-8"); res.on("data", (chunk: string) => (data += chunk)); - res.on("error", reject); + res.on("error", fail); res.on("end", () => { + if (settled) return; + settled = true; let parsed: unknown; try { parsed = JSON.parse(data); @@ -98,10 +105,10 @@ export class TS6HttpQuery extends EventEmitter { }, ); - req.on("error", reject); + req.on("error", fail); req.on("timeout", () => { req.destroy(); - reject(new Error("TS6 HTTP Query timeout")); + fail(new Error("TS6 HTTP Query timeout")); }); if (bodyStr) { diff --git a/src/ts-protocol/protocol-detect.ts b/src/ts-protocol/protocol-detect.ts index 7ef9475..b58b1d5 100644 --- a/src/ts-protocol/protocol-detect.ts +++ b/src/ts-protocol/protocol-detect.ts @@ -67,11 +67,13 @@ function probeTS3Query(host: string, port: number, timeoutMs: number): Promise { banner += data.toString("utf-8"); + if (banner.length > MAX_BANNER) banner = banner.slice(0, MAX_BANNER); if (banner.includes("TS3")) { done(true); }