From 9e51ba6cd47af8dcca877ece03949bccd3a74396 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:03:47 +0000 Subject: [PATCH] Add dual TS3/TS6 protocol support with auto-detection The @honeybbq/teamspeak-client library already handles TS6 license block type 8 (Ts5Server) in its handshake, so voice connections work with both TS3 and TS6 servers. This commit adds the surrounding infrastructure: - protocol-detect.ts: Auto-detect server type by probing TS3 ServerQuery (port 10011) and TS6 HTTP Query (port 10080) in parallel - http-query.ts: TS6 HTTP Query client replacing the raw-TCP ServerQuery that TS6 servers no longer support (ports 10080/10443) - client.ts: Protocol-aware connection with auto-detection, TS6 HTTP Query setup, and forced protocol override option - manager.ts: Pass through serverProtocol and ts6ApiKey config options - connection.ts: Mark legacy TS3 ServerQuery as deprecated for TS6 https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- src/bot/manager.ts | 11 +- src/ts-protocol/client.ts | 63 ++++++++- src/ts-protocol/connection.ts | 9 +- src/ts-protocol/http-query.ts | 171 ++++++++++++++++++++++++ src/ts-protocol/protocol-detect.test.ts | 23 ++++ src/ts-protocol/protocol-detect.ts | 114 ++++++++++++++++ 6 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 src/ts-protocol/http-query.ts create mode 100644 src/ts-protocol/protocol-detect.test.ts create mode 100644 src/ts-protocol/protocol-detect.ts diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 4dab98a..9768e21 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -8,6 +8,8 @@ import type { BotDatabase } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; import type { Logger } from "../logger.js"; +import type { ServerProtocol } from "../ts-protocol/client.js"; + export interface CreateBotParams { name: string; serverAddress: string; @@ -17,6 +19,10 @@ export interface CreateBotParams { defaultChannel?: string; channelPassword?: string; autoStart?: boolean; + /** Force TS3 or TS6 protocol; omit or "unknown" for auto-detect. */ + serverProtocol?: ServerProtocol; + /** API key for TS6 HTTP Query (port 10080/10443). */ + ts6ApiKey?: string; } export class BotManager { @@ -57,6 +63,8 @@ export class BotManager { nickname: params.nickname, defaultChannel: params.defaultChannel, channelPassword: params.channelPassword, + serverProtocol: params.serverProtocol, + ts6ApiKey: params.ts6ApiKey, }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, @@ -148,10 +156,11 @@ export class BotManager { tsOptions: { host: saved.serverAddress, port: saved.serverPort, - queryPort: 10011, + queryPort: 10011, // Will be overridden by auto-detection for TS6 nickname: saved.nickname, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, + // Protocol will be auto-detected on connect }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 63e5c04..3b771d8 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -12,18 +12,29 @@ import { type ClientInfo, } 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"; export { CODEC_OPUS_MUSIC } from "./voice.js"; +export type { ServerProtocol } from "./protocol-detect.js"; export interface TS3ClientOptions { host: string; port: number; // Voice/virtual server port (default 9987) - queryPort: number; // ServerQuery port (default 10011) — unused now, kept for compat + queryPort: number; // ServerQuery port (10011 for TS3, 10080 for TS6 HTTP) nickname: string; identity?: string; // Exported identity string, or undefined to generate new defaultChannel?: string; channelPassword?: string; serverPassword?: string; + /** Force a specific protocol instead of auto-detecting. */ + serverProtocol?: ServerProtocol; + /** API key for TS6 HTTP Query authentication. */ + ts6ApiKey?: string; } export interface TS3TextMessage { @@ -40,6 +51,8 @@ export class TS3Client extends EventEmitter { private clientId = 0; private logger: Logger; private disconnecting = false; + private detectedProtocol: ServerProtocol = "unknown"; + private httpQuery: TS6HttpQuery | null = null; constructor(private options: TS3ClientOptions, logger: Logger) { super(); @@ -52,9 +65,50 @@ export class TS3Client extends EventEmitter { } } + /** The detected (or forced) server protocol after connect(). */ + getServerProtocol(): ServerProtocol { + return this.detectedProtocol; + } + + /** TS6 HTTP Query client (available after connecting to a TS6 server). */ + getHttpQuery(): TS6HttpQuery | null { + return this.httpQuery; + } + async connect(): Promise { const addr = `${this.options.host}:${this.options.port}`; - this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)"); + + // Detect or use forced protocol + if (this.options.serverProtocol && this.options.serverProtocol !== "unknown") { + this.detectedProtocol = this.options.serverProtocol; + this.logger.info( + { addr, protocol: this.detectedProtocol }, + "Using forced server protocol", + ); + } else { + this.logger.info({ addr }, "Detecting server protocol (TS3/TS6)..."); + const detection = await detectServerProtocol(this.options.host, this.options.port); + this.detectedProtocol = detection.protocol; + this.logger.info( + { addr, protocol: this.detectedProtocol, queryPort: detection.queryPort }, + `Server protocol detected: ${this.detectedProtocol.toUpperCase()}`, + ); + } + + // Set up TS6 HTTP Query if applicable + if (this.detectedProtocol === "ts6") { + const queryPort = this.options.queryPort !== 10011 ? this.options.queryPort : 10080; + this.httpQuery = new TS6HttpQuery({ + host: this.options.host, + port: queryPort, + apiKey: this.options.ts6ApiKey, + }); + } + + this.logger.info( + { addr, protocol: this.detectedProtocol }, + "Connecting to TeamSpeak server (full client protocol)", + ); // Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks) let udpErrorCount = 0; @@ -115,7 +169,10 @@ export class TS3Client extends EventEmitter { await this.client.waitConnected(); this.clientId = this.client.clientID(); this.voiceFramesSent = 0; - this.logger.info({ clientId: this.clientId }, "Logged in (visible client)"); + this.logger.info( + { clientId: this.clientId, protocol: this.detectedProtocol }, + `Logged in (visible client, ${this.detectedProtocol.toUpperCase()} server)`, + ); // Join default channel if specified if (this.options.defaultChannel) { diff --git a/src/ts-protocol/connection.ts b/src/ts-protocol/connection.ts index 135453c..065c66c 100644 --- a/src/ts-protocol/connection.ts +++ b/src/ts-protocol/connection.ts @@ -1,10 +1,17 @@ +/** + * TS3 raw-TCP ServerQuery connection (port 10011). + * + * @deprecated This module only works with TS3 servers. TS6 servers replaced + * the raw-TCP ServerQuery with HTTP/HTTPS (port 10080/10443) and SSH (10022). + * For TS6 servers, use {@link ../http-query.js TS6HttpQuery} instead. + */ import net from "node:net"; import { EventEmitter } from "node:events"; import { encodeCommand, decodeResponse, parseErrorLine } from "./commands.js"; export interface ConnectionOptions { host: string; - port: number; // ServerQuery port, typically 10011 + port: number; // ServerQuery port: 10011 (TS3) — not available on TS6 } export interface CommandResult { diff --git a/src/ts-protocol/http-query.ts b/src/ts-protocol/http-query.ts new file mode 100644 index 0000000..06c2946 --- /dev/null +++ b/src/ts-protocol/http-query.ts @@ -0,0 +1,171 @@ +import http from "node:http"; +import https from "node:https"; +import { EventEmitter } from "node:events"; + +export interface HttpQueryOptions { + host: string; + port: number; // 10080 (HTTP) or 10443 (HTTPS) + useTls?: boolean; + apiKey?: string; + timeoutMs?: number; +} + +export interface HttpQueryResult { + status: number; + body: unknown; +} + +/** + * TS6 HTTP Query client. + * + * TeamSpeak 6 Server replaces the TS3 raw-TCP ServerQuery (port 10011) + * with an HTTP/HTTPS API on ports 10080/10443. + * + * Common endpoints (TS6 HTTP Query): + * GET / → server info / health check + * POST /api-key → create API key + * GET /1/serverlist → list virtual servers + * GET /1/clientlist?sid={sid} → list clients + * POST /1/sendtextmessage → send text message + * POST /1/clientmove → move a client + * GET /1/channellist?sid={sid} → list channels + * POST /1/clientupdate → update client properties + */ +export class TS6HttpQuery extends EventEmitter { + private options: Required; + + constructor(options: HttpQueryOptions) { + super(); + this.options = { + host: options.host, + port: options.port, + useTls: options.useTls ?? options.port === 10443, + apiKey: options.apiKey ?? "", + timeoutMs: options.timeoutMs ?? 5000, + }; + } + + async request( + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + body?: Record, + ): Promise { + const { host, port, useTls, apiKey, timeoutMs } = this.options; + const transport = useTls ? https : http; + + const headers: Record = { + Accept: "application/json", + }; + if (apiKey) { + headers["x-api-key"] = apiKey; + } + + let bodyStr: string | undefined; + if (body) { + bodyStr = JSON.stringify(body); + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = String(Buffer.byteLength(bodyStr)); + } + + return new Promise((resolve, reject) => { + const req = transport.request( + { + hostname: host, + port, + path, + method, + timeout: timeoutMs, + headers, + rejectUnauthorized: false, // self-signed certs common on self-hosted + }, + (res) => { + let data = ""; + res.setEncoding("utf-8"); + res.on("data", (chunk: string) => (data += chunk)); + res.on("end", () => { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + parsed = data; + } + resolve({ + status: res.statusCode ?? 0, + body: parsed, + }); + }); + }, + ); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("TS6 HTTP Query timeout")); + }); + + if (bodyStr) { + req.write(bodyStr); + } + req.end(); + }); + } + + /** Check if the TS6 HTTP Query is reachable */ + async healthCheck(): Promise { + try { + const result = await this.request("GET", "/"); + return result.status >= 200 && result.status < 500; + } catch { + return false; + } + } + + /** List virtual servers */ + async serverList(): Promise { + return this.request("GET", "/1/serverlist"); + } + + /** List clients on a virtual server */ + async clientList(sid = 1): Promise { + return this.request("GET", `/1/clientlist?sid=${sid}`); + } + + /** List channels on a virtual server */ + async channelList(sid = 1): Promise { + return this.request("GET", `/1/channellist?sid=${sid}`); + } + + /** Send a text message */ + async sendTextMessage( + targetMode: number, + target: number, + msg: string, + sid = 1, + ): Promise { + return this.request("POST", `/1/sendtextmessage?sid=${sid}`, { + targetmode: targetMode, + target, + msg, + }); + } + + /** Update client properties (e.g., description) */ + async clientUpdate( + properties: Record, + sid = 1, + ): Promise { + return this.request("POST", `/1/clientupdate?sid=${sid}`, properties); + } + + /** Move a client to a channel */ + async clientMove( + clid: number, + cid: number, + cpw?: string, + sid = 1, + ): Promise { + const body: Record = { clid, cid }; + if (cpw) body.cpw = cpw; + return this.request("POST", `/1/clientmove?sid=${sid}`, body); + } +} diff --git a/src/ts-protocol/protocol-detect.test.ts b/src/ts-protocol/protocol-detect.test.ts new file mode 100644 index 0000000..46fdb86 --- /dev/null +++ b/src/ts-protocol/protocol-detect.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { + detectServerProtocol, + type ServerProtocol, + type ProtocolDetectResult, +} from "./protocol-detect.js"; + +describe("protocol-detect", () => { + it("returns unknown for unreachable hosts", async () => { + const result = await detectServerProtocol("192.0.2.1", 9987, 1000); + expect(result.protocol).toBe("unknown"); + expect(result.queryPort).toBeNull(); + expect(result.voicePort).toBe(9987); + }); + + it("result shape matches ProtocolDetectResult interface", async () => { + const result = await detectServerProtocol("127.0.0.1", 9987, 500); + expect(result).toHaveProperty("protocol"); + expect(result).toHaveProperty("queryPort"); + expect(result).toHaveProperty("voicePort"); + expect(["ts3", "ts6", "unknown"]).toContain(result.protocol); + }); +}); diff --git a/src/ts-protocol/protocol-detect.ts b/src/ts-protocol/protocol-detect.ts new file mode 100644 index 0000000..53269c8 --- /dev/null +++ b/src/ts-protocol/protocol-detect.ts @@ -0,0 +1,114 @@ +import net from "node:net"; +import http from "node:http"; + +export type ServerProtocol = "ts3" | "ts6" | "unknown"; + +export interface ProtocolDetectResult { + protocol: ServerProtocol; + /** The query port that responded (10011 for TS3, 10080 for TS6 HTTP) */ + queryPort: number | null; + /** Whether the voice port (UDP 9987) is the same for both */ + voicePort: number; +} + +/** + * Probe a TeamSpeak server to determine if it's running TS3 or TS6. + * + * Detection strategy: + * 1. Try TCP connect to port 10011 (TS3 ServerQuery) — if banner starts with "TS3", it's TS3. + * 2. Try HTTP GET to port 10080 (TS6 HTTP Query) — if we get a valid HTTP response, it's TS6. + * 3. If neither responds, return "unknown" (voice-only connection may still work). + */ +export async function detectServerProtocol( + host: string, + voicePort = 9987, + timeoutMs = 3000, +): Promise { + const [ts3, ts6] = await Promise.allSettled([ + probeTS3Query(host, 10011, timeoutMs), + probeTS6HttpQuery(host, 10080, timeoutMs), + ]); + + if (ts3.status === "fulfilled" && ts3.value) { + return { protocol: "ts3", queryPort: 10011, voicePort }; + } + if (ts6.status === "fulfilled" && ts6.value) { + return { protocol: "ts6", queryPort: 10080, voicePort }; + } + + return { protocol: "unknown", queryPort: null, voicePort }; +} + +/** + * Probe TS3 ServerQuery by connecting to raw TCP and checking for "TS3" banner. + */ +function probeTS3Query(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port, timeout: timeoutMs }); + let banner = ""; + + const cleanup = () => { + socket.removeAllListeners(); + socket.destroy(); + }; + + socket.setTimeout(timeoutMs); + + socket.on("data", (data: Buffer) => { + banner += data.toString("utf-8"); + if (banner.includes("TS3")) { + cleanup(); + resolve(true); + } + }); + + socket.on("connect", () => { + // Wait briefly for banner + setTimeout(() => { + cleanup(); + resolve(banner.includes("TS3")); + }, 500); + }); + + socket.on("error", () => { + cleanup(); + resolve(false); + }); + + socket.on("timeout", () => { + cleanup(); + resolve(false); + }); + }); +} + +/** + * Probe TS6 HTTP Query by sending GET / and checking for a valid response. + */ +function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: host, + port, + path: "/", + method: "GET", + timeout: timeoutMs, + headers: { Accept: "application/json" }, + }, + (res) => { + // TS6 HTTP Query returns some response (even 401/403 is valid — it means the service exists) + res.resume(); + resolve(res.statusCode !== undefined); + }, + ); + + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +}