mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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>
327 lines
10 KiB
TypeScript
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");
|
|
}
|
|
}
|