diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 0058824..b436a4e 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -494,14 +494,19 @@ export class BotInstance extends EventEmitter { this.voteSkipUsers.clear(); const next = this.queue.next(); if (next) { - const ok = await this.resolveAndPlay(next); - if (!ok) { + 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++) { const retry = this.queue.next(); if (!retry) break; - if (await this.resolveAndPlay(retry)) break; + if (await this.resolveAndPlay(retry)) { + started = true; + break; + } } + } + if (!started) { this.player.stop(); } } else { diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index bf574e6..acdc859 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -10,14 +10,15 @@ import { type Identity, type TextMessage, type ClientInfo, + type CommandMiddleware, } from "@honeybbq/teamspeak-client"; import type { Logger } from "../logger.js"; import { detectServerProtocol, type ServerProtocol, - type ProtocolDetectResult, } from "./protocol-detect.js"; import { TS6HttpQuery } from "./http-query.js"; +import { ts6VersionMiddleware } from "./ts6-compat.js"; export { CODEC_OPUS_MUSIC } from "./voice.js"; export type { ServerProtocol } from "./protocol-detect.js"; @@ -153,6 +154,13 @@ 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), @@ -160,6 +168,7 @@ export class TS3Client extends EventEmitter { warn: throttledWarn, error: (msg) => this.logger.error(msg), }, + commandMiddleware, }); this.client.on("textMessage", (msg: TextMessage) => { diff --git a/src/ts-protocol/ts6-compat.test.ts b/src/ts-protocol/ts6-compat.test.ts new file mode 100644 index 0000000..fd61412 --- /dev/null +++ b/src/ts-protocol/ts6-compat.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { ts6VersionMiddleware } 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); + + 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); + + expect(captured).toContain("client_version=3.6.2\\s[Build:\\s1695203293]"); + expect(captured).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"); + }); +}); diff --git a/src/ts-protocol/ts6-compat.ts b/src/ts-protocol/ts6-compat.ts new file mode 100644 index 0000000..5d24f09 --- /dev/null +++ b/src/ts-protocol/ts6-compat.ts @@ -0,0 +1,80 @@ +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. + */ +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==", + }, +}; + +/** + * Command middleware that upgrades the client_version in `clientinit` to + * a newer version known to work with TS6 servers. + * + * 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 version_sign must match the version string — it's a server-verified + * ECDSA signature, so we can only use known-valid pairs. + */ +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); + }; +} + +/** + * Replace an escaped field value in a TS3 command string. + * Fields are in the format: key=escaped_value (space-separated) + */ +function replaceField(cmd: string, key: string, value: string): string { + // Escape the value using TS3 escaping rules + const escaped = escapeTS3(value); + const regex = new RegExp(`${key}=\\S*`); + if (regex.test(cmd)) { + return cmd.replace(regex, `${key}=${escaped}`); + } + return cmd; +} + +function escapeTS3(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\//g, "\\/") + .replace(/ /g, "\\s") + .replace(/\|/g, "\\p") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +}