mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { Readable } from "node:stream";
|
|
import {
|
|
Client as TS3FullClient,
|
|
generateIdentity as genTS3Identity,
|
|
identityFromString,
|
|
sendTextMessage,
|
|
listChannels,
|
|
listClients,
|
|
clientMove,
|
|
fileTransferDeleteFile,
|
|
type Identity,
|
|
type TextMessage,
|
|
type ClientInfo,
|
|
type ClientLeftViewEvent,
|
|
type ClientMovedEvent,
|
|
type FileUploadInfo,
|
|
} 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 type { FileUploadInfo } from "@honeybbq/teamspeak-client";
|
|
|
|
/** Escape a string for use in TS3 ServerQuery-style commands. */
|
|
export function escapeTS3(str: string): string {
|
|
return str
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/ /g, "\\s")
|
|
.replace(/\//g, "\\/")
|
|
.replace(/\|/g, "\\p")
|
|
.replace(/\t/g, "\\t")
|
|
.replace(/\n/g, "\\n")
|
|
.replace(/\r/g, "\\r");
|
|
}
|
|
|
|
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, {
|
|
// Forward server password to the protocol library so it can be
|
|
// included in clientinit for password-protected servers
|
|
serverPassword: this.options.serverPassword,
|
|
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"
|
|
);
|
|
this.emit("clientEnter", info);
|
|
});
|
|
|
|
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
|
|
this.logger.debug({ id: ev.id }, "Client left");
|
|
this.emit("clientLeave", ev);
|
|
});
|
|
|
|
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
|
|
this.logger.debug(
|
|
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
|
|
"Client moved"
|
|
);
|
|
this.emit("clientMoved", ev);
|
|
});
|
|
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
// --- Raw command & file transfer pass-through ---
|
|
|
|
async execCommand(cmd: string): Promise<void> {
|
|
if (!this.client) throw new Error("Not connected");
|
|
await this.client.execCommand(cmd);
|
|
}
|
|
|
|
/** Fire a command without waiting for the server's response. */
|
|
async sendCommandNoWait(cmd: string): Promise<void> {
|
|
if (!this.client) throw new Error("Not connected");
|
|
await this.client.sendCommandNoWait(cmd);
|
|
}
|
|
|
|
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
|
|
if (!this.client) throw new Error("Not connected");
|
|
return this.client.execCommandWithResponse(cmd);
|
|
}
|
|
|
|
async fileTransferInitUpload(
|
|
channelID: bigint,
|
|
path: string,
|
|
password: string,
|
|
size: bigint,
|
|
overwrite = true,
|
|
): Promise<FileUploadInfo> {
|
|
if (!this.client) throw new Error("Not connected");
|
|
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
|
|
}
|
|
|
|
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
|
|
if (!this.client) throw new Error("Not connected");
|
|
await this.client.uploadFileData(host, info, data);
|
|
}
|
|
|
|
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
|
|
if (!this.client) throw new Error("Not connected");
|
|
await fileTransferDeleteFile(this.client, channelID, paths);
|
|
}
|
|
|
|
/** The server host (needed for file transfer TCP connections). */
|
|
getHost(): string {
|
|
return this.options.host;
|
|
}
|
|
|
|
/** The current channel ID of this client. */
|
|
getChannelId(): bigint {
|
|
if (!this.client) return 0n;
|
|
return this.client.channelID();
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|