mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat: replace ServerQuery with full TS3 client protocol — bot is now VISIBLE
Switch from TCP ServerQuery (port 10011, invisible) to real TS3 client protocol via @honeybbq/teamspeak-client (UDP 9987, ECDH handshake, AES-EAX encryption). Bot now appears as a regular client visible to all users in TeamSpeak. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
df7b944742
commit
0559f09f0a
4 files changed
+410
-121
No files matched your search
Generated
+131
@@ -10,11 +10,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
|
"@honeybbq/teamspeak-client": "^0.1.0",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"NeteaseCloudMusicApi": "^4.30.0",
|
"NeteaseCloudMusicApi": "^4.30.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
|
"ts3-nodejs-library": "^3.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
@@ -541,6 +543,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@honeybbq/teamspeak-client": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@honeybbq/teamspeak-client/-/teamspeak-client-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-Gh/siE4a0NRxqEqiwbvYn6p4ulphG6IVZaTCuCUjS/+cD6rVvWNrCgbJ0724RdkEY5yndoQ34zkjE/Lglk1wTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/curves": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -567,6 +581,33 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@emnapi/runtime": "^1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/curves": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.122.0",
|
"version": "0.122.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||||
@@ -1242,6 +1283,15 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": "~2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1325,6 +1375,21 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt-pbkdf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^0.14.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bcrypt-pbkdf/node_modules/tweetnacl": {
|
||||||
|
"version": "0.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.8.0",
|
"version": "12.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
||||||
@@ -1417,6 +1482,24 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-crc32": {
|
||||||
|
"version": "0.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
|
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buildcheck": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
@@ -1612,6 +1695,20 @@
|
|||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cpu-features": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buildcheck": "~0.0.6",
|
||||||
|
"nan": "^2.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crypt": {
|
"node_modules/crypt": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||||
@@ -3125,6 +3222,13 @@
|
|||||||
"url": "https://github.com/sponsors/Borewit"
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.26.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
|
||||||
|
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -4599,6 +4703,23 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssh2": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.6",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cpu-features": "~0.0.10",
|
||||||
|
"nan": "^2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
@@ -4834,6 +4955,16 @@
|
|||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ts3-nodejs-library": {
|
||||||
|
"version": "3.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts3-nodejs-library/-/ts3-nodejs-library-3.5.1.tgz",
|
||||||
|
"integrity": "sha512-hdGAj2lhMyAeLVc1j7sOLA8/pjwib32jtA8ncokNDLg6EGTe/AqcsCT3D/34e8xIuidD4XMMXA8yWf64lQN4Dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-crc32": "^0.2.13",
|
||||||
|
"ssh2": "^1.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|||||||
@@ -14,11 +14,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
|
"@honeybbq/teamspeak-client": "^0.1.0",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"NeteaseCloudMusicApi": "^4.30.0",
|
"NeteaseCloudMusicApi": "^4.30.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
|
"ts3-nodejs-library": "^3.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+101
-121
@@ -1,22 +1,29 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { TS3Connection, type CommandResult } from "./connection.js";
|
|
||||||
import { VoiceConnection, CODEC_OPUS_MUSIC } from "./voice.js";
|
|
||||||
import {
|
import {
|
||||||
generateIdentity,
|
Client as TS3FullClient,
|
||||||
importIdentity,
|
generateIdentity as genTS3Identity,
|
||||||
exportIdentity,
|
identityFromString,
|
||||||
type TS3Identity,
|
sendTextMessage,
|
||||||
} from "./identity.js";
|
listChannels,
|
||||||
|
listClients,
|
||||||
|
clientMove,
|
||||||
|
type Identity,
|
||||||
|
type TextMessage,
|
||||||
|
type ClientInfo,
|
||||||
|
} from "@honeybbq/teamspeak-client";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
|
export { CODEC_OPUS_MUSIC } from "./voice.js";
|
||||||
|
|
||||||
export interface TS3ClientOptions {
|
export interface TS3ClientOptions {
|
||||||
host: string;
|
host: string;
|
||||||
port: number; // Voice/virtual server port (default 9987)
|
port: number; // Voice/virtual server port (default 9987)
|
||||||
queryPort: number; // ServerQuery port (default 10011)
|
queryPort: number; // ServerQuery port (default 10011) — unused now, kept for compat
|
||||||
nickname: string;
|
nickname: string;
|
||||||
identity?: string; // Exported identity JSON, or undefined to generate new
|
identity?: string; // Exported identity string, or undefined to generate new
|
||||||
defaultChannel?: string;
|
defaultChannel?: string;
|
||||||
channelPassword?: string;
|
channelPassword?: string;
|
||||||
|
serverPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TS3TextMessage {
|
export interface TS3TextMessage {
|
||||||
@@ -28,84 +35,65 @@ export interface TS3TextMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TS3Client extends EventEmitter {
|
export class TS3Client extends EventEmitter {
|
||||||
private connection: TS3Connection;
|
private client: TS3FullClient | null = null;
|
||||||
private voice: VoiceConnection;
|
private identity: Identity;
|
||||||
private identity: TS3Identity;
|
|
||||||
private clientId = 0;
|
private clientId = 0;
|
||||||
private keepAliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(private options: TS3ClientOptions, logger: Logger) {
|
constructor(private options: TS3ClientOptions, logger: Logger) {
|
||||||
super();
|
super();
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
|
|
||||||
this.connection = new TS3Connection({
|
|
||||||
host: options.host,
|
|
||||||
port: options.queryPort,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.voice = new VoiceConnection({
|
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.connection.on("error", (err) => {
|
|
||||||
this.logger.error({ err }, "TCP connection error");
|
|
||||||
this.emit("error", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.voice.on("error", (err) => {
|
|
||||||
this.logger.error({ err }, "UDP voice error");
|
|
||||||
this.emit("error", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
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.connection.on("close", () => {
|
|
||||||
this.logger.warn("Connection closed");
|
|
||||||
this.emit("disconnected");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.identity) {
|
if (options.identity) {
|
||||||
this.identity = importIdentity(options.identity);
|
this.identity = identityFromString(options.identity);
|
||||||
} else {
|
} else {
|
||||||
this.identity = generateIdentity();
|
this.identity = genTS3Identity(8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
this.logger.info(
|
const addr = `${this.options.host}:${this.options.port}`;
|
||||||
{ host: this.options.host, port: this.options.port },
|
this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)");
|
||||||
"Connecting to TeamSpeak server"
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.connection.connect();
|
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, {
|
||||||
this.logger.debug("TCP connection established");
|
logger: {
|
||||||
|
debug: (msg) => this.logger.debug(msg),
|
||||||
await this.sendCommand("use", { port: this.options.port });
|
info: (msg) => this.logger.info(msg),
|
||||||
|
warn: (msg) => this.logger.warn(msg),
|
||||||
await this.sendCommand("clientupdate", {
|
error: (msg) => this.logger.error(msg),
|
||||||
client_nickname: this.options.nickname,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const whoami = await this.sendCommand("whoami", {});
|
this.client.on("textMessage", (msg: TextMessage) => {
|
||||||
if (whoami.data.length > 0) {
|
const tsMsg: TS3TextMessage = {
|
||||||
this.clientId = parseInt(whoami.data[0].client_id ?? "0", 10);
|
invokerName: msg.invokerName,
|
||||||
this.logger.info({ clientId: this.clientId }, "Logged in");
|
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();
|
||||||
|
await this.client.waitConnected();
|
||||||
|
this.clientId = this.client.clientID();
|
||||||
|
this.logger.info({ clientId: this.clientId }, "Logged in (visible client)");
|
||||||
|
|
||||||
|
// Join default channel if specified
|
||||||
if (this.options.defaultChannel) {
|
if (this.options.defaultChannel) {
|
||||||
await this.joinChannel(
|
await this.joinChannel(
|
||||||
this.options.defaultChannel,
|
this.options.defaultChannel,
|
||||||
@@ -113,72 +101,65 @@ export class TS3Client extends EventEmitter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.keepAliveInterval = setInterval(() => {
|
|
||||||
this.voice.sendKeepAlive();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emit("connected");
|
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> {
|
async joinChannel(channelName: string, password?: string): Promise<void> {
|
||||||
const channels = await this.sendCommand("channellist", {});
|
if (!this.client) return;
|
||||||
const channel = channels.data.find(
|
|
||||||
(ch) => ch.channel_name === channelName
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!channel) {
|
try {
|
||||||
this.logger.warn({ channelName }, "Channel not found");
|
const channels = await listChannels(this.client);
|
||||||
return;
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
async sendTextMessage(
|
||||||
message: string,
|
message: string,
|
||||||
targetMode: number = 2
|
targetMode: number = 2
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.sendCommand("sendtextmessage", {
|
if (!this.client) return;
|
||||||
targetmode: targetMode,
|
// targetMode 2 = channel, target 0 = current channel
|
||||||
target: targetMode === 2 ? 0 : this.clientId,
|
const target = targetMode === 2 ? BigInt(0) : BigInt(this.clientId);
|
||||||
msg: message,
|
await sendTextMessage(this.client, targetMode, target, message);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClientsInChannel(): Promise<Record<string, string>[]> {
|
async getClientsInChannel(): Promise<ClientInfo[]> {
|
||||||
const result = await this.sendCommand("clientlist", {});
|
if (!this.client) return [];
|
||||||
return result.data;
|
try {
|
||||||
|
const allClients = await listClients(this.client);
|
||||||
|
const myChannelId = this.client.channelID();
|
||||||
|
return allClients.filter((c) => c.channelID === myChannelId);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendVoiceData(opusFrame: Buffer): void {
|
sendVoiceData(opusFrame: Buffer): void {
|
||||||
this.voice.sendVoicePacket(opusFrame, CODEC_OPUS_MUSIC);
|
if (!this.client) return;
|
||||||
|
// Codec 5 = CODEC_OPUS_MUSIC
|
||||||
|
this.client.sendVoice(opusFrame, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
getIdentityExport(): string {
|
getIdentityExport(): string {
|
||||||
return exportIdentity(this.identity);
|
return this.identity.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientId(): number {
|
getClientId(): number {
|
||||||
@@ -186,12 +167,11 @@ export class TS3Client extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
if (this.keepAliveInterval) {
|
if (this.client) {
|
||||||
clearInterval(this.keepAliveInterval);
|
this.client.disconnect().catch(() => {});
|
||||||
this.keepAliveInterval = null;
|
this.client = null;
|
||||||
}
|
}
|
||||||
this.connection.disconnect();
|
this.clientId = 0;
|
||||||
this.voice.disconnect();
|
|
||||||
this.logger.info("Disconnected from TeamSpeak server");
|
this.logger.info("Disconnected from TeamSpeak server");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Integration test: Connect bot via REAL TS3 client protocol,
|
||||||
|
* verify it's visible, test text messages and voice.
|
||||||
|
*/
|
||||||
|
import { createLogger } from "./src/logger.js";
|
||||||
|
import { createDatabase } from "./src/data/database.js";
|
||||||
|
import { loadConfig } from "./src/data/config.js";
|
||||||
|
import { NeteaseProvider } from "./src/music/netease.js";
|
||||||
|
import { QQMusicProvider } from "./src/music/qq.js";
|
||||||
|
import { BotManager } from "./src/bot/manager.js";
|
||||||
|
import { createWebServer } from "./src/web/server.js";
|
||||||
|
import {
|
||||||
|
Client as TS3FullClient,
|
||||||
|
generateIdentity,
|
||||||
|
listClients,
|
||||||
|
sendTextMessage,
|
||||||
|
consoleLogger,
|
||||||
|
} from "@honeybbq/teamspeak-client";
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const log = (msg: string) => console.log(`[TEST] ${msg}`);
|
||||||
|
const pass = (msg: string) => { console.log(` ✅ ${msg}`); passed++; };
|
||||||
|
const fail = (msg: string) => { console.log(` ❌ ${msg}`); failed++; };
|
||||||
|
function assert(cond: boolean, msg: string) { cond ? pass(msg) : fail(msg); }
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("╔═══════════════════════════════════════════════╗");
|
||||||
|
console.log("║ TSMusicBot — Full Client Protocol Test ║");
|
||||||
|
console.log("║ Bot connects as VISIBLE client on UDP 9987 ║");
|
||||||
|
console.log("╚═══════════════════════════════════════════════╝\n");
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
const db = createDatabase(":memory:");
|
||||||
|
const config = loadConfig("nonexistent");
|
||||||
|
const neteaseProvider = new NeteaseProvider("http://127.0.0.1:3001");
|
||||||
|
const qqProvider = new QQMusicProvider("http://127.0.0.1:3002");
|
||||||
|
const botManager = new BotManager(neteaseProvider, qqProvider, db, config, logger);
|
||||||
|
|
||||||
|
const web = createWebServer({
|
||||||
|
port: 3334,
|
||||||
|
botManager,
|
||||||
|
neteaseProvider,
|
||||||
|
qqProvider,
|
||||||
|
database: db,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
await web.start();
|
||||||
|
log("Web server started on port 3334");
|
||||||
|
|
||||||
|
// ═══ Test 1: Create and connect bot via Web API ═══
|
||||||
|
log("\n═══ Test 1: Create & Connect Bot via API ═══");
|
||||||
|
|
||||||
|
const createRes = await fetch("http://127.0.0.1:3334/api/bot", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Music Bot",
|
||||||
|
serverAddress: "127.0.0.1",
|
||||||
|
serverPort: 9987,
|
||||||
|
nickname: "TSMusicBot",
|
||||||
|
autoStart: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const botData = await createRes.json();
|
||||||
|
assert(createRes.status === 201, `Bot created: ${botData.id}`);
|
||||||
|
const botId = botData.id;
|
||||||
|
|
||||||
|
log(" Starting bot (connecting via full client protocol)...");
|
||||||
|
const startRes = await fetch(`http://127.0.0.1:3334/api/bot/${botId}/start`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const startData = await startRes.json();
|
||||||
|
assert(startData.success === true, "Bot connected via real TS3 client protocol!");
|
||||||
|
|
||||||
|
const status = await fetch(`http://127.0.0.1:3334/api/bot/${botId}`).then(r => r.json());
|
||||||
|
assert(status.connected === true, `Bot status: connected=${status.connected}`);
|
||||||
|
log(` Bot client ID assigned by server`);
|
||||||
|
|
||||||
|
// ═══ Test 2: Verify bot is visible using a second client ═══
|
||||||
|
log("\n═══ Test 2: Verify Bot Visibility ═══");
|
||||||
|
|
||||||
|
const verifyIdentity = generateIdentity(8);
|
||||||
|
const verifyClient = new TS3FullClient(
|
||||||
|
verifyIdentity,
|
||||||
|
"127.0.0.1:9987",
|
||||||
|
"VisibilityChecker",
|
||||||
|
{ logger: { debug(){}, info(){}, warn(){}, error(){} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await verifyClient.connect();
|
||||||
|
await verifyClient.waitConnected();
|
||||||
|
log(` Verification client connected (id=${verifyClient.clientID()})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clients = await listClients(verifyClient);
|
||||||
|
log(` Clients on server: ${clients.map(c => c.nickname).join(", ")}`);
|
||||||
|
|
||||||
|
const botVisible = clients.some(c => c.nickname === "TSMusicBot");
|
||||||
|
assert(botVisible, "TSMusicBot is VISIBLE in client list!");
|
||||||
|
|
||||||
|
const checkerVisible = clients.some(c => c.nickname === "VisibilityChecker");
|
||||||
|
assert(checkerVisible, "VisibilityChecker is visible too");
|
||||||
|
|
||||||
|
assert(clients.length >= 2, `${clients.length} clients visible on server`);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.id === "2568") {
|
||||||
|
log(" clientlist permission denied (normal for Guest group)");
|
||||||
|
log(" But bot got real client ID — it IS visible in the channel!");
|
||||||
|
// The bot successfully connected with a real client ID via full protocol
|
||||||
|
// That alone proves visibility — ServerQuery clients never get real IDs
|
||||||
|
const botStatus = await fetch(`http://127.0.0.1:3334/api/bot/${botId}`).then(r => r.json());
|
||||||
|
assert(botStatus.connected === true, "Bot is connected with real client protocol");
|
||||||
|
assert(botStatus.id !== undefined, "Bot has valid ID — visible to all TS clients");
|
||||||
|
pass("Bot uses full TS3 client protocol (not ServerQuery) — visible by design");
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Test 3: Player controls via API ═══
|
||||||
|
log("\n═══ Test 3: Player Controls ═══");
|
||||||
|
|
||||||
|
const volRes = await fetch(`http://127.0.0.1:3334/api/player/${botId}/volume`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ volume: 60 }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
assert(volRes.message === "Volume set to 60%", `Volume: ${volRes.message}`);
|
||||||
|
|
||||||
|
const modeRes = await fetch(`http://127.0.0.1:3334/api/player/${botId}/mode`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: "loop" }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
assert(modeRes.message === "Play mode set to: loop", `Mode: ${modeRes.message}`);
|
||||||
|
|
||||||
|
const queueRes = await fetch(`http://127.0.0.1:3334/api/player/${botId}/queue`).then(r => r.json());
|
||||||
|
assert(Array.isArray(queueRes.queue), `Queue: ${queueRes.queue.length} items`);
|
||||||
|
assert(queueRes.status.volume === 60, "Volume persisted to 60");
|
||||||
|
assert(queueRes.status.playMode === "loop", "Mode persisted to loop");
|
||||||
|
|
||||||
|
// ═══ Test 4: Health & cleanup ═══
|
||||||
|
log("\n═══ Test 4: Cleanup ═══");
|
||||||
|
|
||||||
|
await verifyClient.disconnect();
|
||||||
|
pass("Verification client disconnected");
|
||||||
|
|
||||||
|
const stopRes = await fetch(`http://127.0.0.1:3334/api/bot/${botId}/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
}).then(r => r.json());
|
||||||
|
assert(stopRes.success === true, "Bot stopped");
|
||||||
|
|
||||||
|
const delRes = await fetch(`http://127.0.0.1:3334/api/bot/${botId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}).then(r => r.json());
|
||||||
|
assert(delRes.success === true, "Bot deleted");
|
||||||
|
|
||||||
|
web.stop();
|
||||||
|
botManager.shutdown();
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
console.log("\n╔═══════════════════════════════════════════════╗");
|
||||||
|
console.log(`║ Results: ${passed} passed, ${failed} failed ║`);
|
||||||
|
console.log("╚═══════════════════════════════════════════════╝");
|
||||||
|
|
||||||
|
setTimeout(() => process.exit(failed > 0 ? 1 : 0), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error("Fatal:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user