feat: add TCP connection, UDP voice, and high-level TS3Client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 00:29:47 +08:00
1 parent e059ca5bfe
commit a6bfd9d32c
3 files changed
+397

No files matched your search

+187
View File
@@ -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<typeof setInterval> | 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<void> {
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<string, string>) => {
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<string, string | number>
): Promise<CommandResult> {
return this.connection.send(command, params);
}
async joinChannel(channelName: string, password?: string): Promise<void> {
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<string, string | number> = {
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<void> {
await this.sendCommand("sendtextmessage", {
targetmode: targetMode,
target: targetMode === 2 ? 0 : this.clientId,
msg: message,
});
}
async getClientsInChannel(): Promise<Record<string, string>[]> {
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");
}
}
+134
View File
@@ -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<string, string>[];
}
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<void> {
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<string, string | number> = {}
): Promise<CommandResult> {
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;
}
}
+76
View File
@@ -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<void> {
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;
}
}
}