From a6bfd9d32c3b8741256004eb05d3d4ae0f2f92ab Mon Sep 17 00:00:00 2001 From: saopig1 Date: Mon, 30 Mar 2026 00:29:47 +0800 Subject: [PATCH] feat: add TCP connection, UDP voice, and high-level TS3Client Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ts-protocol/client.ts | 187 ++++++++++++++++++++++++++++++++++ src/ts-protocol/connection.ts | 134 ++++++++++++++++++++++++ src/ts-protocol/voice.ts | 76 ++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 src/ts-protocol/client.ts create mode 100644 src/ts-protocol/connection.ts create mode 100644 src/ts-protocol/voice.ts diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts new file mode 100644 index 0000000..0638d90 --- /dev/null +++ b/src/ts-protocol/client.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from "node:events"; +import { TS3Connection, type CommandResult } from "./connection.js"; +import { VoiceConnection, CODEC_OPUS_MUSIC } from "./voice.js"; +import { + generateIdentity, + importIdentity, + exportIdentity, + type TS3Identity, +} from "./identity.js"; +import type pino from "pino"; + +export interface TS3ClientOptions { + host: string; + port: number; // Voice/virtual server port (default 9987) + queryPort: number; // ServerQuery port (default 10011) + nickname: string; + identity?: string; // Exported identity JSON, or undefined to generate new + defaultChannel?: string; + channelPassword?: 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 connection: TS3Connection; + private voice: VoiceConnection; + private identity: TS3Identity; + private clientId = 0; + private keepAliveInterval: ReturnType | null = null; + private logger: pino.Logger; + + constructor(private options: TS3ClientOptions, logger: pino.Logger) { + super(); + this.logger = logger; + + this.connection = new TS3Connection({ + host: options.host, + port: options.queryPort, + }); + + this.voice = new VoiceConnection({ + host: options.host, + port: options.port, + }); + + if (options.identity) { + this.identity = importIdentity(options.identity); + } else { + this.identity = generateIdentity(); + } + } + + async connect(): Promise { + this.logger.info( + { host: this.options.host, port: this.options.port }, + "Connecting to TeamSpeak server" + ); + + await this.connection.connect(); + this.logger.debug("TCP connection established"); + + await this.sendCommand("use", { port: this.options.port }); + + await this.sendCommand("clientupdate", { + client_nickname: this.options.nickname, + }); + + const whoami = await this.sendCommand("whoami", {}); + if (whoami.data.length > 0) { + this.clientId = parseInt(whoami.data[0].client_id ?? "0", 10); + this.logger.info({ clientId: this.clientId }, "Logged in"); + } + + if (this.options.defaultChannel) { + await this.joinChannel( + this.options.defaultChannel, + this.options.channelPassword + ); + } + + await this.voice.connect(); + this.voice.setClientId(this.clientId); + this.logger.debug("UDP voice connection established"); + + await this.sendCommand("servernotifyregister", { event: "textchannel" }); + await this.sendCommand("servernotifyregister", { event: "textprivate" }); + + this.connection.on( + "notify:textmessage", + (data: Record) => { + const msg: TS3TextMessage = { + invokerName: data.invokername ?? "", + invokerId: data.invokerid ?? "", + invokerUid: data.invokeruid ?? "", + message: data.msg ?? "", + targetMode: parseInt(data.targetmode ?? "0", 10), + }; + this.emit("textMessage", msg); + } + ); + + this.keepAliveInterval = setInterval(() => { + this.voice.sendKeepAlive(); + }, 5000); + + this.connection.on("close", () => { + this.logger.warn("Connection closed"); + this.emit("disconnected"); + }); + + this.emit("connected"); + } + + async sendCommand( + command: string, + params: Record + ): Promise { + return this.connection.send(command, params); + } + + async joinChannel(channelName: string, password?: string): Promise { + const channels = await this.sendCommand("channellist", {}); + const channel = channels.data.find( + (ch) => ch.channel_name === channelName + ); + + if (!channel) { + this.logger.warn({ channelName }, "Channel not found"); + return; + } + + const params: Record = { + cid: channel.cid, + clid: this.clientId, + }; + if (password) { + params.cpw = password; + } + + await this.sendCommand("clientmove", params); + this.logger.info({ channelName, cid: channel.cid }, "Joined channel"); + } + + async sendTextMessage( + message: string, + targetMode: number = 2 + ): Promise { + await this.sendCommand("sendtextmessage", { + targetmode: targetMode, + target: targetMode === 2 ? 0 : this.clientId, + msg: message, + }); + } + + async getClientsInChannel(): Promise[]> { + const result = await this.sendCommand("clientlist", {}); + return result.data; + } + + sendVoiceData(opusFrame: Buffer): void { + this.voice.sendVoicePacket(opusFrame, CODEC_OPUS_MUSIC); + } + + getIdentityExport(): string { + return exportIdentity(this.identity); + } + + getClientId(): number { + return this.clientId; + } + + disconnect(): void { + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + this.keepAliveInterval = null; + } + this.connection.disconnect(); + this.voice.disconnect(); + this.logger.info("Disconnected from TeamSpeak server"); + } +} diff --git a/src/ts-protocol/connection.ts b/src/ts-protocol/connection.ts new file mode 100644 index 0000000..9ea5989 --- /dev/null +++ b/src/ts-protocol/connection.ts @@ -0,0 +1,134 @@ +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 +} + +export interface CommandResult { + errorId: number; + errorMessage: string; + data: Record[]; +} + +export class TS3Connection extends EventEmitter { + private socket: net.Socket | null = null; + private buffer = ""; + private commandQueue: Array<{ + resolve: (result: CommandResult) => void; + reject: (err: Error) => void; + }> = []; + private connected = false; + private responseLines: string[] = []; + + constructor(private options: ConnectionOptions) { + super(); + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this.socket = net.createConnection( + this.options.port, + this.options.host, + () => { + this.connected = true; + resolve(); + } + ); + + this.socket.setEncoding("utf-8"); + this.socket.on("data", (data: string) => this.handleData(data)); + this.socket.on("error", (err) => { + if (!this.connected) { + reject(err); + } + this.emit("error", err); + }); + this.socket.on("close", () => { + this.connected = false; + this.emit("close"); + }); + }); + } + + private handleData(data: string): void { + this.buffer += data; + const lines = this.buffer.split("\n\r"); + + this.buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith("TS3") || trimmed.startsWith("Welcome")) + continue; + + if (trimmed.startsWith("notify")) { + this.handleNotification(trimmed); + continue; + } + + if (trimmed.startsWith("error ")) { + const error = parseErrorLine(trimmed); + const pending = this.commandQueue.shift(); + if (pending) { + pending.resolve({ + errorId: error.id, + errorMessage: error.msg, + data: + this.responseLines.length > 0 + ? decodeResponse(this.responseLines.join("\n")) + : [], + }); + } + this.responseLines = []; + continue; + } + + this.responseLines.push(trimmed); + } + } + + private handleNotification(line: string): void { + const spaceIndex = line.indexOf(" "); + const notifyPart = spaceIndex === -1 ? line : line.substring(0, spaceIndex); + const eventName = notifyPart.replace(/^notify/, ""); + const data = + spaceIndex === -1 + ? {} + : decodeResponse(line.substring(spaceIndex + 1))[0]; + this.emit("notify", eventName, data); + this.emit(`notify:${eventName}`, data); + } + + async send( + command: string, + params: Record = {} + ): Promise { + if (!this.socket || !this.connected) { + throw new Error("Not connected"); + } + + const encoded = encodeCommand(command, params); + + return new Promise((resolve, reject) => { + this.commandQueue.push({ resolve, reject }); + this.socket!.write(encoded); + }); + } + + disconnect(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + this.connected = false; + } + } + + isConnected(): boolean { + return this.connected; + } +} diff --git a/src/ts-protocol/voice.ts b/src/ts-protocol/voice.ts new file mode 100644 index 0000000..4cfe8bb --- /dev/null +++ b/src/ts-protocol/voice.ts @@ -0,0 +1,76 @@ +import dgram from "node:dgram"; +import { EventEmitter } from "node:events"; + +export interface VoiceOptions { + host: string; + port: number; // same as virtual server port, typically 9987 +} + +export const CODEC_OPUS_VOICE = 4; +export const CODEC_OPUS_MUSIC = 5; + +export class VoiceConnection extends EventEmitter { + private socket: dgram.Socket | null = null; + private packetCounter = 0; + private clientId = 0; + + constructor(private options: VoiceOptions) { + super(); + } + + setClientId(id: number): void { + this.clientId = id; + } + + async connect(): Promise { + return new Promise((resolve) => { + this.socket = dgram.createSocket("udp4"); + + this.socket.on("message", (msg) => { + this.emit("voiceData", msg); + }); + + this.socket.on("error", (err) => { + this.emit("error", err); + }); + + this.socket.connect(this.options.port, this.options.host, () => { + resolve(); + }); + }); + } + + sendVoicePacket( + opusData: Buffer, + codec: number = CODEC_OPUS_MUSIC + ): void { + if (!this.socket) return; + + const packetId = this.packetCounter++; + if (this.packetCounter > 0xffff) this.packetCounter = 0; + + const header = Buffer.alloc(5); + header.writeUInt16BE(packetId, 0); + header.writeUInt16BE(this.clientId, 2); + header.writeUInt8(codec, 4); + + const packet = Buffer.concat([header, opusData]); + this.socket.send(packet); + } + + sendKeepAlive(): void { + if (!this.socket) return; + const ping = Buffer.alloc(4); + ping.writeUInt16BE(this.packetCounter++, 0); + ping.writeUInt16BE(this.clientId, 2); + if (this.packetCounter > 0xffff) this.packetCounter = 0; + this.socket.send(ping); + } + + disconnect(): void { + if (this.socket) { + this.socket.close(); + this.socket = null; + } + } +}