Files
teamspeak-music-bot/src/ts-protocol/client.ts
T
saopig1andClaude Opus 4.6 ca4cb1790a chore(deps): bump @honeybbq/teamspeak-client to 0.2.1, drop ts6-compat shim
Version 0.2.1 ships a universal clientinit format that works natively
against both TS3 and TS6 servers:

    client_version: "3.?.? [Build: 5680278000]"
    client_version_sign: DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOo
                         BzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==

The old ts6-compat.ts workaround (monkey-patching handler.sendPacket to
rewrite clientinit's client_version to "3.6.2") is now actively wrong:
it replaces the library's new correct version/signature pair with a
stale one that TS6 servers reject, which is why the first 0.2.1 TS6
handshake attempt still hung at `received initivexpand2`.

Changes:
- package.json: "@honeybbq/teamspeak-client": "^0.1.0" -> "^0.2.1"
- src/ts-protocol/client.ts: remove patchClientInitVersion import and
  the sendPacket monkey-patch block. Leave an inline comment so anyone
  reading the git blame understands why the shim is gone.
- Delete src/ts-protocol/ts6-compat.ts and ts6-compat.test.ts — no
  callers remain.

Other 0.2.x notes worth knowing (no code change here, just documenting):
- ClientOptions gained serverPassword / defaultChannel /
  defaultChannelPassword that are sent DURING clientinit. We still call
  our own joinChannel() post-connect because the existing flow works
  and switching is an orthogonal refactor.
- 0.1.1 contains the P-256 DER encoding fix (PR #5 by ZHANGTIANYAO1).
  Identities generated by 0.1.0 are cryptographically incompatible with
  0.2.x's corrected handshake path — a bot whose identity column was
  populated before this upgrade will hang at `received initivexpand2`
  and fall through the 15s connect deadline. Workaround: clear the
  identity column so the next start generates a fresh key. Server
  groups assigned to the old UID must be re-granted once against the
  new one.

Verification:
- tsc --noEmit clean
- vitest: 93/93 unit tests pass (1 test file removed with ts6-compat)
- scripts/test_full_feature.py against a local TS6 server: 51/51 pass,
  including handshake, voice playback, identity persistence, WebSocket
  stateChange broadcasts, and all corner-case regressions.
- Live: bot connected to TS6 in ~80ms after identity regeneration,
  played NetEase audio through the voice channel, clean stop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:54:43 +08:00

327 lines
10 KiB
TypeScript

import { EventEmitter } from "node:events";
import {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
identityFromString,
sendTextMessage,
listChannels,
listClients,
clientMove,
type Identity,
type TextMessage,
type ClientInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
import {
detectServerProtocol,
type ServerProtocol,
} 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 (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 {
invokerName: string;
invokerId: string;
invokerUid: string;
message: string;
targetMode: number; // 1=private, 2=channel, 3=server
}
export class TS3Client extends EventEmitter {
private client: TS3FullClient | null = null;
private identity: Identity;
private clientId = 0;
private logger: Logger;
private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown";
private httpQuery: TS6HttpQuery | null = null;
private udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private options: TS3ClientOptions, logger: Logger) {
super();
this.logger = logger;
if (options.identity) {
this.identity = identityFromString(options.identity);
} else {
this.identity = genTS3Identity(8);
}
}
/** 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> {
// Clean up any existing connection before creating a new one
if (this.client) {
this.logger.info("Cleaning up previous connection before reconnecting");
try {
await this.client.disconnect();
} catch {
// Ignore errors during cleanup
}
this.client = null;
this.clientId = 0;
}
const addr = `${this.options.host}:${this.options.port}`;
// 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,
3000,
{ ts3QueryPort: 10011, ts6HttpPort: 10080 },
);
this.detectedProtocol = detection.protocol;
if (this.detectedProtocol === "unknown") {
this.logger.warn(
{ addr },
"Could not detect server protocol (query ports 10011/10080 unreachable). " +
"Will attempt voice connection anyway. Use serverProtocol option to force TS3 or TS6.",
);
} else {
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,
});
}
// 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));
}
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;
const throttledWarn = (msg: string, ...args: unknown[]) => {
if (typeof msg === "string" && msg.includes("udp send error")) {
udpErrorCount++;
if (udpErrorCount === 1) {
this.logger.warn(msg);
// 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)`);
}
udpErrorCount = 0;
this.udpErrorTimer = null;
}, 2000);
}
return;
}
this.logger.warn(msg);
};
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, {
logger: {
debug: (msg) => this.logger.debug(msg),
info: (msg) => this.logger.info(msg),
warn: throttledWarn,
error: (msg) => this.logger.error(msg),
},
});
this.client.on("textMessage", (msg: TextMessage) => {
const tsMsg: TS3TextMessage = {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
};
this.emit("textMessage", tsMsg);
});
this.client.on("disconnected", (err) => {
this.logger.warn({ err: err?.message }, "Connection closed");
this.clientId = 0;
this.emit("disconnected");
});
this.client.on("clientEnter", (info: ClientInfo) => {
this.logger.debug(
{ nickname: info.nickname, id: info.id },
"Client entered"
);
});
await this.client.connect();
// Note: @honeybbq/teamspeak-client 0.2.x ships a universal clientinit
// (client_version "3.?.? [Build: 5680278000]" + matching signature)
// that works against both TS3 and TS6 servers. The old 3.6.2 monkey-
// patch on handler.sendPacket was removed when we bumped to 0.2.1 — it
// would have replaced the library's new correct version with a stale
// signature and made TS6 handshakes fail.
await this.client.waitConnected();
this.clientId = this.client.clientID();
this.voiceFramesSent = 0;
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) {
await this.joinChannel(
this.options.defaultChannel,
this.options.channelPassword
);
}
this.emit("connected");
}
async joinChannel(channelName: string, password?: string): Promise<void> {
if (!this.client) return;
try {
const channels = await listChannels(this.client);
const channel = channels.find((ch) => ch.name === channelName);
if (!channel) {
this.logger.warn({ channelName }, "Channel not found");
return;
}
await clientMove(
this.client,
this.clientId,
channel.id,
password
);
this.logger.info(
{ channelName, cid: channel.id.toString() },
"Joined channel"
);
} catch (err) {
this.logger.error({ err, channelName }, "Failed to join channel");
}
}
async sendTextMessage(
message: string,
targetMode: number = 2
): Promise<void> {
if (!this.client) return;
// targetMode 2 = channel, target 0 = current channel
const target = targetMode === 2 ? BigInt(0) : BigInt(this.clientId);
await sendTextMessage(this.client, targetMode, target, message);
}
async getClientsInChannel(): Promise<ClientInfo[]> {
if (!this.client) return [];
try {
const allClients = await listClients(this.client);
const myChannelId = this.client.channelID();
return allClients.filter((c) => c.channelID === myChannelId);
} catch {
return [];
}
}
private voiceFramesSent = 0;
sendVoiceData(opusFrame: Buffer): void {
if (!this.client || this.disconnecting) return;
try {
this.client.sendVoice(opusFrame, 5);
this.voiceFramesSent++;
if (this.voiceFramesSent === 1) {
this.logger.info({ opusBytes: opusFrame.length, clientId: this.clientId }, "First voice packet sent to TeamSpeak");
}
} catch (err) {
if (this.voiceFramesSent === 0) {
this.logger.error({ err }, "Failed to send first voice packet");
}
}
}
getIdentityExport(): string {
return this.identity.toString();
}
getClientId(): number {
return this.clientId;
}
disconnect(): void {
if (this.client && !this.disconnecting) {
this.disconnecting = true;
const client = this.client;
client.disconnect().catch(() => {}).finally(() => {
if (this.client === client) {
this.client = null;
}
this.disconnecting = false;
});
}
this.clientId = 0;
this.httpQuery = null;
this.detectedProtocol = "unknown";
if (this.udpErrorTimer) {
clearTimeout(this.udpErrorTimer);
this.udpErrorTimer = null;
}
this.logger.info("Disconnected from TeamSpeak server");
}
}