mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
1 parent
e059ca5bfe
commit
a6bfd9d32c
3 files changed
+397
No files matched your search
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user