mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
6 files changed
+386
-5
No files matched your search
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user