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
This commit is contained in:
Claude committed 2026-04-03 13:03:47 +00:00
1 parent 01940e74dd
commit 9e51ba6cd4
6 files changed
+386 -5

No files matched your search

+10 -1
View File
@@ -8,6 +8,8 @@ import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js"; import type { BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js";
export interface CreateBotParams { export interface CreateBotParams {
name: string; name: string;
serverAddress: string; serverAddress: string;
@@ -17,6 +19,10 @@ export interface CreateBotParams {
defaultChannel?: string; defaultChannel?: string;
channelPassword?: string; channelPassword?: string;
autoStart?: boolean; 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 { export class BotManager {
@@ -57,6 +63,8 @@ export class BotManager {
nickname: params.nickname, nickname: params.nickname,
defaultChannel: params.defaultChannel, defaultChannel: params.defaultChannel,
channelPassword: params.channelPassword, channelPassword: params.channelPassword,
serverProtocol: params.serverProtocol,
ts6ApiKey: params.ts6ApiKey,
}, },
neteaseProvider: this.neteaseProvider, neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
@@ -148,10 +156,11 @@ export class BotManager {
tsOptions: { tsOptions: {
host: saved.serverAddress, host: saved.serverAddress,
port: saved.serverPort, port: saved.serverPort,
queryPort: 10011, queryPort: 10011, // Will be overridden by auto-detection for TS6
nickname: saved.nickname, nickname: saved.nickname,
defaultChannel: saved.defaultChannel || undefined, defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined, channelPassword: saved.channelPassword || undefined,
// Protocol will be auto-detected on connect
}, },
neteaseProvider: this.neteaseProvider, neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
+60 -3
View File
@@ -12,18 +12,29 @@ import {
type ClientInfo, type ClientInfo,
} from "@honeybbq/teamspeak-client"; } from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js"; 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 { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js";
export interface TS3ClientOptions { export interface TS3ClientOptions {
host: string; host: string;
port: number; // Voice/virtual server port (default 9987) 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; nickname: string;
identity?: string; // Exported identity string, or undefined to generate new identity?: string; // Exported identity string, or undefined to generate new
defaultChannel?: string; defaultChannel?: string;
channelPassword?: string; channelPassword?: string;
serverPassword?: 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 { export interface TS3TextMessage {
@@ -40,6 +51,8 @@ export class TS3Client extends EventEmitter {
private clientId = 0; private clientId = 0;
private logger: Logger; private logger: Logger;
private disconnecting = false; private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown";
private httpQuery: TS6HttpQuery | null = null;
constructor(private options: TS3ClientOptions, logger: Logger) { constructor(private options: TS3ClientOptions, logger: Logger) {
super(); 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<void> { async connect(): Promise<void> {
const addr = `${this.options.host}:${this.options.port}`; 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) // Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks)
let udpErrorCount = 0; let udpErrorCount = 0;
@@ -115,7 +169,10 @@ export class TS3Client extends EventEmitter {
await this.client.waitConnected(); await this.client.waitConnected();
this.clientId = this.client.clientID(); this.clientId = this.client.clientID();
this.voiceFramesSent = 0; 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 // Join default channel if specified
if (this.options.defaultChannel) { if (this.options.defaultChannel) {
+8 -1
View File
@@ -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 net from "node:net";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { encodeCommand, decodeResponse, parseErrorLine } from "./commands.js"; import { encodeCommand, decodeResponse, parseErrorLine } from "./commands.js";
export interface ConnectionOptions { export interface ConnectionOptions {
host: string; host: string;
port: number; // ServerQuery port, typically 10011 port: number; // ServerQuery port: 10011 (TS3) — not available on TS6
} }
export interface CommandResult { export interface CommandResult {
+171
View File
@@ -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<HttpQueryOptions>;
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<string, unknown>,
): Promise<HttpQueryResult> {
const { host, port, useTls, apiKey, timeoutMs } = this.options;
const transport = useTls ? https : http;
const headers: Record<string, string> = {
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<boolean> {
try {
const result = await this.request("GET", "/");
return result.status >= 200 && result.status < 500;
} catch {
return false;
}
}
/** List virtual servers */
async serverList(): Promise<HttpQueryResult> {
return this.request("GET", "/1/serverlist");
}
/** List clients on a virtual server */
async clientList(sid = 1): Promise<HttpQueryResult> {
return this.request("GET", `/1/clientlist?sid=${sid}`);
}
/** List channels on a virtual server */
async channelList(sid = 1): Promise<HttpQueryResult> {
return this.request("GET", `/1/channellist?sid=${sid}`);
}
/** Send a text message */
async sendTextMessage(
targetMode: number,
target: number,
msg: string,
sid = 1,
): Promise<HttpQueryResult> {
return this.request("POST", `/1/sendtextmessage?sid=${sid}`, {
targetmode: targetMode,
target,
msg,
});
}
/** Update client properties (e.g., description) */
async clientUpdate(
properties: Record<string, string | number>,
sid = 1,
): Promise<HttpQueryResult> {
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<HttpQueryResult> {
const body: Record<string, unknown> = { clid, cid };
if (cpw) body.cpw = cpw;
return this.request("POST", `/1/clientmove?sid=${sid}`, body);
}
}
+23
View File
@@ -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);
});
});
+114
View File
@@ -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<ProtocolDetectResult> {
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<boolean> {
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<boolean> {
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();
});
}