From 7ad9559653da4e827bd779fdc16fefbf0282c12c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 14:24:06 +0000 Subject: [PATCH 1/2] Fix TS6 connection: patch clientinit version at handler level The @honeybbq/teamspeak-client library sends clientinit directly via handler.sendPacket() during the handshake, bypassing the commandMiddleware chain entirely. This meant the ts6VersionMiddleware never intercepted the handshake clientinit, so TS6 servers always received version 3.5.3 and silently rejected it (never responding with initserver), causing idle timeout. Fix: monkey-patch handler.sendPacket() to intercept clientinit packets and upgrade the version to 3.6.2 before they're sent over the wire. https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD --- src/ts-protocol/client.ts | 33 ++++++++++----- src/ts-protocol/ts6-compat.test.ts | 39 ++++-------------- src/ts-protocol/ts6-compat.ts | 64 +++++++++--------------------- 3 files changed, 49 insertions(+), 87 deletions(-) diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index acdc859..2436480 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -10,7 +10,6 @@ import { type Identity, type TextMessage, type ClientInfo, - type CommandMiddleware, } from "@honeybbq/teamspeak-client"; import type { Logger } from "../logger.js"; import { @@ -18,7 +17,7 @@ import { type ServerProtocol, } from "./protocol-detect.js"; import { TS6HttpQuery } from "./http-query.js"; -import { ts6VersionMiddleware } from "./ts6-compat.js"; +import { patchClientInitVersion } from "./ts6-compat.js"; export { CODEC_OPUS_MUSIC } from "./voice.js"; export type { ServerProtocol } from "./protocol-detect.js"; @@ -154,13 +153,6 @@ export class TS3Client extends EventEmitter { this.logger.warn(msg); }; - // Apply TS6 version middleware if connecting to a TS6 server - const commandMiddleware: CommandMiddleware[] = []; - if (this.detectedProtocol === "ts6") { - commandMiddleware.push(ts6VersionMiddleware("3.6.2")); - this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); - } - this.client = new TS3FullClient(this.identity, addr, this.options.nickname, { logger: { debug: (msg) => this.logger.debug(msg), @@ -168,9 +160,30 @@ export class TS3Client extends EventEmitter { warn: throttledWarn, error: (msg) => this.logger.error(msg), }, - commandMiddleware, }); + // Patch handler.sendPacket to intercept clientinit during handshake for TS6. + // The library's commandMiddleware only applies to post-connection commands + // (sendCommandNoWait/execCommand), but the handshake's clientinit is sent + // directly via handler.sendPacket, bypassing middleware entirely. + // This monkey-patch ensures the version is upgraded for TS6 servers. + if (this.detectedProtocol === "ts6") { + this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); + const origSendPacket = this.client.handler.sendPacket.bind(this.client.handler); + this.client.handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { + // PacketType.Command = 2 + if (pType === 2) { + const str = Buffer.from(data).toString("utf-8"); + if (str.startsWith("clientinit ")) { + const patched = patchClientInitVersion(str); + origSendPacket(pType, Buffer.from(patched), flags); + return; + } + } + origSendPacket(pType, data, flags); + }; + } + this.client.on("textMessage", (msg: TextMessage) => { const tsMsg: TS3TextMessage = { invokerName: msg.invokerName, diff --git a/src/ts-protocol/ts6-compat.test.ts b/src/ts-protocol/ts6-compat.test.ts index fd61412..3a6211c 100644 --- a/src/ts-protocol/ts6-compat.test.ts +++ b/src/ts-protocol/ts6-compat.test.ts @@ -1,45 +1,22 @@ import { describe, it, expect } from "vitest"; -import { ts6VersionMiddleware } from "./ts6-compat.js"; +import { patchClientInitVersion } from "./ts6-compat.js"; describe("ts6-compat", () => { - it("patches clientinit version to 3.6.2", async () => { - const middleware = ts6VersionMiddleware("3.6.2"); - let captured = ""; - const next = async (cmd: string) => { - captured = cmd; - }; - const handler = middleware(next); - + it("patches clientinit version to 3.6.2", () => { const original = "clientinit client_nickname=Bot client_version=3.5.3\\s[Build:\\s1587971024] " + "client_platform=Windows client_version_sign=Kvmj7qX6wJCPI5GVT71samfmhz/bvs7M+OTXWB/JWxdQbxDe17xda7dzUWLX7pjvdJTqZmbse1HBmTxThPKvAg== " + "client_key_offset=42 hwid=abc123"; - await handler(original); + const patched = patchClientInitVersion(original); - expect(captured).toContain("client_version=3.6.2\\s[Build:\\s1695203293]"); - expect(captured).toContain( + expect(patched).toContain("client_version=3.6.2\\s[Build:\\s1695203293]"); + expect(patched).toContain( "client_version_sign=4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==" ); // Other fields preserved - expect(captured).toContain("client_nickname=Bot"); - expect(captured).toContain("client_key_offset=42"); - expect(captured).toContain("hwid=abc123"); - }); - - it("does not modify non-clientinit commands", async () => { - const middleware = ts6VersionMiddleware("3.6.2"); - let captured = ""; - const handler = middleware(async (cmd) => { - captured = cmd; - }); - - const original = "sendtextmessage targetmode=2 msg=hello"; - await handler(original); - expect(captured).toBe(original); - }); - - it("throws for unknown target version", () => { - expect(() => ts6VersionMiddleware("9.9.9")).toThrow("Unknown version"); + expect(patched).toContain("client_nickname=Bot"); + expect(patched).toContain("client_key_offset=42"); + expect(patched).toContain("hwid=abc123"); }); }); diff --git a/src/ts-protocol/ts6-compat.ts b/src/ts-protocol/ts6-compat.ts index 5d24f09..4ff8bd9 100644 --- a/src/ts-protocol/ts6-compat.ts +++ b/src/ts-protocol/ts6-compat.ts @@ -1,57 +1,29 @@ -import type { CommandMiddleware } from "@honeybbq/teamspeak-client"; - /** - * Known-compatible client versions with their signatures. - * - * The version_sign is a P-256 ECDSA signature over the version string, - * verified by TeamSpeak servers. Only officially signed versions work. + * TS6 version used for compatibility. The version_sign is a P-256 ECDSA + * signature verified by TeamSpeak servers; only officially signed pairs work. */ -const KNOWN_VERSIONS: Record< - string, - { version: string; platform: string; sign: string } -> = { - // TS3 client 3.5.3 (default in @honeybbq/teamspeak-client) - "3.5.3": { - version: "3.5.3 [Build: 1587971024]", - platform: "Windows", - sign: "Kvmj7qX6wJCPI5GVT71samfmhz/bvs7M+OTXWB/JWxdQbxDe17xda7dzUWLX7pjvdJTqZmbse1HBmTxThPKvAg==", - }, - // TS3 client 3.6.2 (used by NeteaseTSBot, known to work with TS6 servers) - "3.6.2": { - version: "3.6.2 [Build: 1695203293]", - platform: "Windows", - sign: "4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==", - }, +const TS6_VERSION = { + version: "3.6.2 [Build: 1695203293]", + platform: "Windows", + sign: "4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==", }; /** - * Command middleware that upgrades the client_version in `clientinit` to - * a newer version known to work with TS6 servers. + * Patch a raw `clientinit` command string to use the 3.6.2 version. * - * TS6 servers may reject connections from older client versions. The default - * library sends version 3.5.3; this middleware patches it to 3.6.2 which is - * the version NeteaseTSBot uses successfully with TS6. + * The @honeybbq/teamspeak-client library hardcodes version 3.5.3 in the + * handshake and sends it directly via handler.sendPacket(), bypassing the + * commandMiddleware chain. TS6 servers silently reject 3.5.3 (they never + * respond with `initserver`), causing an idle timeout. * - * The version_sign must match the version string — it's a server-verified - * ECDSA signature, so we can only use known-valid pairs. + * This function is called from a monkey-patched handler.sendPacket() so it + * intercepts the actual handshake packets. */ -export function ts6VersionMiddleware( - targetVersion = "3.6.2", -): CommandMiddleware { - const vInfo = KNOWN_VERSIONS[targetVersion]; - if (!vInfo) { - throw new Error(`Unknown version ${targetVersion}. Known: ${Object.keys(KNOWN_VERSIONS).join(", ")}`); - } - - return (next) => async (cmd) => { - // Only intercept clientinit commands - if (cmd.startsWith("clientinit ")) { - cmd = replaceField(cmd, "client_version", vInfo.version); - cmd = replaceField(cmd, "client_platform", vInfo.platform); - cmd = replaceField(cmd, "client_version_sign", vInfo.sign); - } - return next(cmd); - }; +export function patchClientInitVersion(cmd: string): string { + cmd = replaceField(cmd, "client_version", TS6_VERSION.version); + cmd = replaceField(cmd, "client_platform", TS6_VERSION.platform); + cmd = replaceField(cmd, "client_version_sign", TS6_VERSION.sign); + return cmd; } /** From b00b6637d2a09595ed7b301d9cca58463f6b7f28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 15:26:41 +0000 Subject: [PATCH 2/2] Fix corner cases in connect/disconnect lifecycle and resource cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Move TS6 handler patch to after client.connect() — the library's connect() internally replaces handler via #S(), discarding any patch applied beforehand. Patching after connect() is safe because clientinit is sent in async message callbacks after Init1 round-trips. 2. Preserve detectedProtocol across reconnect — disconnect() resets it to "unknown", causing the TS6 patch to be skipped on reconnect. 3. Prevent double "disconnected" event in BotInstance — disconnect() emitted it directly AND the async TS3Client disconnect triggered another through the event chain. 4. Guard playNext() against running after disconnect — check connected flag to avoid ghost queue processing. 5. Fix UDP error timer leak — clear previous timer before setting a new one to prevent accumulation. 6. Fix isPortFree() FD leak — close the test server on error path. https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD --- src/bot/instance.ts | 11 +++++-- src/music/api-server.ts | 4 ++- src/ts-protocol/client.ts | 64 ++++++++++++++++++++++++--------------- 3 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index b436a4e..920c3e4 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -105,6 +105,9 @@ export class BotInstance extends EventEmitter { }); this.tsClient.on("disconnected", () => { + // Avoid duplicate event if disconnect() was called explicitly + // (it already set connected = false and emitted "disconnected"). + if (!this.connected) return; this.connected = false; this.player.stop(); this.emit("disconnected"); @@ -119,8 +122,10 @@ export class BotInstance extends EventEmitter { disconnect(): void { this.player.stop(); - this.tsClient.disconnect(); + // Set connected = false BEFORE tsClient.disconnect() so that the + // "disconnected" event handler (setupTsEvents) won't emit a duplicate. this.connected = false; + this.tsClient.disconnect(); this.emit("disconnected"); } @@ -488,7 +493,7 @@ export class BotInstance extends EventEmitter { } private async playNext(): Promise { - if (this.isAdvancing) return; + if (this.isAdvancing || !this.connected) return; this.isAdvancing = true; try { this.voteSkipUsers.clear(); @@ -497,7 +502,7 @@ export class BotInstance extends EventEmitter { let started = await this.resolveAndPlay(next); if (!started) { // Skip to next if URL resolve fails (up to 3 retries) - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 3 && this.connected; i++) { const retry = this.queue.next(); if (!retry) break; if (await this.resolveAndPlay(retry)) { diff --git a/src/music/api-server.ts b/src/music/api-server.ts index f517ea5..05fe3fc 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -17,7 +17,9 @@ export interface ApiServerManager { function isPortFree(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); - server.once("error", () => resolve(false)); + server.once("error", () => { + server.close(() => resolve(false)); + }); server.once("listening", () => { server.close(() => resolve(true)); }); diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 2436480..0ac1303 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -119,10 +119,15 @@ export class TS3Client extends EventEmitter { }); } - // Guard against calling connect() while already connected + // Guard against calling connect() while already connected. + // Save detectedProtocol first because disconnect() resets it. if (this.client) { this.logger.warn("connect() called while already connected, disconnecting first"); + const savedProtocol = this.detectedProtocol; + const savedHttpQuery = this.httpQuery; this.disconnect(); + this.detectedProtocol = savedProtocol; + this.httpQuery = savedHttpQuery; // Give the old client a moment to tear down await new Promise((r) => setTimeout(r, 100)); } @@ -139,7 +144,9 @@ export class TS3Client extends EventEmitter { udpErrorCount++; if (udpErrorCount === 1) { this.logger.warn(msg); - // After 2 seconds, log a summary and reset + // After 2 seconds, log a summary and reset. + // Clear any previous timer to avoid leaking it. + if (this.udpErrorTimer) clearTimeout(this.udpErrorTimer); this.udpErrorTimer = setTimeout(() => { if (udpErrorCount > 1) { this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`); @@ -162,28 +169,6 @@ export class TS3Client extends EventEmitter { }, }); - // Patch handler.sendPacket to intercept clientinit during handshake for TS6. - // The library's commandMiddleware only applies to post-connection commands - // (sendCommandNoWait/execCommand), but the handshake's clientinit is sent - // directly via handler.sendPacket, bypassing middleware entirely. - // This monkey-patch ensures the version is upgraded for TS6 servers. - if (this.detectedProtocol === "ts6") { - this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); - const origSendPacket = this.client.handler.sendPacket.bind(this.client.handler); - this.client.handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { - // PacketType.Command = 2 - if (pType === 2) { - const str = Buffer.from(data).toString("utf-8"); - if (str.startsWith("clientinit ")) { - const patched = patchClientInitVersion(str); - origSendPacket(pType, Buffer.from(patched), flags); - return; - } - } - origSendPacket(pType, data, flags); - }; - } - this.client.on("textMessage", (msg: TextMessage) => { const tsMsg: TS3TextMessage = { invokerName: msg.invokerName, @@ -209,6 +194,37 @@ export class TS3Client extends EventEmitter { }); await this.client.connect(); + + // Patch handler.sendPacket to intercept clientinit during handshake for TS6. + // + // Why after connect(): The library's connect() internally calls #S() which + // replaces this.handler with a fresh PacketHandler. Any patch applied before + // connect() would be discarded. Patching here is safe because connect() only + // establishes the UDP socket and sends the first Init1 packet — the actual + // clientinit command is sent later in async message callbacks (after Init1 + // round-trips complete), which cannot fire before this sync continuation. + // + // Why not commandMiddleware: The library's middleware chain only applies to + // post-connection commands (sendCommandNoWait/execCommand). The handshake's + // clientinit is sent directly via handler.sendPacket(), bypassing middleware. + if (this.detectedProtocol === "ts6") { + this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); + const handler = this.client.handler; + const origSendPacket = handler.sendPacket.bind(handler); + handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { + // PacketType.Command = 2 + if (pType === 2) { + const str = Buffer.from(data).toString("utf-8"); + if (str.startsWith("clientinit ")) { + const patched = patchClientInitVersion(str); + origSendPacket(pType, Buffer.from(patched), flags); + return; + } + } + origSendPacket(pType, data, flags); + }; + } + await this.client.waitConnected(); this.clientId = this.client.clientID(); this.voiceFramesSent = 0;