From 9e51ba6cd47af8dcca877ece03949bccd3a74396 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:03:47 +0000 Subject: [PATCH 01/17] Add dual TS3/TS6 protocol support with auto-detection The @honeybbq/teamspeak-client library already handles TS6 license block type 8 (Ts5Server) in its handshake, so voice connections work with both TS3 and TS6 servers. This commit adds the surrounding infrastructure: - protocol-detect.ts: Auto-detect server type by probing TS3 ServerQuery (port 10011) and TS6 HTTP Query (port 10080) in parallel - http-query.ts: TS6 HTTP Query client replacing the raw-TCP ServerQuery that TS6 servers no longer support (ports 10080/10443) - client.ts: Protocol-aware connection with auto-detection, TS6 HTTP Query setup, and forced protocol override option - manager.ts: Pass through serverProtocol and ts6ApiKey config options - connection.ts: Mark legacy TS3 ServerQuery as deprecated for TS6 https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- src/bot/manager.ts | 11 +- src/ts-protocol/client.ts | 63 ++++++++- src/ts-protocol/connection.ts | 9 +- src/ts-protocol/http-query.ts | 171 ++++++++++++++++++++++++ src/ts-protocol/protocol-detect.test.ts | 23 ++++ src/ts-protocol/protocol-detect.ts | 114 ++++++++++++++++ 6 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 src/ts-protocol/http-query.ts create mode 100644 src/ts-protocol/protocol-detect.test.ts create mode 100644 src/ts-protocol/protocol-detect.ts diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 4dab98a..9768e21 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -8,6 +8,8 @@ import type { BotDatabase } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; import type { Logger } from "../logger.js"; +import type { ServerProtocol } from "../ts-protocol/client.js"; + export interface CreateBotParams { name: string; serverAddress: string; @@ -17,6 +19,10 @@ export interface CreateBotParams { defaultChannel?: string; channelPassword?: string; autoStart?: boolean; + /** Force TS3 or TS6 protocol; omit or "unknown" for auto-detect. */ + serverProtocol?: ServerProtocol; + /** API key for TS6 HTTP Query (port 10080/10443). */ + ts6ApiKey?: string; } export class BotManager { @@ -57,6 +63,8 @@ export class BotManager { nickname: params.nickname, defaultChannel: params.defaultChannel, channelPassword: params.channelPassword, + serverProtocol: params.serverProtocol, + ts6ApiKey: params.ts6ApiKey, }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, @@ -148,10 +156,11 @@ export class BotManager { tsOptions: { host: saved.serverAddress, port: saved.serverPort, - queryPort: 10011, + queryPort: 10011, // Will be overridden by auto-detection for TS6 nickname: saved.nickname, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, + // Protocol will be auto-detected on connect }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 63e5c04..3b771d8 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -12,18 +12,29 @@ import { type ClientInfo, } from "@honeybbq/teamspeak-client"; import type { Logger } from "../logger.js"; +import { + detectServerProtocol, + type ServerProtocol, + type ProtocolDetectResult, +} 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 interface TS3ClientOptions { host: string; port: number; // Voice/virtual server port (default 9987) - queryPort: number; // ServerQuery port (default 10011) — unused now, kept for compat + 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 { @@ -40,6 +51,8 @@ export class TS3Client extends EventEmitter { private clientId = 0; private logger: Logger; private disconnecting = false; + private detectedProtocol: ServerProtocol = "unknown"; + private httpQuery: TS6HttpQuery | null = null; constructor(private options: TS3ClientOptions, logger: Logger) { super(); @@ -52,9 +65,50 @@ export class TS3Client extends EventEmitter { } } + /** 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 { const addr = `${this.options.host}:${this.options.port}`; - this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)"); + + // 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); + this.detectedProtocol = detection.protocol; + 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, + }); + } + + 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; @@ -115,7 +169,10 @@ export class TS3Client extends EventEmitter { await this.client.waitConnected(); this.clientId = this.client.clientID(); this.voiceFramesSent = 0; - this.logger.info({ clientId: this.clientId }, "Logged in (visible client)"); + 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) { diff --git a/src/ts-protocol/connection.ts b/src/ts-protocol/connection.ts index 135453c..065c66c 100644 --- a/src/ts-protocol/connection.ts +++ b/src/ts-protocol/connection.ts @@ -1,10 +1,17 @@ +/** + * TS3 raw-TCP ServerQuery connection (port 10011). + * + * @deprecated This module only works with TS3 servers. TS6 servers replaced + * the raw-TCP ServerQuery with HTTP/HTTPS (port 10080/10443) and SSH (10022). + * For TS6 servers, use {@link ../http-query.js TS6HttpQuery} instead. + */ 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 + port: number; // ServerQuery port: 10011 (TS3) — not available on TS6 } export interface CommandResult { diff --git a/src/ts-protocol/http-query.ts b/src/ts-protocol/http-query.ts new file mode 100644 index 0000000..06c2946 --- /dev/null +++ b/src/ts-protocol/http-query.ts @@ -0,0 +1,171 @@ +import http from "node:http"; +import https from "node:https"; +import { EventEmitter } from "node:events"; + +export interface HttpQueryOptions { + host: string; + port: number; // 10080 (HTTP) or 10443 (HTTPS) + useTls?: boolean; + apiKey?: string; + timeoutMs?: number; +} + +export interface HttpQueryResult { + status: number; + body: unknown; +} + +/** + * TS6 HTTP Query client. + * + * TeamSpeak 6 Server replaces the TS3 raw-TCP ServerQuery (port 10011) + * with an HTTP/HTTPS API on ports 10080/10443. + * + * Common endpoints (TS6 HTTP Query): + * GET / → server info / health check + * POST /api-key → create API key + * GET /1/serverlist → list virtual servers + * GET /1/clientlist?sid={sid} → list clients + * POST /1/sendtextmessage → send text message + * POST /1/clientmove → move a client + * GET /1/channellist?sid={sid} → list channels + * POST /1/clientupdate → update client properties + */ +export class TS6HttpQuery extends EventEmitter { + private options: Required; + + constructor(options: HttpQueryOptions) { + super(); + this.options = { + host: options.host, + port: options.port, + useTls: options.useTls ?? options.port === 10443, + apiKey: options.apiKey ?? "", + timeoutMs: options.timeoutMs ?? 5000, + }; + } + + async request( + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + body?: Record, + ): Promise { + const { host, port, useTls, apiKey, timeoutMs } = this.options; + const transport = useTls ? https : http; + + const headers: Record = { + Accept: "application/json", + }; + if (apiKey) { + headers["x-api-key"] = apiKey; + } + + let bodyStr: string | undefined; + if (body) { + bodyStr = JSON.stringify(body); + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = String(Buffer.byteLength(bodyStr)); + } + + return new Promise((resolve, reject) => { + const req = transport.request( + { + hostname: host, + port, + path, + method, + timeout: timeoutMs, + headers, + rejectUnauthorized: false, // self-signed certs common on self-hosted + }, + (res) => { + let data = ""; + res.setEncoding("utf-8"); + res.on("data", (chunk: string) => (data += chunk)); + res.on("end", () => { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + parsed = data; + } + resolve({ + status: res.statusCode ?? 0, + body: parsed, + }); + }); + }, + ); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("TS6 HTTP Query timeout")); + }); + + if (bodyStr) { + req.write(bodyStr); + } + req.end(); + }); + } + + /** Check if the TS6 HTTP Query is reachable */ + async healthCheck(): Promise { + try { + const result = await this.request("GET", "/"); + return result.status >= 200 && result.status < 500; + } catch { + return false; + } + } + + /** List virtual servers */ + async serverList(): Promise { + return this.request("GET", "/1/serverlist"); + } + + /** List clients on a virtual server */ + async clientList(sid = 1): Promise { + return this.request("GET", `/1/clientlist?sid=${sid}`); + } + + /** List channels on a virtual server */ + async channelList(sid = 1): Promise { + return this.request("GET", `/1/channellist?sid=${sid}`); + } + + /** Send a text message */ + async sendTextMessage( + targetMode: number, + target: number, + msg: string, + sid = 1, + ): Promise { + return this.request("POST", `/1/sendtextmessage?sid=${sid}`, { + targetmode: targetMode, + target, + msg, + }); + } + + /** Update client properties (e.g., description) */ + async clientUpdate( + properties: Record, + sid = 1, + ): Promise { + return this.request("POST", `/1/clientupdate?sid=${sid}`, properties); + } + + /** Move a client to a channel */ + async clientMove( + clid: number, + cid: number, + cpw?: string, + sid = 1, + ): Promise { + const body: Record = { clid, cid }; + if (cpw) body.cpw = cpw; + return this.request("POST", `/1/clientmove?sid=${sid}`, body); + } +} diff --git a/src/ts-protocol/protocol-detect.test.ts b/src/ts-protocol/protocol-detect.test.ts new file mode 100644 index 0000000..46fdb86 --- /dev/null +++ b/src/ts-protocol/protocol-detect.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { + detectServerProtocol, + type ServerProtocol, + type ProtocolDetectResult, +} from "./protocol-detect.js"; + +describe("protocol-detect", () => { + it("returns unknown for unreachable hosts", async () => { + const result = await detectServerProtocol("192.0.2.1", 9987, 1000); + expect(result.protocol).toBe("unknown"); + expect(result.queryPort).toBeNull(); + expect(result.voicePort).toBe(9987); + }); + + it("result shape matches ProtocolDetectResult interface", async () => { + const result = await detectServerProtocol("127.0.0.1", 9987, 500); + expect(result).toHaveProperty("protocol"); + expect(result).toHaveProperty("queryPort"); + expect(result).toHaveProperty("voicePort"); + expect(["ts3", "ts6", "unknown"]).toContain(result.protocol); + }); +}); diff --git a/src/ts-protocol/protocol-detect.ts b/src/ts-protocol/protocol-detect.ts new file mode 100644 index 0000000..53269c8 --- /dev/null +++ b/src/ts-protocol/protocol-detect.ts @@ -0,0 +1,114 @@ +import net from "node:net"; +import http from "node:http"; + +export type ServerProtocol = "ts3" | "ts6" | "unknown"; + +export interface ProtocolDetectResult { + protocol: ServerProtocol; + /** The query port that responded (10011 for TS3, 10080 for TS6 HTTP) */ + queryPort: number | null; + /** Whether the voice port (UDP 9987) is the same for both */ + voicePort: number; +} + +/** + * Probe a TeamSpeak server to determine if it's running TS3 or TS6. + * + * Detection strategy: + * 1. Try TCP connect to port 10011 (TS3 ServerQuery) — if banner starts with "TS3", it's TS3. + * 2. Try HTTP GET to port 10080 (TS6 HTTP Query) — if we get a valid HTTP response, it's TS6. + * 3. If neither responds, return "unknown" (voice-only connection may still work). + */ +export async function detectServerProtocol( + host: string, + voicePort = 9987, + timeoutMs = 3000, +): Promise { + const [ts3, ts6] = await Promise.allSettled([ + probeTS3Query(host, 10011, timeoutMs), + probeTS6HttpQuery(host, 10080, timeoutMs), + ]); + + if (ts3.status === "fulfilled" && ts3.value) { + return { protocol: "ts3", queryPort: 10011, voicePort }; + } + if (ts6.status === "fulfilled" && ts6.value) { + return { protocol: "ts6", queryPort: 10080, voicePort }; + } + + return { protocol: "unknown", queryPort: null, voicePort }; +} + +/** + * Probe TS3 ServerQuery by connecting to raw TCP and checking for "TS3" banner. + */ +function probeTS3Query(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port, timeout: timeoutMs }); + let banner = ""; + + const cleanup = () => { + socket.removeAllListeners(); + socket.destroy(); + }; + + socket.setTimeout(timeoutMs); + + socket.on("data", (data: Buffer) => { + banner += data.toString("utf-8"); + if (banner.includes("TS3")) { + cleanup(); + resolve(true); + } + }); + + socket.on("connect", () => { + // Wait briefly for banner + setTimeout(() => { + cleanup(); + resolve(banner.includes("TS3")); + }, 500); + }); + + socket.on("error", () => { + cleanup(); + resolve(false); + }); + + socket.on("timeout", () => { + cleanup(); + resolve(false); + }); + }); +} + +/** + * Probe TS6 HTTP Query by sending GET / and checking for a valid response. + */ +function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: host, + port, + path: "/", + method: "GET", + timeout: timeoutMs, + headers: { Accept: "application/json" }, + }, + (res) => { + // TS6 HTTP Query returns some response (even 401/403 is valid — it means the service exists) + res.resume(); + resolve(res.statusCode !== undefined); + }, + ); + + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} From e5fd35da32ed22d157500557c13ab1279737f637 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:04:17 +0000 Subject: [PATCH 02/17] Update package-lock.json after dependency install https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- package-lock.json | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index e1d8529..70f217b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -768,9 +768,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -788,9 +785,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -808,9 +802,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -828,9 +819,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -848,9 +836,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -868,9 +853,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3636,9 +3618,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3660,9 +3639,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3684,9 +3660,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3708,9 +3681,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ From 9e45193e2c49628fc507dff27db84b12f123f188 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:34:37 +0000 Subject: [PATCH 03/17] Fix corner cases in protocol detection and connection cleanup - Fix double-resolve race in probeTS3Query (data event vs connect timer) by guarding with a resolved flag - Fix double-resolve in probeTS6HttpQuery similarly - Support custom query ports in detectServerProtocol via DetectOptions - Clean up httpQuery and detectedProtocol on disconnect() - Add res.on("error") handler in HTTP Query client to avoid unhandled errors - Improve logging: warn with actionable message when protocol is unknown https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- src/ts-protocol/client.ts | 25 ++++++++--- src/ts-protocol/http-query.ts | 1 + src/ts-protocol/protocol-detect.ts | 68 ++++++++++++++++++------------ 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 3b771d8..5bded0d 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -87,12 +87,25 @@ export class TS3Client extends EventEmitter { ); } else { this.logger.info({ addr }, "Detecting server protocol (TS3/TS6)..."); - const detection = await detectServerProtocol(this.options.host, this.options.port); - this.detectedProtocol = detection.protocol; - this.logger.info( - { addr, protocol: this.detectedProtocol, queryPort: detection.queryPort }, - `Server protocol detected: ${this.detectedProtocol.toUpperCase()}`, + 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 @@ -270,6 +283,8 @@ export class TS3Client extends EventEmitter { }); } this.clientId = 0; + this.httpQuery = null; + this.detectedProtocol = "unknown"; this.logger.info("Disconnected from TeamSpeak server"); } } diff --git a/src/ts-protocol/http-query.ts b/src/ts-protocol/http-query.ts index 06c2946..d483265 100644 --- a/src/ts-protocol/http-query.ts +++ b/src/ts-protocol/http-query.ts @@ -82,6 +82,7 @@ export class TS6HttpQuery extends EventEmitter { let data = ""; res.setEncoding("utf-8"); res.on("data", (chunk: string) => (data += chunk)); + res.on("error", reject); res.on("end", () => { let parsed: unknown; try { diff --git a/src/ts-protocol/protocol-detect.ts b/src/ts-protocol/protocol-detect.ts index 53269c8..7ef9475 100644 --- a/src/ts-protocol/protocol-detect.ts +++ b/src/ts-protocol/protocol-detect.ts @@ -11,6 +11,13 @@ export interface ProtocolDetectResult { voicePort: number; } +export interface DetectOptions { + /** TS3 ServerQuery probe port (default: 10011) */ + ts3QueryPort?: number; + /** TS6 HTTP Query probe port (default: 10080) */ + ts6HttpPort?: number; +} + /** * Probe a TeamSpeak server to determine if it's running TS3 or TS6. * @@ -23,17 +30,22 @@ export async function detectServerProtocol( host: string, voicePort = 9987, timeoutMs = 3000, + options?: DetectOptions, ): Promise { + const ts3Port = options?.ts3QueryPort ?? 10011; + const ts6Port = options?.ts6HttpPort ?? 10080; + const [ts3, ts6] = await Promise.allSettled([ - probeTS3Query(host, 10011, timeoutMs), - probeTS6HttpQuery(host, 10080, timeoutMs), + probeTS3Query(host, ts3Port, timeoutMs), + probeTS6HttpQuery(host, ts6Port, timeoutMs), ]); + // Prefer TS3 if both somehow respond (shouldn't happen in practice) if (ts3.status === "fulfilled" && ts3.value) { - return { protocol: "ts3", queryPort: 10011, voicePort }; + return { protocol: "ts3", queryPort: ts3Port, voicePort }; } if (ts6.status === "fulfilled" && ts6.value) { - return { protocol: "ts6", queryPort: 10080, voicePort }; + return { protocol: "ts6", queryPort: ts6Port, voicePort }; } return { protocol: "unknown", queryPort: null, voicePort }; @@ -44,49 +56,50 @@ export async function detectServerProtocol( */ function probeTS3Query(host: string, port: number, timeoutMs: number): Promise { return new Promise((resolve) => { - const socket = net.createConnection({ host, port, timeout: timeoutMs }); - let banner = ""; - - const cleanup = () => { + let resolved = false; + const done = (value: boolean) => { + if (resolved) return; + resolved = true; socket.removeAllListeners(); socket.destroy(); + resolve(value); }; + const socket = net.createConnection({ host, port, timeout: timeoutMs }); + let banner = ""; + socket.setTimeout(timeoutMs); socket.on("data", (data: Buffer) => { banner += data.toString("utf-8"); if (banner.includes("TS3")) { - cleanup(); - resolve(true); + done(true); } }); socket.on("connect", () => { - // Wait briefly for banner - setTimeout(() => { - cleanup(); - resolve(banner.includes("TS3")); - }, 500); + // Wait briefly for banner after TCP connect + setTimeout(() => done(banner.includes("TS3")), 500); }); - socket.on("error", () => { - cleanup(); - resolve(false); - }); - - socket.on("timeout", () => { - cleanup(); - resolve(false); - }); + socket.on("error", () => done(false)); + socket.on("timeout", () => done(false)); }); } /** * Probe TS6 HTTP Query by sending GET / and checking for a valid response. + * Any HTTP status (including 401/403) confirms the TS6 HTTP Query exists. */ function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promise { return new Promise((resolve) => { + let resolved = false; + const done = (value: boolean) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + const req = http.request( { hostname: host, @@ -97,16 +110,15 @@ function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promi headers: { Accept: "application/json" }, }, (res) => { - // TS6 HTTP Query returns some response (even 401/403 is valid — it means the service exists) res.resume(); - resolve(res.statusCode !== undefined); + done(res.statusCode !== undefined); }, ); - req.on("error", () => resolve(false)); + req.on("error", () => done(false)); req.on("timeout", () => { req.destroy(); - resolve(false); + done(false); }); req.end(); From 6184f38330098b90799a6c7db676fdbf27a48f0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:38:49 +0000 Subject: [PATCH 04/17] Fix 7 corner cases found in second review pass Critical: - Persist serverProtocol/ts6ApiKey in database schema so TS6 config survives restarts (added columns + manager save/load) Medium: - Clear udpErrorTimer on disconnect to prevent memory leak and stale log messages from firing after teardown - Guard against double connect() by disconnecting old client first - Add settled guard in TS6HttpQuery.request() to prevent double reject when both res error and req error fire - Add res.on("error") handler to TS6HttpQuery response stream Low: - Cap probeTS3Query banner buffer at 256 bytes to prevent memory abuse from non-TS3 services sending large data on port 10011 - Remove unnecessary EventEmitter inheritance from TS6HttpQuery - Update database test fixtures with new serverProtocol/ts6ApiKey fields https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- src/bot/manager.ts | 10 ++++++++-- src/data/database.test.ts | 4 ++++ src/data/database.ts | 16 ++++++++++++---- src/ts-protocol/client.ts | 18 +++++++++++++++--- src/ts-protocol/http-query.ts | 19 +++++++++++++------ src/ts-protocol/protocol-detect.ts | 2 ++ 6 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 9768e21..02f43c3 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -85,6 +85,8 @@ export class BotManager { defaultChannel: params.defaultChannel ?? "", channelPassword: params.channelPassword ?? "", autoStart: params.autoStart ?? false, + serverProtocol: params.serverProtocol ?? "", + ts6ApiKey: params.ts6ApiKey ?? "", }); this.logger.info({ botId: id, name: params.name }, "Bot instance created"); @@ -114,6 +116,8 @@ export class BotManager { nickname: params.nickname ?? existing.nickname, defaultChannel: params.defaultChannel ?? existing.defaultChannel, channelPassword: params.channelPassword ?? existing.channelPassword, + serverProtocol: params.serverProtocol ?? existing.serverProtocol, + ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey, }); // Update in-memory name immediately (other fields need reconnect) const bot = this.bots.get(id); @@ -150,17 +154,19 @@ export class BotManager { async loadSavedBots(): Promise { const savedInstances = this.database.getBotInstances(); for (const saved of savedInstances) { + const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined; const bot = new BotInstance({ id: saved.id, name: saved.name, tsOptions: { host: saved.serverAddress, port: saved.serverPort, - queryPort: 10011, // Will be overridden by auto-detection for TS6 + queryPort: proto === "ts6" ? 10080 : 10011, nickname: saved.nickname, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, - // Protocol will be auto-detected on connect + serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, + ts6ApiKey: saved.ts6ApiKey || undefined, }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 51da01a..8807797 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -60,6 +60,8 @@ describe("database", () => { defaultChannel: "Music", channelPassword: "", autoStart: true, + serverProtocol: "", + ts6ApiKey: "", }; botDb.saveBotInstance(instance); @@ -86,6 +88,8 @@ describe("database", () => { defaultChannel: "Music", channelPassword: "", autoStart: false, + serverProtocol: "", + ts6ApiKey: "", }); expect(botDb.deleteBotInstance("bot1")).toBe(true); diff --git a/src/data/database.ts b/src/data/database.ts index 8f043ba..5a3ad84 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -24,6 +24,10 @@ export interface BotInstance { defaultChannel: string; channelPassword: string; autoStart: boolean; + /** "ts3" | "ts6" | "" (empty = auto-detect) */ + serverProtocol: string; + /** API key for TS6 HTTP Query */ + ts6ApiKey: string; } export interface BotDatabase { @@ -58,7 +62,9 @@ function initTables(db: Database.Database): void { nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL, channelPassword TEXT NOT NULL, - autoStart INTEGER NOT NULL DEFAULT 0 + autoStart INTEGER NOT NULL DEFAULT 0, + serverProtocol TEXT NOT NULL DEFAULT '', + ts6ApiKey TEXT NOT NULL DEFAULT '' ); `); } @@ -78,8 +84,8 @@ export function createDatabase(dbPath: string): BotDatabase { `); const upsertInstance = db.prepare(` - INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart) - VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart) + INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey) + VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey) ON CONFLICT(id) DO UPDATE SET name = excluded.name, serverAddress = excluded.serverAddress, @@ -87,7 +93,9 @@ export function createDatabase(dbPath: string): BotDatabase { nickname = excluded.nickname, defaultChannel = excluded.defaultChannel, channelPassword = excluded.channelPassword, - autoStart = excluded.autoStart + autoStart = excluded.autoStart, + serverProtocol = excluded.serverProtocol, + ts6ApiKey = excluded.ts6ApiKey `); const selectInstances = db.prepare(`SELECT * FROM bot_instances`); diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 5bded0d..bf574e6 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -53,6 +53,7 @@ export class TS3Client extends EventEmitter { private disconnecting = false; private detectedProtocol: ServerProtocol = "unknown"; private httpQuery: TS6HttpQuery | null = null; + private udpErrorTimer: ReturnType | null = null; constructor(private options: TS3ClientOptions, logger: Logger) { super(); @@ -118,6 +119,14 @@ export class TS3Client extends EventEmitter { }); } + // Guard against calling connect() while already connected + if (this.client) { + this.logger.warn("connect() called while already connected, disconnecting first"); + this.disconnect(); + // 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)", @@ -125,19 +134,18 @@ export class TS3Client extends EventEmitter { // Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks) let udpErrorCount = 0; - let udpErrorTimer: ReturnType | null = null; 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 - udpErrorTimer = setTimeout(() => { + this.udpErrorTimer = setTimeout(() => { if (udpErrorCount > 1) { this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`); } udpErrorCount = 0; - udpErrorTimer = null; + this.udpErrorTimer = null; }, 2000); } return; @@ -285,6 +293,10 @@ export class TS3Client extends EventEmitter { 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"); } } diff --git a/src/ts-protocol/http-query.ts b/src/ts-protocol/http-query.ts index d483265..a0af0cd 100644 --- a/src/ts-protocol/http-query.ts +++ b/src/ts-protocol/http-query.ts @@ -1,6 +1,5 @@ import http from "node:http"; import https from "node:https"; -import { EventEmitter } from "node:events"; export interface HttpQueryOptions { host: string; @@ -31,11 +30,10 @@ export interface HttpQueryResult { * GET /1/channellist?sid={sid} → list channels * POST /1/clientupdate → update client properties */ -export class TS6HttpQuery extends EventEmitter { +export class TS6HttpQuery { private options: Required; constructor(options: HttpQueryOptions) { - super(); this.options = { host: options.host, port: options.port, @@ -68,6 +66,13 @@ export class TS6HttpQuery extends EventEmitter { } return new Promise((resolve, reject) => { + let settled = false; + const fail = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; + const req = transport.request( { hostname: host, @@ -82,8 +87,10 @@ export class TS6HttpQuery extends EventEmitter { let data = ""; res.setEncoding("utf-8"); res.on("data", (chunk: string) => (data += chunk)); - res.on("error", reject); + res.on("error", fail); res.on("end", () => { + if (settled) return; + settled = true; let parsed: unknown; try { parsed = JSON.parse(data); @@ -98,10 +105,10 @@ export class TS6HttpQuery extends EventEmitter { }, ); - req.on("error", reject); + req.on("error", fail); req.on("timeout", () => { req.destroy(); - reject(new Error("TS6 HTTP Query timeout")); + fail(new Error("TS6 HTTP Query timeout")); }); if (bodyStr) { diff --git a/src/ts-protocol/protocol-detect.ts b/src/ts-protocol/protocol-detect.ts index 7ef9475..b58b1d5 100644 --- a/src/ts-protocol/protocol-detect.ts +++ b/src/ts-protocol/protocol-detect.ts @@ -67,11 +67,13 @@ function probeTS3Query(host: string, port: number, timeoutMs: number): Promise { banner += data.toString("utf-8"); + if (banner.length > MAX_BANNER) banner = banner.slice(0, MAX_BANNER); if (banner.includes("TS3")) { done(true); } From 1379a062babefb03f33ce54eda1ff561c98cdb2e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:46:31 +0000 Subject: [PATCH 05/17] Add TS6 clientinit version middleware and fix playNext retry bug clientinit compatibility: - Analyzed @honeybbq/teamspeak-client's clientinit: it already sends a clean 14-field payload without problematic fields (no badges, integrations, security_hash, etc.) - The key difference vs NeteaseTSBot is client_version: our library sends 3.5.3, NeteaseTSBot sends 3.6.2. TS6 servers may reject older versions. - Add ts6-compat.ts with CommandMiddleware that patches clientinit to use version 3.6.2 + matching ECDSA signature when connecting to detected TS6 servers - Middleware is automatically applied when detectedProtocol === "ts6" Pre-existing bug fix: - Fix playNext() in instance.ts where successful retry still fell through to player.stop(), killing the just-started playback https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- src/bot/instance.ts | 11 ++-- src/ts-protocol/client.ts | 11 +++- src/ts-protocol/ts6-compat.test.ts | 45 +++++++++++++++++ src/ts-protocol/ts6-compat.ts | 80 ++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/ts-protocol/ts6-compat.test.ts create mode 100644 src/ts-protocol/ts6-compat.ts diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 0058824..b436a4e 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -494,14 +494,19 @@ export class BotInstance extends EventEmitter { this.voteSkipUsers.clear(); const next = this.queue.next(); if (next) { - const ok = await this.resolveAndPlay(next); - if (!ok) { + let started = await this.resolveAndPlay(next); + if (!started) { // Skip to next if URL resolve fails (up to 3 retries) for (let i = 0; i < 3; i++) { const retry = this.queue.next(); if (!retry) break; - if (await this.resolveAndPlay(retry)) break; + if (await this.resolveAndPlay(retry)) { + started = true; + break; + } } + } + if (!started) { this.player.stop(); } } else { diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index bf574e6..acdc859 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -10,14 +10,15 @@ import { type Identity, type TextMessage, type ClientInfo, + type CommandMiddleware, } from "@honeybbq/teamspeak-client"; import type { Logger } from "../logger.js"; import { detectServerProtocol, type ServerProtocol, - type ProtocolDetectResult, } from "./protocol-detect.js"; import { TS6HttpQuery } from "./http-query.js"; +import { ts6VersionMiddleware } from "./ts6-compat.js"; export { CODEC_OPUS_MUSIC } from "./voice.js"; export type { ServerProtocol } from "./protocol-detect.js"; @@ -153,6 +154,13 @@ export class TS3Client extends EventEmitter { this.logger.warn(msg); }; + // Apply TS6 version middleware if connecting to a TS6 server + const commandMiddleware: CommandMiddleware[] = []; + if (this.detectedProtocol === "ts6") { + commandMiddleware.push(ts6VersionMiddleware("3.6.2")); + this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); + } + this.client = new TS3FullClient(this.identity, addr, this.options.nickname, { logger: { debug: (msg) => this.logger.debug(msg), @@ -160,6 +168,7 @@ export class TS3Client extends EventEmitter { warn: throttledWarn, error: (msg) => this.logger.error(msg), }, + commandMiddleware, }); this.client.on("textMessage", (msg: TextMessage) => { diff --git a/src/ts-protocol/ts6-compat.test.ts b/src/ts-protocol/ts6-compat.test.ts new file mode 100644 index 0000000..fd61412 --- /dev/null +++ b/src/ts-protocol/ts6-compat.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { ts6VersionMiddleware } from "./ts6-compat.js"; + +describe("ts6-compat", () => { + it("patches clientinit version to 3.6.2", async () => { + const middleware = ts6VersionMiddleware("3.6.2"); + let captured = ""; + const next = async (cmd: string) => { + captured = cmd; + }; + const handler = middleware(next); + + const original = + "clientinit client_nickname=Bot client_version=3.5.3\\s[Build:\\s1587971024] " + + "client_platform=Windows client_version_sign=Kvmj7qX6wJCPI5GVT71samfmhz/bvs7M+OTXWB/JWxdQbxDe17xda7dzUWLX7pjvdJTqZmbse1HBmTxThPKvAg== " + + "client_key_offset=42 hwid=abc123"; + + await handler(original); + + expect(captured).toContain("client_version=3.6.2\\s[Build:\\s1695203293]"); + expect(captured).toContain( + "client_version_sign=4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==" + ); + // Other fields preserved + expect(captured).toContain("client_nickname=Bot"); + expect(captured).toContain("client_key_offset=42"); + expect(captured).toContain("hwid=abc123"); + }); + + it("does not modify non-clientinit commands", async () => { + const middleware = ts6VersionMiddleware("3.6.2"); + let captured = ""; + const handler = middleware(async (cmd) => { + captured = cmd; + }); + + const original = "sendtextmessage targetmode=2 msg=hello"; + await handler(original); + expect(captured).toBe(original); + }); + + it("throws for unknown target version", () => { + expect(() => ts6VersionMiddleware("9.9.9")).toThrow("Unknown version"); + }); +}); diff --git a/src/ts-protocol/ts6-compat.ts b/src/ts-protocol/ts6-compat.ts new file mode 100644 index 0000000..5d24f09 --- /dev/null +++ b/src/ts-protocol/ts6-compat.ts @@ -0,0 +1,80 @@ +import type { CommandMiddleware } from "@honeybbq/teamspeak-client"; + +/** + * Known-compatible client versions with their signatures. + * + * The version_sign is a P-256 ECDSA signature over the version string, + * verified by TeamSpeak servers. Only officially signed versions work. + */ +const KNOWN_VERSIONS: Record< + string, + { version: string; platform: string; sign: string } +> = { + // TS3 client 3.5.3 (default in @honeybbq/teamspeak-client) + "3.5.3": { + version: "3.5.3 [Build: 1587971024]", + platform: "Windows", + sign: "Kvmj7qX6wJCPI5GVT71samfmhz/bvs7M+OTXWB/JWxdQbxDe17xda7dzUWLX7pjvdJTqZmbse1HBmTxThPKvAg==", + }, + // TS3 client 3.6.2 (used by NeteaseTSBot, known to work with TS6 servers) + "3.6.2": { + version: "3.6.2 [Build: 1695203293]", + platform: "Windows", + sign: "4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==", + }, +}; + +/** + * Command middleware that upgrades the client_version in `clientinit` to + * a newer version known to work with TS6 servers. + * + * TS6 servers may reject connections from older client versions. The default + * library sends version 3.5.3; this middleware patches it to 3.6.2 which is + * the version NeteaseTSBot uses successfully with TS6. + * + * The version_sign must match the version string — it's a server-verified + * ECDSA signature, so we can only use known-valid pairs. + */ +export function ts6VersionMiddleware( + targetVersion = "3.6.2", +): CommandMiddleware { + const vInfo = KNOWN_VERSIONS[targetVersion]; + if (!vInfo) { + throw new Error(`Unknown version ${targetVersion}. Known: ${Object.keys(KNOWN_VERSIONS).join(", ")}`); + } + + return (next) => async (cmd) => { + // Only intercept clientinit commands + if (cmd.startsWith("clientinit ")) { + cmd = replaceField(cmd, "client_version", vInfo.version); + cmd = replaceField(cmd, "client_platform", vInfo.platform); + cmd = replaceField(cmd, "client_version_sign", vInfo.sign); + } + return next(cmd); + }; +} + +/** + * Replace an escaped field value in a TS3 command string. + * Fields are in the format: key=escaped_value (space-separated) + */ +function replaceField(cmd: string, key: string, value: string): string { + // Escape the value using TS3 escaping rules + const escaped = escapeTS3(value); + const regex = new RegExp(`${key}=\\S*`); + if (regex.test(cmd)) { + return cmd.replace(regex, `${key}=${escaped}`); + } + return cmd; +} + +function escapeTS3(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\//g, "\\/") + .replace(/ /g, "\\s") + .replace(/\|/g, "\\p") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} From 185f592ce9253cbff5f6b0a22eb8bf6ecbc22178 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:01:01 +0000 Subject: [PATCH 06/17] Update README for dev branch with TS3/TS6 dual protocol changelog - Add dev branch notice and changelog section at the top - Add TS3/TS6 badges - Document new protocol modules (protocol-detect, http-query, ts6-compat) - List all bug fixes in dev branch - Update architecture diagram with new ts-protocol files - Add TS6 Server FAQ entry - Credit NeteaseTSBot for TS6 protocol reference https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7b0eb60..227b6bb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@

- TSMusicBot + TSMusicBot

TSMusicBot

- TeamSpeak 3 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板 + TeamSpeak 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板

@@ -16,14 +16,51 @@ + +

+> **`dev` 分支** — 活跃开发分支,包含最新特性和 bug 修复。 +> 如需稳定版本,请切换到 [`main`](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/tree/main) 分支。 + +--- + +## dev 分支最新变更 + +### TS3/TS6 双协议支持 + +本分支新增了 TeamSpeak 6 Server 的完整支持。TS6 Server([teamspeak/teamspeak6-server](https://github.com/teamspeak/teamspeak6-server))是 TeamSpeak 全新的自托管服务器,与 TS3 Server 存在协议层差异。 + +**新增模块:** + +| 文件 | 说明 | +|------|------| +| `src/ts-protocol/protocol-detect.ts` | 服务器协议自动检测(并行探测 TS3 port 10011 + TS6 port 10080) | +| `src/ts-protocol/http-query.ts` | TS6 HTTP Query 客户端(替代 TS3 已废弃的 raw TCP ServerQuery) | +| `src/ts-protocol/ts6-compat.ts` | TS6 兼容中间件(升级 clientinit 版本号 + 签名) | + +**关键改动:** + +- **自动协议检测** — 连接时自动判断目标服务器是 TS3 还是 TS6,无需手动配置 +- **TS6 HTTP Query** — TS6 用 HTTP API(10080/10443)替代了 TS3 的 raw TCP ServerQuery(10011),已适配 +- **clientinit 版本升级** — 通过 CommandMiddleware 将客户端版本从 3.5.3 升级到 3.6.2(含匹配 ECDSA 签名),避免 TS6 服务器拒绝连接 +- **License block type 8** — `@honeybbq/teamspeak-client` 已内置 `Ts5Server` 类型支持,握手兼容 TS6 +- **数据库持久化** — `serverProtocol` 和 `ts6ApiKey` 配置持久化到 SQLite,重启不丢失 + +**Bug 修复:** + +- 修复 `playNext()` 重试逻辑中成功重试后仍执行 `player.stop()` 的 bug +- 修复协议探测中 `probeTS3Query` 双重 resolve 竞态条件 +- 修复 `disconnect()` 未清理 `httpQuery` / `udpErrorTimer` 的内存泄漏 +- 修复 `TS6HttpQuery.request()` 双重 reject 问题 +- 添加重复 `connect()` 调用的保护(先断开旧连接) + --- ## 功能特性 - **三平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩,统一搜索,结果标注来源 -- **真实 TS3 客户端协议** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),兼容 TS3/TS5/TS6 服务器 +- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环 @@ -207,8 +244,11 @@ tsmusicbot/ │ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取) │ │ ├── auth.ts # Cookie 持久化存储 │ │ └── api-server.ts # 嵌入式 API 服务(自动启动) -│ ├── ts-protocol/ # TS3 客户端协议 -│ │ └── client.ts # 完整客户端(ECDH + AES-EAX 加密协议) +│ ├── ts-protocol/ # TeamSpeak 客户端协议(TS3/TS6 双协议) +│ │ ├── client.ts # 完整客户端(ECDH + AES-EAX 加密协议) +│ │ ├── protocol-detect.ts # 服务器协议自动检测(TS3 vs TS6) +│ │ ├── http-query.ts # TS6 HTTP Query 客户端(替代 TS3 ServerQuery) +│ │ └── ts6-compat.ts # TS6 兼容中间件(版本升级 + 签名) │ ├── web/ # Web 后端 │ │ ├── server.ts # Express + WebSocket 服务 │ │ ├── websocket.ts # 实时状态广播 @@ -246,7 +286,7 @@ tsmusicbot/ | **后端框架** | Express 4, WebSocket (ws) | | **数据库** | better-sqlite3 (SQLite) | | **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus | -| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议,兼容 TS3/TS5/TS6) | +| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 | | **网易云 API** | NeteaseCloudMusicApi | | **QQ 音乐 API** | @sansenjian/qq-music-api | | **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) | @@ -277,6 +317,9 @@ tsmusicbot/ ## 常见问题 +**Q:支持 TeamSpeak 6 Server 吗?** +A:支持。`dev` 分支已实现 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。 + **Q:机器人连接了但 TeamSpeak 中听不到音乐?** A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。 @@ -322,6 +365,7 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm | 项目 | 说明 | |------|------| +| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) | | [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 | | [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 | | [TS3AudioBot-NetEaseCloudmusic-plugin](https://github.com/ZHANGTIANYAO1/TS3AudioBot-NetEaseCloudmusic-plugin) | 提供插件开发参考和懒加载设计参考 | From 80e92b77b88aa30b8f497c27947cef0d36b17b8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:25:08 +0000 Subject: [PATCH 07/17] Fix install.sh: build from source instead of requiring pre-built dist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script previously checked for a dist/ directory and failed with "Please run this script from the TSMusicBot source directory after building" — requiring users to manually build before installing. Now the script: - Resolves project root from script location (works from any cwd) - Installs build tools (build-essential/gcc) for native modules - Runs npm install + npm run build automatically - Copies built artifacts to /opt/tsmusicbot - Creates data directory for runtime files - Adds journalctl command to the help output https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh --- scripts/install.sh | 58 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 1550c31..4715d15 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,9 +6,19 @@ echo "║ TSMusicBot Installer ║" echo "╚══════════════════════════════════════╝" echo "" +# Resolve script location → project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" INSTALL_DIR="/opt/tsmusicbot" SERVICE_NAME="tsmusicbot" +# Verify we're in a valid project directory +if [ ! -f "$PROJECT_DIR/package.json" ]; then + echo "Error: Cannot find package.json in $PROJECT_DIR" + echo "Please run this script from the TSMusicBot project directory." + exit 1 +fi + # Detect OS if [ -f /etc/os-release ]; then . /etc/os-release @@ -18,44 +28,57 @@ else exit 1 fi -echo "[1/5] Installing system dependencies..." +echo "[1/6] Installing system dependencies..." case $OS in ubuntu|debian) sudo apt-get update -qq - sudo apt-get install -y -qq curl ffmpeg + sudo apt-get install -y -qq curl build-essential python3 ;; centos|rhel|fedora) - sudo yum install -y curl ffmpeg + sudo yum install -y curl gcc gcc-c++ make python3 ;; arch|manjaro) - sudo pacman -S --noconfirm curl ffmpeg + sudo pacman -S --noconfirm curl base-devel python ;; *) - echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually." + echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually." ;; esac -echo "[2/5] Installing Node.js 20 LTS..." +echo "[2/6] Installing Node.js 20 LTS..." if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null fi echo "Node.js $(node -v) installed" -echo "[3/5] Downloading TSMusicBot..." -sudo mkdir -p "$INSTALL_DIR" -if [ -d "$(pwd)/dist" ]; then - sudo cp -r "$(pwd)"/* "$INSTALL_DIR/" -else - echo "Please run this script from the TSMusicBot source directory after building." - exit 1 +echo "[3/6] Installing dependencies..." +cd "$PROJECT_DIR" +npm install +if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then + (cd "$PROJECT_DIR/web" && npm install) fi -echo "[4/5] Installing npm dependencies..." -cd "$INSTALL_DIR" -sudo npm install --production +echo "[4/6] Building project..." +npm run build -echo "[5/5] Creating systemd service..." +echo "[5/6] Copying to $INSTALL_DIR..." +sudo mkdir -p "$INSTALL_DIR" +sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/" +sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/" +sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/" +# Copy web frontend if built +if [ -d "$PROJECT_DIR/web/dist" ]; then + sudo mkdir -p "$INSTALL_DIR/web" + sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/" +fi +# Copy scripts for future use +sudo mkdir -p "$INSTALL_DIR/scripts" +sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true +# Create data directory +sudo mkdir -p "$INSTALL_DIR/data" + +echo "[6/6] Creating systemd service..." sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null < Date: Fri, 3 Apr 2026 22:38:36 +0800 Subject: [PATCH 08/17] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 227b6bb..adc7768 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

> **`dev` 分支** — 活跃开发分支,包含最新特性和 bug 修复。 -> 如需稳定版本,请切换到 [`main`](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/tree/main) 分支。 +> !!!如非必要!!!请使用稳定版本,请切换到 [`main`](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/tree/main) 分支。 --- From 7ad9559653da4e827bd779fdc16fefbf0282c12c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 14:24:06 +0000 Subject: [PATCH 09/17] Fix TS6 connection: patch clientinit version at handler level The @honeybbq/teamspeak-client library sends clientinit directly via handler.sendPacket() during the handshake, bypassing the commandMiddleware chain entirely. This meant the ts6VersionMiddleware never intercepted the handshake clientinit, so TS6 servers always received version 3.5.3 and silently rejected it (never responding with initserver), causing idle timeout. Fix: monkey-patch handler.sendPacket() to intercept clientinit packets and upgrade the version to 3.6.2 before they're sent over the wire. https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD --- src/ts-protocol/client.ts | 33 ++++++++++----- src/ts-protocol/ts6-compat.test.ts | 39 ++++-------------- src/ts-protocol/ts6-compat.ts | 64 +++++++++--------------------- 3 files changed, 49 insertions(+), 87 deletions(-) diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index acdc859..2436480 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -10,7 +10,6 @@ import { type Identity, type TextMessage, type ClientInfo, - type CommandMiddleware, } from "@honeybbq/teamspeak-client"; import type { Logger } from "../logger.js"; import { @@ -18,7 +17,7 @@ import { type ServerProtocol, } from "./protocol-detect.js"; import { TS6HttpQuery } from "./http-query.js"; -import { ts6VersionMiddleware } from "./ts6-compat.js"; +import { patchClientInitVersion } from "./ts6-compat.js"; export { CODEC_OPUS_MUSIC } from "./voice.js"; export type { ServerProtocol } from "./protocol-detect.js"; @@ -154,13 +153,6 @@ export class TS3Client extends EventEmitter { this.logger.warn(msg); }; - // Apply TS6 version middleware if connecting to a TS6 server - const commandMiddleware: CommandMiddleware[] = []; - if (this.detectedProtocol === "ts6") { - commandMiddleware.push(ts6VersionMiddleware("3.6.2")); - this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); - } - this.client = new TS3FullClient(this.identity, addr, this.options.nickname, { logger: { debug: (msg) => this.logger.debug(msg), @@ -168,9 +160,30 @@ export class TS3Client extends EventEmitter { warn: throttledWarn, error: (msg) => this.logger.error(msg), }, - commandMiddleware, }); + // Patch handler.sendPacket to intercept clientinit during handshake for TS6. + // The library's commandMiddleware only applies to post-connection commands + // (sendCommandNoWait/execCommand), but the handshake's clientinit is sent + // directly via handler.sendPacket, bypassing middleware entirely. + // This monkey-patch ensures the version is upgraded for TS6 servers. + if (this.detectedProtocol === "ts6") { + this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); + const origSendPacket = this.client.handler.sendPacket.bind(this.client.handler); + this.client.handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { + // PacketType.Command = 2 + if (pType === 2) { + const str = Buffer.from(data).toString("utf-8"); + if (str.startsWith("clientinit ")) { + const patched = patchClientInitVersion(str); + origSendPacket(pType, Buffer.from(patched), flags); + return; + } + } + origSendPacket(pType, data, flags); + }; + } + this.client.on("textMessage", (msg: TextMessage) => { const tsMsg: TS3TextMessage = { invokerName: msg.invokerName, diff --git a/src/ts-protocol/ts6-compat.test.ts b/src/ts-protocol/ts6-compat.test.ts index fd61412..3a6211c 100644 --- a/src/ts-protocol/ts6-compat.test.ts +++ b/src/ts-protocol/ts6-compat.test.ts @@ -1,45 +1,22 @@ import { describe, it, expect } from "vitest"; -import { ts6VersionMiddleware } from "./ts6-compat.js"; +import { patchClientInitVersion } from "./ts6-compat.js"; describe("ts6-compat", () => { - it("patches clientinit version to 3.6.2", async () => { - const middleware = ts6VersionMiddleware("3.6.2"); - let captured = ""; - const next = async (cmd: string) => { - captured = cmd; - }; - const handler = middleware(next); - + it("patches clientinit version to 3.6.2", () => { const original = "clientinit client_nickname=Bot client_version=3.5.3\\s[Build:\\s1587971024] " + "client_platform=Windows client_version_sign=Kvmj7qX6wJCPI5GVT71samfmhz/bvs7M+OTXWB/JWxdQbxDe17xda7dzUWLX7pjvdJTqZmbse1HBmTxThPKvAg== " + "client_key_offset=42 hwid=abc123"; - await handler(original); + const patched = patchClientInitVersion(original); - expect(captured).toContain("client_version=3.6.2\\s[Build:\\s1695203293]"); - expect(captured).toContain( + expect(patched).toContain("client_version=3.6.2\\s[Build:\\s1695203293]"); + expect(patched).toContain( "client_version_sign=4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==" ); // Other fields preserved - expect(captured).toContain("client_nickname=Bot"); - expect(captured).toContain("client_key_offset=42"); - expect(captured).toContain("hwid=abc123"); - }); - - it("does not modify non-clientinit commands", async () => { - const middleware = ts6VersionMiddleware("3.6.2"); - let captured = ""; - const handler = middleware(async (cmd) => { - captured = cmd; - }); - - const original = "sendtextmessage targetmode=2 msg=hello"; - await handler(original); - expect(captured).toBe(original); - }); - - it("throws for unknown target version", () => { - expect(() => ts6VersionMiddleware("9.9.9")).toThrow("Unknown version"); + expect(patched).toContain("client_nickname=Bot"); + expect(patched).toContain("client_key_offset=42"); + expect(patched).toContain("hwid=abc123"); }); }); diff --git a/src/ts-protocol/ts6-compat.ts b/src/ts-protocol/ts6-compat.ts index 5d24f09..4ff8bd9 100644 --- a/src/ts-protocol/ts6-compat.ts +++ b/src/ts-protocol/ts6-compat.ts @@ -1,57 +1,29 @@ -import type { CommandMiddleware } from "@honeybbq/teamspeak-client"; - /** - * Known-compatible client versions with their signatures. - * - * The version_sign is a P-256 ECDSA signature over the version string, - * verified by TeamSpeak servers. Only officially signed versions work. + * TS6 version used for compatibility. The version_sign is a P-256 ECDSA + * signature verified by TeamSpeak servers; only officially signed pairs work. */ -const KNOWN_VERSIONS: Record< - string, - { version: string; platform: string; sign: string } -> = { - // TS3 client 3.5.3 (default in @honeybbq/teamspeak-client) - "3.5.3": { - version: "3.5.3 [Build: 1587971024]", - platform: "Windows", - sign: "Kvmj7qX6wJCPI5GVT71samfmhz/bvs7M+OTXWB/JWxdQbxDe17xda7dzUWLX7pjvdJTqZmbse1HBmTxThPKvAg==", - }, - // TS3 client 3.6.2 (used by NeteaseTSBot, known to work with TS6 servers) - "3.6.2": { - version: "3.6.2 [Build: 1695203293]", - platform: "Windows", - sign: "4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==", - }, +const TS6_VERSION = { + version: "3.6.2 [Build: 1695203293]", + platform: "Windows", + sign: "4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==", }; /** - * Command middleware that upgrades the client_version in `clientinit` to - * a newer version known to work with TS6 servers. + * Patch a raw `clientinit` command string to use the 3.6.2 version. * - * TS6 servers may reject connections from older client versions. The default - * library sends version 3.5.3; this middleware patches it to 3.6.2 which is - * the version NeteaseTSBot uses successfully with TS6. + * The @honeybbq/teamspeak-client library hardcodes version 3.5.3 in the + * handshake and sends it directly via handler.sendPacket(), bypassing the + * commandMiddleware chain. TS6 servers silently reject 3.5.3 (they never + * respond with `initserver`), causing an idle timeout. * - * The version_sign must match the version string — it's a server-verified - * ECDSA signature, so we can only use known-valid pairs. + * This function is called from a monkey-patched handler.sendPacket() so it + * intercepts the actual handshake packets. */ -export function ts6VersionMiddleware( - targetVersion = "3.6.2", -): CommandMiddleware { - const vInfo = KNOWN_VERSIONS[targetVersion]; - if (!vInfo) { - throw new Error(`Unknown version ${targetVersion}. Known: ${Object.keys(KNOWN_VERSIONS).join(", ")}`); - } - - return (next) => async (cmd) => { - // Only intercept clientinit commands - if (cmd.startsWith("clientinit ")) { - cmd = replaceField(cmd, "client_version", vInfo.version); - cmd = replaceField(cmd, "client_platform", vInfo.platform); - cmd = replaceField(cmd, "client_version_sign", vInfo.sign); - } - return next(cmd); - }; +export function patchClientInitVersion(cmd: string): string { + cmd = replaceField(cmd, "client_version", TS6_VERSION.version); + cmd = replaceField(cmd, "client_platform", TS6_VERSION.platform); + cmd = replaceField(cmd, "client_version_sign", TS6_VERSION.sign); + return cmd; } /** From b00b6637d2a09595ed7b301d9cca58463f6b7f28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 15:26:41 +0000 Subject: [PATCH 10/17] Fix corner cases in connect/disconnect lifecycle and resource cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Move TS6 handler patch to after client.connect() — the library's connect() internally replaces handler via #S(), discarding any patch applied beforehand. Patching after connect() is safe because clientinit is sent in async message callbacks after Init1 round-trips. 2. Preserve detectedProtocol across reconnect — disconnect() resets it to "unknown", causing the TS6 patch to be skipped on reconnect. 3. Prevent double "disconnected" event in BotInstance — disconnect() emitted it directly AND the async TS3Client disconnect triggered another through the event chain. 4. Guard playNext() against running after disconnect — check connected flag to avoid ghost queue processing. 5. Fix UDP error timer leak — clear previous timer before setting a new one to prevent accumulation. 6. Fix isPortFree() FD leak — close the test server on error path. https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD --- src/bot/instance.ts | 11 +++++-- src/music/api-server.ts | 4 ++- src/ts-protocol/client.ts | 64 ++++++++++++++++++++++++--------------- 3 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index b436a4e..920c3e4 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -105,6 +105,9 @@ export class BotInstance extends EventEmitter { }); this.tsClient.on("disconnected", () => { + // Avoid duplicate event if disconnect() was called explicitly + // (it already set connected = false and emitted "disconnected"). + if (!this.connected) return; this.connected = false; this.player.stop(); this.emit("disconnected"); @@ -119,8 +122,10 @@ export class BotInstance extends EventEmitter { disconnect(): void { this.player.stop(); - this.tsClient.disconnect(); + // Set connected = false BEFORE tsClient.disconnect() so that the + // "disconnected" event handler (setupTsEvents) won't emit a duplicate. this.connected = false; + this.tsClient.disconnect(); this.emit("disconnected"); } @@ -488,7 +493,7 @@ export class BotInstance extends EventEmitter { } private async playNext(): Promise { - if (this.isAdvancing) return; + if (this.isAdvancing || !this.connected) return; this.isAdvancing = true; try { this.voteSkipUsers.clear(); @@ -497,7 +502,7 @@ export class BotInstance extends EventEmitter { let started = await this.resolveAndPlay(next); if (!started) { // Skip to next if URL resolve fails (up to 3 retries) - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 3 && this.connected; i++) { const retry = this.queue.next(); if (!retry) break; if (await this.resolveAndPlay(retry)) { diff --git a/src/music/api-server.ts b/src/music/api-server.ts index f517ea5..05fe3fc 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -17,7 +17,9 @@ export interface ApiServerManager { function isPortFree(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); - server.once("error", () => resolve(false)); + server.once("error", () => { + server.close(() => resolve(false)); + }); server.once("listening", () => { server.close(() => resolve(true)); }); diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 2436480..0ac1303 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -119,10 +119,15 @@ export class TS3Client extends EventEmitter { }); } - // Guard against calling connect() while already connected + // 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)); } @@ -139,7 +144,9 @@ export class TS3Client extends EventEmitter { udpErrorCount++; if (udpErrorCount === 1) { this.logger.warn(msg); - // After 2 seconds, log a summary and reset + // 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)`); @@ -162,28 +169,6 @@ export class TS3Client extends EventEmitter { }, }); - // Patch handler.sendPacket to intercept clientinit during handshake for TS6. - // The library's commandMiddleware only applies to post-connection commands - // (sendCommandNoWait/execCommand), but the handshake's clientinit is sent - // directly via handler.sendPacket, bypassing middleware entirely. - // This monkey-patch ensures the version is upgraded for TS6 servers. - if (this.detectedProtocol === "ts6") { - this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); - const origSendPacket = this.client.handler.sendPacket.bind(this.client.handler); - this.client.handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { - // PacketType.Command = 2 - if (pType === 2) { - const str = Buffer.from(data).toString("utf-8"); - if (str.startsWith("clientinit ")) { - const patched = patchClientInitVersion(str); - origSendPacket(pType, Buffer.from(patched), flags); - return; - } - } - origSendPacket(pType, data, flags); - }; - } - this.client.on("textMessage", (msg: TextMessage) => { const tsMsg: TS3TextMessage = { invokerName: msg.invokerName, @@ -209,6 +194,37 @@ export class TS3Client extends EventEmitter { }); await this.client.connect(); + + // Patch handler.sendPacket to intercept clientinit during handshake for TS6. + // + // Why after connect(): The library's connect() internally calls #S() which + // replaces this.handler with a fresh PacketHandler. Any patch applied before + // connect() would be discarded. Patching here is safe because connect() only + // establishes the UDP socket and sends the first Init1 packet — the actual + // clientinit command is sent later in async message callbacks (after Init1 + // round-trips complete), which cannot fire before this sync continuation. + // + // Why not commandMiddleware: The library's middleware chain only applies to + // post-connection commands (sendCommandNoWait/execCommand). The handshake's + // clientinit is sent directly via handler.sendPacket(), bypassing middleware. + if (this.detectedProtocol === "ts6") { + this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); + const handler = this.client.handler; + const origSendPacket = handler.sendPacket.bind(handler); + handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { + // PacketType.Command = 2 + if (pType === 2) { + const str = Buffer.from(data).toString("utf-8"); + if (str.startsWith("clientinit ")) { + const patched = patchClientInitVersion(str); + origSendPacket(pType, Buffer.from(patched), flags); + return; + } + } + origSendPacket(pType, data, flags); + }; + } + await this.client.waitConnected(); this.clientId = this.client.clientID(); this.voiceFramesSent = 0; From b20ddf2603f846fed88e691b1932d37da45e5317 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Thu, 9 Apr 2026 11:31:00 +0800 Subject: [PATCH 11/17] feat(manager): reload bot config from database on startBot When starting a bot, re-instantiate BotInstance from latest database config so changes to host, nickname, channel, protocol etc. take effect immediately without requiring a full app restart. --- src/bot/manager.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/bot/manager.ts b/src/bot/manager.ts index aa36cc0..bb3cac6 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -140,15 +140,40 @@ export class BotManager { } async startBot(id: string): Promise { - const bot = this.bots.get(id); - if (!bot) throw new Error(`Bot ${id} not found`); - await bot.connect(); + const oldBot = this.bots.get(id); + if (!oldBot) throw new Error(`Bot ${id} not found`); - // Mark as autoStart so it reconnects on Docker restart, and persist identity + // Reload config from database so updated settings (channel, nickname, etc.) take effect const saved = this.database.getBotInstances().find((i) => i.id === id); if (saved) { + const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined; + const bot = new BotInstance({ + id: saved.id, + name: saved.name, + tsOptions: { + host: saved.serverAddress, + port: saved.serverPort, + queryPort: proto === "ts6" ? 10080 : 10011, + nickname: saved.nickname, + defaultChannel: saved.defaultChannel || undefined, + channelPassword: saved.channelPassword || undefined, + serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, + ts6ApiKey: saved.ts6ApiKey || undefined, + }, + neteaseProvider: this.neteaseProvider, + qqProvider: this.qqProvider, + bilibiliProvider: this.bilibiliProvider, + database: this.database, + config: this.config, + logger: this.logger, + }); + this.bots.set(id, bot); + await bot.connect(); + // Mark as autoStart so it reconnects on Docker restart, and persist identity this.database.saveBotInstance({ ...saved, autoStart: true }); this.persistBotIdentity(saved, bot); + } else { + await oldBot.connect(); } } From d5d6abeb1e241323bf8e36d6307ce5ea636b11da Mon Sep 17 00:00:00 2001 From: saopig1 Date: Thu, 9 Apr 2026 11:58:07 +0800 Subject: [PATCH 12/17] feat: implement server password login, YouTube source, and improved bot selector UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **TS server password** (#7/#9): add serverPassword field across database, manager, bot API, and Settings UI — allows joining password-protected servers - **YouTube audio source** (#1/#10): new YouTubeProvider using yt-dlp binary; adds -y flag in chat commands, /api/music supports platform=youtube, YouTube badge in SongCard, yt-dlp-wrap npm dependency - **Bot selector UI** (#14): selector always visible (not just when >1 bot), bigger button with border and play-state indicator; per-bot URL routing at /bot/:id with BotRedirect view; copy-link button in dropdown Closes #1, #7, #9, #10, #14 Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 10 +- package.json | 3 +- src/audio/queue.ts | 2 +- src/bot/instance.ts | 8 +- src/bot/manager.ts | 13 +++ src/data/database.test.ts | 2 + src/data/database.ts | 14 ++- src/music/provider.ts | 8 +- src/music/youtube.ts | 184 ++++++++++++++++++++++++++++++++ src/web/api/bot.ts | 6 +- src/web/api/music.ts | 3 + web/src/components/Navbar.vue | 107 ++++++++++++++----- web/src/components/SongCard.vue | 9 +- web/src/router/index.ts | 6 ++ web/src/views/BotRedirect.vue | 42 ++++++++ web/src/views/Settings.vue | 14 +++ 16 files changed, 391 insertions(+), 40 deletions(-) create mode 100644 src/music/youtube.ts create mode 100644 web/src/views/BotRedirect.vue diff --git a/package-lock.json b/package-lock.json index 70f217b..468387e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "pino": "^10.3.1", "ts3-nodejs-library": "^3.5.1", "tweetnacl": "^1.0.3", - "ws": "^8.20.0" + "ws": "^8.20.0", + "yt-dlp-wrap": "^2.3.12" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -6268,6 +6269,13 @@ "engines": { "node": ">= 4.0.0" } + }, + "node_modules/yt-dlp-wrap": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/yt-dlp-wrap/-/yt-dlp-wrap-2.3.12.tgz", + "integrity": "sha512-P8fJ+6M1YjukyJENCTviNLiZ8mokxprR54ho3DsSKPWDcac489OjRiStGEARJr6un6ETS6goTn4CWl/b/rM3aA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" } } } diff --git a/package.json b/package.json index 694d5fc..1123e8d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "pino": "^10.3.1", "ts3-nodejs-library": "^3.5.1", "tweetnacl": "^1.0.3", - "ws": "^8.20.0" + "ws": "^8.20.0", + "yt-dlp-wrap": "^2.3.12" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 4e58ca0..0416522 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -10,7 +10,7 @@ export interface QueuedSong { name: string; artist: string; album: string; - platform: "netease" | "qq" | "bilibili"; + platform: "netease" | "qq" | "bilibili" | "youtube"; url?: string; // resolved lazily at play time coverUrl: string; duration: number; // seconds diff --git a/src/bot/instance.ts b/src/bot/instance.ts index e1ce0e5..f31fa71 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -23,6 +23,7 @@ export interface BotInstanceOptions { neteaseProvider: MusicProvider; qqProvider: MusicProvider; bilibiliProvider: MusicProvider; + youtubeProvider: MusicProvider; database: BotDatabase; config: BotConfig; logger: Logger; @@ -51,6 +52,7 @@ export class BotInstance extends EventEmitter { private neteaseProvider: MusicProvider; private qqProvider: MusicProvider; private bilibiliProvider: MusicProvider; + private youtubeProvider: MusicProvider; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -65,6 +67,7 @@ export class BotInstance extends EventEmitter { this.neteaseProvider = options.neteaseProvider; this.qqProvider = options.qqProvider; this.bilibiliProvider = options.bilibiliProvider; + this.youtubeProvider = options.youtubeProvider; this.database = options.database; this.config = options.config; this.logger = options.logger.child({ botId: this.id }); @@ -217,14 +220,16 @@ export class BotInstance extends EventEmitter { } } - getProviderFor(platform: "netease" | "qq" | "bilibili"): MusicProvider { + getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider { if (platform === "bilibili") return this.bilibiliProvider; + if (platform === "youtube") return this.youtubeProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider; } private getProvider(flags: Set): MusicProvider { if (flags.has("b")) return this.bilibiliProvider; if (flags.has("q")) return this.qqProvider; + if (flags.has("y")) return this.youtubeProvider; return this.neteaseProvider; } @@ -477,6 +482,7 @@ export class BotInstance extends EventEmitter { `${p}play — Search and play`, `${p}play -q — Search from QQ Music`, `${p}play -b — Search from BiliBili`, + `${p}play -y — Search from YouTube (yt-dlp)`, `${p}add — Add to queue`, `${p}pause/resume — Pause/resume`, `${p}next/prev — Next/previous`, diff --git a/src/bot/manager.ts b/src/bot/manager.ts index bb3cac6..08b4c65 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -4,6 +4,7 @@ import { type BotInstanceOptions, } from "./instance.js"; import type { MusicProvider } from "../music/provider.js"; +import { YouTubeProvider } from "../music/youtube.js"; import type { BotDatabase } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; import type { Logger } from "../logger.js"; @@ -23,6 +24,8 @@ export interface CreateBotParams { serverProtocol?: ServerProtocol; /** API key for TS6 HTTP Query (port 10080/10443). */ ts6ApiKey?: string; + /** Password required to join the TS server. */ + serverPassword?: string; } export class BotManager { @@ -30,6 +33,7 @@ export class BotManager { private neteaseProvider: MusicProvider; private qqProvider: MusicProvider; private bilibiliProvider: MusicProvider; + private youtubeProvider: MusicProvider; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -45,6 +49,7 @@ export class BotManager { this.neteaseProvider = neteaseProvider; this.qqProvider = qqProvider; this.bilibiliProvider = bilibiliProvider; + this.youtubeProvider = new YouTubeProvider(); this.database = database; this.config = config; this.logger = logger; @@ -63,12 +68,14 @@ export class BotManager { nickname: params.nickname, defaultChannel: params.defaultChannel, channelPassword: params.channelPassword, + serverPassword: params.serverPassword, serverProtocol: params.serverProtocol, ts6ApiKey: params.ts6ApiKey, }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, bilibiliProvider: this.bilibiliProvider, + youtubeProvider: this.youtubeProvider, database: this.database, config: this.config, logger: this.logger, @@ -87,6 +94,7 @@ export class BotManager { autoStart: params.autoStart ?? false, serverProtocol: params.serverProtocol ?? "", ts6ApiKey: params.ts6ApiKey ?? "", + serverPassword: params.serverPassword ?? "", }); this.logger.info({ botId: id, name: params.name }, "Bot instance created"); @@ -118,6 +126,7 @@ export class BotManager { channelPassword: params.channelPassword ?? existing.channelPassword, serverProtocol: params.serverProtocol ?? existing.serverProtocol, ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey, + serverPassword: params.serverPassword ?? existing.serverPassword, }); // Update in-memory name immediately (other fields need reconnect) const bot = this.bots.get(id); @@ -157,12 +166,14 @@ export class BotManager { nickname: saved.nickname, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, + serverPassword: saved.serverPassword || undefined, serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, ts6ApiKey: saved.ts6ApiKey || undefined, }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, bilibiliProvider: this.bilibiliProvider, + youtubeProvider: this.youtubeProvider, database: this.database, config: this.config, logger: this.logger, @@ -204,12 +215,14 @@ export class BotManager { identity: saved.identity || undefined, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, + serverPassword: saved.serverPassword || undefined, serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, ts6ApiKey: saved.ts6ApiKey || undefined, }, neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, bilibiliProvider: this.bilibiliProvider, + youtubeProvider: this.youtubeProvider, database: this.database, config: this.config, logger: this.logger, diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 8807797..38b140d 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -62,6 +62,7 @@ describe("database", () => { autoStart: true, serverProtocol: "", ts6ApiKey: "", + serverPassword: "", }; botDb.saveBotInstance(instance); @@ -90,6 +91,7 @@ describe("database", () => { autoStart: false, serverProtocol: "", ts6ApiKey: "", + serverPassword: "", }); expect(botDb.deleteBotInstance("bot1")).toBe(true); diff --git a/src/data/database.ts b/src/data/database.ts index ca66970..8e193e1 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -6,7 +6,7 @@ export interface PlayHistoryEntry { songName: string; artist: string; album: string; - platform: "netease" | "qq" | "bilibili"; + platform: "netease" | "qq" | "bilibili" | "youtube"; coverUrl: string; } @@ -28,6 +28,8 @@ export interface BotInstance { serverProtocol: string; /** API key for TS6 HTTP Query */ ts6ApiKey: string; + /** Password to join the TS server (server password) */ + serverPassword: string; identity?: string; } @@ -53,6 +55,9 @@ function migrateSchema(db: Database.Database): void { if (!names.includes("ts6ApiKey")) { db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''"); } + if (!names.includes("serverPassword")) { + db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''"); + } } function initTables(db: Database.Database): void { @@ -80,6 +85,7 @@ function initTables(db: Database.Database): void { autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '', ts6ApiKey TEXT NOT NULL DEFAULT '', + serverPassword TEXT NOT NULL DEFAULT '', identity TEXT ); `); @@ -101,8 +107,8 @@ export function createDatabase(dbPath: string): BotDatabase { `); const upsertInstance = db.prepare(` - INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, identity) - VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @identity) + INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity) + VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity) ON CONFLICT(id) DO UPDATE SET name = excluded.name, serverAddress = excluded.serverAddress, @@ -113,6 +119,7 @@ export function createDatabase(dbPath: string): BotDatabase { autoStart = excluded.autoStart, serverProtocol = excluded.serverProtocol, ts6ApiKey = excluded.ts6ApiKey, + serverPassword = excluded.serverPassword, identity = excluded.identity `); @@ -148,6 +155,7 @@ export function createDatabase(dbPath: string): BotDatabase { autoStart: r.autoStart === 1, serverProtocol: r.serverProtocol ?? "", ts6ApiKey: r.ts6ApiKey ?? "", + serverPassword: r.serverPassword ?? "", identity: r.identity ?? undefined, })); }, diff --git a/src/music/provider.ts b/src/music/provider.ts index 90b5f87..55a2c76 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -5,7 +5,7 @@ export interface Song { album: string; duration: number; // seconds coverUrl: string; - platform: "netease" | "qq" | "bilibili"; + platform: "netease" | "qq" | "bilibili" | "youtube"; } export interface SongWithUrl extends Song { @@ -17,7 +17,7 @@ export interface Playlist { name: string; coverUrl: string; songCount: number; - platform: "netease" | "qq" | "bilibili"; + platform: "netease" | "qq" | "bilibili" | "youtube"; } export interface Album { @@ -26,7 +26,7 @@ export interface Album { artist: string; coverUrl: string; songCount: number; - platform: "netease" | "qq" | "bilibili"; + platform: "netease" | "qq" | "bilibili" | "youtube"; } export interface LyricLine { @@ -54,7 +54,7 @@ export interface AuthStatus { } export interface MusicProvider { - readonly platform: "netease" | "qq" | "bilibili"; + readonly platform: "netease" | "qq" | "bilibili" | "youtube"; search(query: string, limit?: number): Promise; getSongUrl(songId: string, quality?: string): Promise; diff --git a/src/music/youtube.ts b/src/music/youtube.ts new file mode 100644 index 0000000..339edf0 --- /dev/null +++ b/src/music/youtube.ts @@ -0,0 +1,184 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import type { + MusicProvider, + Song, + SongWithUrl, + Playlist, + Album, + SearchResult, + LyricLine, + QrCodeResult, + AuthStatus, +} from "./provider.js"; + +const execFileAsync = promisify(execFile); + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Resolve the yt-dlp binary path. Checks the project bin/ dir first, then PATH. */ +function findYtDlp(): string { + const candidates = [ + join(__dirname, "..", "..", "bin", "yt-dlp.exe"), + join(__dirname, "..", "..", "bin", "yt-dlp"), + "yt-dlp", + "yt-dlp.exe", + ]; + for (const c of candidates) { + if (!c.includes(join("bin", "yt-dlp")) || existsSync(c)) return c; + // For PATH entries (no directory prefix), fall through to let execFile try + } + return "yt-dlp"; +} + +async function runYtDlp(args: string[], timeoutMs = 30_000): Promise { + const binary = findYtDlp(); + const env = { ...process.env }; + if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) { + // yt-dlp respects these env vars natively + } + const { stdout } = await execFileAsync(binary, args, { + timeout: timeoutMs, + env, + maxBuffer: 10 * 1024 * 1024, + }); + return stdout; +} + +interface YtDlpEntry { + id: string; + title: string; + uploader?: string; + channel?: string; + duration?: number; + thumbnail?: string; + webpage_url?: string; + url?: string; + entries?: YtDlpEntry[]; + _type?: string; +} + +function entryToSong(entry: YtDlpEntry): Song { + return { + id: entry.id ?? "", + name: entry.title ?? "Unknown", + artist: entry.uploader ?? entry.channel ?? "YouTube", + album: "YouTube", + duration: Math.round(entry.duration ?? 0), + coverUrl: entry.thumbnail ?? "", + platform: "youtube", + }; +} + +export class YouTubeProvider implements MusicProvider { + readonly platform = "youtube" as const; + private quality = "bestaudio"; + + async search(query: string, limit = 5): Promise { + try { + const raw = await runYtDlp([ + `ytsearch${limit}:${query}`, + "--dump-json", + "--flat-playlist", + "--no-warnings", + "--quiet", + ]); + const lines = raw.trim().split("\n").filter(Boolean); + const songs: Song[] = lines.map((line) => { + const entry = JSON.parse(line) as YtDlpEntry; + return entryToSong(entry); + }); + return { songs, playlists: [], albums: [] }; + } catch { + return { songs: [], playlists: [], albums: [] }; + } + } + + async getSongUrl(songId: string): Promise { + try { + const url = `https://www.youtube.com/watch?v=${songId}`; + const raw = await runYtDlp([ + url, + "--get-url", + "-f", + "bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio", + "--no-warnings", + "--quiet", + ], 45_000); + const audioUrl = raw.trim().split("\n")[0]; + return audioUrl || null; + } catch { + return null; + } + } + + setQuality(quality: string): void { + this.quality = quality; + } + + getQuality(): string { + return this.quality; + } + + async getSongDetail(songId: string): Promise { + try { + const url = `https://www.youtube.com/watch?v=${songId}`; + const raw = await runYtDlp([url, "--dump-json", "--no-warnings", "--quiet"]); + const entry = JSON.parse(raw.trim()) as YtDlpEntry; + return entryToSong(entry); + } catch { + return null; + } + } + + async getPlaylistSongs(playlistId: string): Promise { + try { + const url = playlistId.startsWith("http") + ? playlistId + : `https://www.youtube.com/playlist?list=${playlistId}`; + const raw = await runYtDlp([ + url, + "--dump-json", + "--flat-playlist", + "--no-warnings", + "--quiet", + ], 60_000); + const lines = raw.trim().split("\n").filter(Boolean); + return lines.map((line) => entryToSong(JSON.parse(line) as YtDlpEntry)); + } catch { + return []; + } + } + + async getRecommendPlaylists(): Promise { + return []; + } + + async getAlbumSongs(_albumId: string): Promise { + return []; + } + + async getLyrics(_songId: string): Promise { + return []; + } + + async getQrCode(): Promise { + return { qrUrl: "", key: "" }; + } + + async checkQrCodeStatus( + _key: string + ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { + return "expired"; + } + + setCookie(_cookie: string): void {} + getCookie(): string { return ""; } + + async getAuthStatus(): Promise { + return { loggedIn: true, nickname: "YouTube (yt-dlp)" }; + } +} diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index 2d4d51b..e60bece 100644 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -43,6 +43,7 @@ export function createBotRouter( nickname, defaultChannel, channelPassword, + serverPassword, autoStart, } = req.body; if (!name || !serverAddress || !nickname) { @@ -58,6 +59,7 @@ export function createBotRouter( nickname, defaultChannel, channelPassword, + serverPassword, autoStart: autoStart ?? false, }); res.status(201).json(bot.getStatus()); @@ -75,10 +77,10 @@ export function createBotRouter( res.status(404).json({ error: "Bot not found" }); return; } - const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword } = req.body; + const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body; // Update in database botManager.updateBot(req.params.id, { - name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, + name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword, }); res.json({ success: true }); } catch (err) { diff --git a/src/web/api/music.ts b/src/web/api/music.ts index e1ef73b..3690674 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import type { MusicProvider } from "../../music/provider.js"; +import { YouTubeProvider } from "../../music/youtube.js"; import type { Logger } from "../../logger.js"; export function createMusicRouter( @@ -9,9 +10,11 @@ export function createMusicRouter( logger: Logger ): Router { const router = Router(); + const youtubeProvider: MusicProvider = new YouTubeProvider(); function getProvider(platform?: string): MusicProvider { if (platform === "bilibili") return bilibiliProvider; + if (platform === "youtube") return youtubeProvider; return platform === "qq" ? qqProvider : neteaseProvider; } diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 006a51e..f381595 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -9,36 +9,42 @@ @@ -118,6 +118,11 @@ function formatDuration(seconds: number): string { color: #00a1d6; } +.badge-youtube { + background: rgba(255, 0, 0, 0.12); + color: #ff0000; +} + .song-artist { font-size: 12px; color: var(--text-secondary); diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 66da02a..356d57f 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -38,6 +38,12 @@ const router = createRouter({ name: 'setup', component: () => import('../views/Setup.vue'), }, + { + // Per-bot URL: /bot/:id — sets active bot then redirects to home + path: '/bot/:id', + name: 'bot', + component: () => import('../views/BotRedirect.vue'), + }, ], }); diff --git a/web/src/views/BotRedirect.vue b/web/src/views/BotRedirect.vue new file mode 100644 index 0000000..3378ac4 --- /dev/null +++ b/web/src/views/BotRedirect.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 474f8a5..7bb2128 100644 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -76,6 +76,10 @@ +
+ + +
+
+ + +
@@ -394,6 +402,7 @@ const newBotServer = ref(''); const newBotPort = ref(9987); const newBotNickname = ref('MusicBot'); const newBotChannel = ref(''); +const newBotServerPassword = ref(''); // Edit bot const editingBot = ref(null); @@ -404,6 +413,7 @@ const editForm = reactive({ nickname: '', defaultChannel: '', channelPassword: '', + serverPassword: '', }); const neteaseCookie = ref(''); @@ -559,6 +569,7 @@ async function createBot() { serverPort: newBotPort.value || 9987, nickname: newBotNickname.value || newBotName.value, defaultChannel: newBotChannel.value || undefined, + serverPassword: newBotServerPassword.value || undefined, autoStart: false, }); newBotName.value = ''; @@ -566,6 +577,7 @@ async function createBot() { newBotPort.value = 9987; newBotNickname.value = 'MusicBot'; newBotChannel.value = ''; + newBotServerPassword.value = ''; await store.fetchBots(); } catch { // Ignore @@ -598,6 +610,7 @@ async function openEditBot(bot: any) { editForm.nickname = res.data.nickname ?? ''; editForm.defaultChannel = res.data.defaultChannel ?? ''; editForm.channelPassword = res.data.channelPassword ?? ''; + editForm.serverPassword = res.data.serverPassword ?? ''; } catch { // Config not found — use defaults editForm.serverAddress = ''; @@ -605,6 +618,7 @@ async function openEditBot(bot: any) { editForm.nickname = bot.name; editForm.defaultChannel = ''; editForm.channelPassword = ''; + editForm.serverPassword = ''; } } From 6e828b9c2dbcc119ae90b57a53125946dc71b55a Mon Sep 17 00:00:00 2001 From: saopig1 Date: Fri, 10 Apr 2026 17:16:29 +0800 Subject: [PATCH 13/17] fix(ws): re-attach stateChange listeners when bot instance is replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startBot creates a fresh BotInstance but the WS layer keyed its listener map by bot.id, so ensureAllBotsAttached skipped the new object and stateChange events were never broadcast — the player bar only appeared after a manual refresh. BotManager now extends EventEmitter and emits "botInstance" whenever a new bot object is created; websocket.ts stores the bot reference, detaches on replacement, and subscribes to the event for immediate wiring. Also enlarges the bot selector (padding 10×20, font 16, min-height 44, bigger dot/chevron/state icons, wider name) and adds Playwright repro scripts for the player bar bug and navbar sizing check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + scripts/screenshot_navbar.py | 12 ++++ scripts/test_playbar.py | 105 ++++++++++++++++++++++++++++++++++ src/bot/manager.ts | 7 ++- src/web/websocket.ts | 39 +++++++++---- web/src/components/Navbar.vue | 18 +++--- 6 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 scripts/screenshot_navbar.py create mode 100644 scripts/test_playbar.py diff --git a/.gitignore b/.gitignore index 23a3dc6..7a588c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ config.json cookies/ .superpowers/ +/bin/ +scripts/navbar_bigger.png diff --git a/scripts/screenshot_navbar.py b/scripts/screenshot_navbar.py new file mode 100644 index 0000000..8326fe3 --- /dev/null +++ b/scripts/screenshot_navbar.py @@ -0,0 +1,12 @@ +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 900}) + page.goto("http://localhost:3000") + page.wait_for_load_state("networkidle") + page.wait_for_timeout(800) + page.locator(".navbar").screenshot(path="scripts/navbar_bigger.png") + rect = page.locator(".bot-selector-btn").bounding_box() + print("bot-selector-btn bbox:", rect) + browser.close() diff --git a/scripts/test_playbar.py b/scripts/test_playbar.py new file mode 100644 index 0000000..45dc41c --- /dev/null +++ b/scripts/test_playbar.py @@ -0,0 +1,105 @@ +"""Reproduce the player-bar-not-appearing bug.""" +import time +import requests +from playwright.sync_api import sync_playwright + +BASE = "http://localhost:3000" + +def get_bot_id(): + r = requests.get(f"{BASE}/api/bot/") + return r.json()["bots"][0]["id"] + +def stop(bot_id): + requests.post(f"{BASE}/api/player/{bot_id}/stop") + requests.post(f"{BASE}/api/player/{bot_id}/clear") + +def play(bot_id, query="test"): + requests.post(f"{BASE}/api/player/{bot_id}/play", json={"query": query, "platform": "netease"}) + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + ctx = browser.new_context() + ctx.add_init_script(""" + (() => { + const OrigWS = window.WebSocket; + window.__wsMessages = []; + window.WebSocket = function(...args) { + const ws = new OrigWS(...args); + ws.addEventListener('message', (ev) => { + try { + const d = JSON.parse(ev.data); + const summary = {type: d.type, botId: d.botId}; + if (d.status) summary.playing = d.status.playing; + if (d.status?.currentSong) summary.song = d.status.currentSong.name; + window.__wsMessages.push(summary); + console.log('WS_MSG ' + JSON.stringify(summary)); + } catch(e) {} + }); + return ws; + }; + Object.assign(window.WebSocket, OrigWS); + })(); + """) + page = ctx.new_page() + logs = [] + page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}")) + + bot_id = get_bot_id() + stop(bot_id) + time.sleep(0.6) + + page.goto(BASE) + page.wait_for_load_state("networkidle") + time.sleep(0.8) + + # Hook into WebSocket messages from the page to confirm they arrive + page.evaluate(""" + () => { + const origWS = window.WebSocket; + // Already connected via useWebSocket — tap Pinia store directly + // Expose store state via window + const store = window.__pinia?._s?.get('player'); + window.__getStore = () => ({ + bots: JSON.parse(JSON.stringify(store?.bots || [])), + activeBotId: store?.activeBotId, + currentSong: store?.currentSong ? JSON.parse(JSON.stringify(store.currentSong)) : null, + isPlaying: store?.isPlaying, + }); + } + """) + + snap_before = page.evaluate("() => window.__getStore?.()") + print("store before:", snap_before) + + # Player bar should be absent right now (nothing playing) + initial = page.locator(".player-wrapper").count() + print(f"initial .player-wrapper count: {initial}") + + # Trigger play via API (simulates any play trigger) + play(bot_id, "test") + + # Poll for up to 6s to see if player bar appears automatically + appeared_at = None + for i in range(60): + if page.locator(".player-wrapper").count() > 0: + appeared_at = i * 0.1 + break + page.wait_for_timeout(100) + + print(f"player bar appeared after: {appeared_at}") + snap_after = page.evaluate("() => window.__getStore?.()") + print("store after:", snap_after) + + # After force reload, does it appear? + if appeared_at is None: + page.reload() + page.wait_for_load_state("networkidle") + time.sleep(0.8) + after_reload = page.locator(".player-wrapper").count() + print(f"after reload .player-wrapper count: {after_reload}") + + print("--- console logs ---") + for line in logs[-20:]: + print(line) + + browser.close() diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 08b4c65..8e8acd9 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { EventEmitter } from "node:events"; import { BotInstance, type BotInstanceOptions, @@ -28,7 +29,7 @@ export interface CreateBotParams { serverPassword?: string; } -export class BotManager { +export class BotManager extends EventEmitter { private bots = new Map(); private neteaseProvider: MusicProvider; private qqProvider: MusicProvider; @@ -46,6 +47,7 @@ export class BotManager { config: BotConfig, logger: Logger ) { + super(); this.neteaseProvider = neteaseProvider; this.qqProvider = qqProvider; this.bilibiliProvider = bilibiliProvider; @@ -82,6 +84,7 @@ export class BotManager { }); this.bots.set(id, bot); + this.emit("botInstance", bot); this.database.saveBotInstance({ id, @@ -179,6 +182,7 @@ export class BotManager { logger: this.logger, }); this.bots.set(id, bot); + this.emit("botInstance", bot); await bot.connect(); // Mark as autoStart so it reconnects on Docker restart, and persist identity this.database.saveBotInstance({ ...saved, autoStart: true }); @@ -229,6 +233,7 @@ export class BotManager { }); this.bots.set(saved.id, bot); + this.emit("botInstance", bot); // Only auto-connect bots that have autoStart enabled if (saved.autoStart) { diff --git a/src/web/websocket.ts b/src/web/websocket.ts index fc8813d..1c52333 100644 --- a/src/web/websocket.ts +++ b/src/web/websocket.ts @@ -10,8 +10,9 @@ export function setupWebSocket( ): () => void { const clients = new Set(); - /** Track which bots already have listeners attached */ + /** Track which bot instances have listeners attached (keyed by id, storing ref) */ const attachedBots = new Map void; connected: () => void; disconnected: () => void; @@ -48,8 +49,22 @@ export function setupWebSocket( } }; + function detachBotListener(id: string): void { + const existing = attachedBots.get(id); + if (!existing) return; + existing.bot.removeListener("stateChange", existing.stateChange); + existing.bot.removeListener("connected", existing.connected); + existing.bot.removeListener("disconnected", existing.disconnected); + attachedBots.delete(id); + } + function attachBotListener(bot: BotInstance): void { - if (attachedBots.has(bot.id)) return; + const existing = attachedBots.get(bot.id); + if (existing) { + if (existing.bot === bot) return; // already attached to this instance + // Bot instance was replaced (e.g. startBot re-created it) — re-attach + detachBotListener(bot.id); + } const onStateChange = () => { broadcast({ @@ -81,6 +96,7 @@ export function setupWebSocket( bot.on("disconnected", onDisconnected); attachedBots.set(bot.id, { + bot, stateChange: onStateChange, connected: onConnected, disconnected: onDisconnected, @@ -94,21 +110,20 @@ export function setupWebSocket( } } - // Check for newly added bots periodically + // React immediately when a bot instance is created or replaced + const onBotInstance = (bot: BotInstance) => attachBotListener(bot); + botManager.on("botInstance", onBotInstance); + + // Safety net: periodically re-check in case any bot was missed const intervalId = setInterval(ensureAllBotsAttached, 5000); ensureAllBotsAttached(); return () => { clearInterval(intervalId); - // Clean up named listeners - for (const bot of botManager.getAllBots()) { - const listeners = attachedBots.get(bot.id); - if (listeners) { - bot.removeListener("stateChange", listeners.stateChange); - bot.removeListener("connected", listeners.connected); - bot.removeListener("disconnected", listeners.disconnected); - } + botManager.removeListener("botInstance", onBotInstance); + // Clean up all attached listeners (detach from stored bot refs, not live map) + for (const id of Array.from(attachedBots.keys())) { + detachBotListener(id); } - attachedBots.clear(); }; } diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index f381595..9e8d493 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -158,13 +158,13 @@ onUnmounted(() => { .bot-selector-btn { display: flex; align-items: center; - gap: 8px; - padding: 8px 16px; + gap: 10px; + padding: 10px 20px; background: var(--hover-bg); border-radius: var(--radius-md); - font-size: 14px; + font-size: 16px; font-weight: 600; - min-height: 36px; + min-height: 44px; border: 1px solid var(--border-color); transition: background var(--transition-fast), border-color var(--transition-fast); cursor: pointer; @@ -176,13 +176,13 @@ onUnmounted(() => { } .bot-state-mini { - font-size: 11px; + font-size: 14px; &.playing { color: #22c55e; } &.paused { color: #eab308; } } .bot-chevron { - font-size: 16px; + font-size: 20px; opacity: 0.5; transition: transform 0.2s ease; @@ -192,8 +192,8 @@ onUnmounted(() => { } .bot-dot { - width: 8px; - height: 8px; + width: 10px; + height: 10px; border-radius: 50%; background: var(--text-tertiary); flex-shrink: 0; @@ -204,7 +204,7 @@ onUnmounted(() => { } .bot-selector-name { - max-width: 120px; + max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; From 4643f70f4a9a47c1cc8abc085c4bc122ddb54f1c Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 01:35:14 +0800 Subject: [PATCH 14/17] fix: harden bot lifecycle, validate HTTP inputs, make YouTube truly optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major bug fixes and corner-case hardening across the backend, plus a comprehensive feature test suite. All 94 unit tests + 51 integration tests pass against a local TS3 server. Lifecycle & state consistency ----------------------------- - Bug A: startBot() now wraps connect() in a 15s deadline. A hung TS handshake no longer blocks the /start HTTP call forever; the failing instance is torn down and the caller gets a clean 500. - Bug B: executeCommand rejects audio-dispatching commands (play, add, next, skip, prev, playlist, album, fm) when the bot is disconnected. Config-only commands (vol, mode, clear, stop, queue, now, lyrics) still work so the UI stays usable while offline. - Bug C: the tsClient 'disconnected' handler always clears player state now, even when connect() never completed. A separate disconnectEmitted flag guards duplicate external event emission. Previously an orphaned connect attempt that idle-timed-out would leave playing=true forever. - resolveAndPlay re-checks this.connected AFTER the URL-resolve await so a stop() during the network call can't spawn ffmpeg on a disconnected bot. - connect() throws if disconnect() fired during the handshake await, preventing a concurrent stop from being overwritten by a late connected flag flip. - startBot always disconnects the outgoing BotInstance before creating a replacement, covering the mid-handshake case where isConnected() still returned false but the library client was live. - startBot now reuses the stored identity so server groups granted to the bot survive restarts (was regenerating a fresh UID each time). WebSocket reliability --------------------- - BotManager extends EventEmitter and emits 'botInstance' whenever a new instance is created. websocket.ts listens and re-attaches its stateChange / connected / disconnected listeners immediately, fixing the bug where player-bar UI never updated until manual refresh. - attachedBots map now stores the BotInstance reference and detaches stale listeners when the instance is replaced. Safety-net interval (5s) also reconciles to catch anything missed. - removeBot emits 'botInstanceRemoved' -> WS broadcasts a new {type:"botRemoved", botId} message. Client drops the bot from its local store instead of showing it as permanently offline. HTTP input validation --------------------- - /volume rejects non-number, NaN, Infinity, and out-of-range values with a proper 400 instead of a 200 OK wrapping a usage-text string. - /mode rejects anything not in {seq, loop, random, rloop} with 400. - /seek rejects NaN / Infinity / negative (previously NaN slipped through typeof==="number" and poisoned seekOffset). - /play-at validates index < queue.size() BEFORE stopping current playback (was silently killing the current song on invalid input). - /play, /add, /playlist, /play-by-id, /add-by-id, /play-playlist all honour platform=youtube now (previously fell through to netease and silently played the wrong platform). YouTube made truly optional --------------------------- - Lazy checkYtDlpAvailable() runs `yt-dlp --version` once, caches only positive results so users can install yt-dlp mid-run and have it picked up without a restart. - getAuthStatus() returns loggedIn=false with nickname "YouTube (yt-dlp not installed)" when the binary is missing. UI can grey out YouTube instead of silently returning empty searches. - findYtDlp() picks .exe on win32 and bare binary elsewhere. - /auth/status?platform=youtube now routes to the YouTube provider instead of falling through to NetEase and leaking the NetEase user's nickname + avatar. - /auth/cookie rejects platform=youtube with 400 instead of clobbering the NetEase cookie entry. - README documents yt-dlp install paths (bin/ local vs PATH) and adds a dedicated "Optional: YouTube source" section. Bot Selector UI --------------- - New power button in each row of the dropdown with play-state-aware styling: disabled + wait-cursor during API call, green highlight when connected, greys out when the bot is offline. - Dropdown always visible when >=1 bot exists, bigger font + padding. Queue correctness ----------------- - PlayQueue.remove(current) now decrements currentIndex so next() in sequential mode advances to the shifted song. Previously removing the currently-playing track silently skipped the next track because current() falsely reported it as active and next() then incremented past it. Vote-skip hardening ------------------- - cmdVote: needed threshold is Math.max(1, ceil(users/2)) so a single voter in an empty channel can't unanimously pass a vote with needed=0. - resolveAndPlay clears voteSkipUsers on every new track load so votes can't leak across songs via cmdPlay/cmdPlaylist/cmdAlbum/cmdFm paths. cmdAdd parity ------------- - cmdAdd auto-plays the newly-added song if the player was idle, matching /api/player/:id/add-by-id behaviour. Previously add'ing to an empty queue on a connected+idle bot silently enqueued without starting playback. Test suite ---------- - scripts/test_full_feature.py — 51 tests across 10 groups exercising every HTTP endpoint, WebSocket broadcasts, all music providers, bot lifecycle, disconnected-state corners, seek validation, input validation, and the main race conditions. Captures and restores the target bot's initial state. Resilient to TS3 anti-flood via retry with exponential backoff. Runs against a real local TS3 server. - scripts/test_rapid_cycle.py — Bugs A/B/C regressions - scripts/test_corner_cases.py — disconnect-during-connect race, config commands while disconnected, etc. - scripts/test_more_corners.py — resolveAndPlay race, seek NaN - scripts/test_power_button.py — E2E for the new power button - scripts/test_bot_remove.py — E2E for WS botRemoved broadcast - scripts/test_playbar.py — player bar auto-show regression (updated to restore bot state on exit) - scripts/test_multibot.py — two-bot concurrent playback monitor - src/audio/queue.test.ts — 4 new vitest cases for remove() edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 67 ++- scripts/test_bot_remove.py | 99 ++++ scripts/test_corner_cases.py | 171 ++++++ scripts/test_full_feature.py | 852 ++++++++++++++++++++++++++++ scripts/test_more_corners.py | 166 ++++++ scripts/test_multibot.py | 212 +++++++ scripts/test_playbar.py | 184 +++--- scripts/test_power_button.py | 100 ++++ scripts/test_rapid_cycle.py | 130 +++++ src/audio/player.ts | 7 + src/audio/queue.test.ts | 43 ++ src/audio/queue.ts | 11 +- src/bot/instance.ts | 85 ++- src/bot/manager.ts | 57 +- src/music/youtube.ts | 62 +- src/web/api/auth.ts | 13 + src/web/api/player.ts | 92 ++- src/web/websocket.ts | 21 +- web/src/components/Navbar.vue | 52 ++ web/src/composables/useWebSocket.ts | 7 + web/src/stores/player.ts | 8 + 21 files changed, 2305 insertions(+), 134 deletions(-) create mode 100644 scripts/test_bot_remove.py create mode 100644 scripts/test_corner_cases.py create mode 100644 scripts/test_full_feature.py create mode 100644 scripts/test_more_corners.py create mode 100644 scripts/test_multibot.py create mode 100644 scripts/test_power_button.py create mode 100644 scripts/test_rapid_cycle.py diff --git a/README.md b/README.md index 60954c2..43fb301 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

TSMusicBot

- TeamSpeak 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板 + TeamSpeak 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板

@@ -16,6 +16,7 @@ +

@@ -55,11 +56,19 @@ - 修复 `TS6HttpQuery.request()` 双重 reject 问题 - 添加重复 `connect()` 调用的保护(先断开旧连接) +### YouTube 音源(可选) + +新增基于 `yt-dlp` 的 YouTube 音源,**默认未启用**。安装 `yt-dlp` 后可通过 `!play -y <关键词>` 或 WebUI 平台选项使用。详见 [可选:YouTube 音源](#可选youtube-音源) 章节的安装步骤。 + +- `src/music/youtube.ts` — YouTubeProvider(通过 `yt-dlp --dump-json` 搜索、`--get-url` 取直链) +- 未安装 `yt-dlp` 时搜索静默返回空结果,不影响其他音源 +- 服务器密码登录(`serverPassword` 字段)、Bot 选择器 UI 改进等特性见 git log + --- ## 功能特性 -- **三平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩,统一搜索,结果标注来源 +- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源 - **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 @@ -254,6 +263,7 @@ sudo systemctl start tsmusicbot | `!play <歌名>` | 搜索并播放 | | `!play -q <歌名>` | 从 QQ 音乐搜索 | | `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 | +| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))| | `!add <歌名>` | 添加到播放队列 | | `!pause` / `!resume` | 暂停 / 恢复播放 | | `!next` / `!prev` | 下一首 / 上一首 | @@ -306,6 +316,7 @@ tsmusicbot/ │ │ ├── netease.ts # 网易云音乐适配器 │ │ ├── qq.ts # QQ 音乐适配器 │ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取) +│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp) │ │ ├── auth.ts # Cookie 持久化存储 │ │ └── api-server.ts # 嵌入式 API 服务(自动启动) │ ├── ts-protocol/ # TeamSpeak 客户端协议(TS3/TS6 双协议) @@ -359,6 +370,55 @@ tsmusicbot/ | **图标** | @iconify/vue | | **日志** | pino | +## 可选:YouTube 音源 + +YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。 + +### 启用方式(任选其一) + +**方式一:项目本地 `bin/` 目录(推荐)** + +将 `yt-dlp` 可执行文件放到项目根目录下的 `bin/` 文件夹,程序会优先使用此路径。该目录已被 `.gitignore` 忽略,不会影响代码更新。 + +```bash +# Windows(PowerShell 或 Git Bash) +mkdir bin +curl -L -o bin/yt-dlp.exe https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe + +# Linux / macOS +mkdir -p bin +curl -L -o bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp +chmod +x bin/yt-dlp +``` + +**方式二:系统级安装(让 `yt-dlp` 在 `PATH` 中可用)** + +```bash +# Windows +winget install yt-dlp + +# macOS +brew install yt-dlp + +# Debian/Ubuntu +sudo apt install yt-dlp + +# 通用(Python 环境下) +pip install -U yt-dlp +``` + +### 验证是否可用 + +重启机器人程序,在 WebUI 或 `!play -y lofi` 测试搜索。若 `bin/` 和 `PATH` 中都找不到 `yt-dlp`,YouTube 搜索会静默返回空结果(不会影响其他音源),其余功能正常。 + +### 注意事项 + +- YouTube 音源通过 `yt-dlp` 本地调用实现,不依赖 API Key,也无需登录 +- 播放的是视频的最佳音频流(`bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio`),由 FFmpeg 解码 +- 音质由源视频决定,不受音质设置影响 +- 受 YouTube 风控/地域限制,部分视频可能无法播放 +- `yt-dlp` 更新较频繁,如果播放失败,先尝试升级 `yt-dlp` 到最新版本 + ## 配置文件 `config.json` 在首次运行时自动生成,可手动编辑: @@ -412,6 +472,9 @@ A:原生模块(opus、sqlite3)需要编译工具,Dockerfile 已包含。 **Q:B站视频搜索不到结果?** A:B站搜索需要 buvid3 匿名 Cookie(程序启动时自动获取)。如果失败,重启程序即可。登录B站账号后搜索效果更好。 +**Q:YouTube 平台搜索返回空结果?** +A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:YouTube 音源](#可选youtube-音源) 章节。快速验证:在项目根目录执行 `bin/yt-dlp --version`(或系统 `yt-dlp --version`),能打印版本号即可。若 yt-dlp 已安装但仍搜索失败,通常是网络/地域问题或 yt-dlp 版本过旧(执行 `yt-dlp -U` 升级)。 + **Q:如何更新到新版本?** A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。 diff --git a/scripts/test_bot_remove.py b/scripts/test_bot_remove.py new file mode 100644 index 0000000..8c2c2a7 --- /dev/null +++ b/scripts/test_bot_remove.py @@ -0,0 +1,99 @@ +"""Regression: DELETE /api/bot/:id must broadcast botRemoved so the UI drops the row. + +Creates an ephemeral bot, opens the dropdown, deletes the bot via API, and +asserts the row disappears without any page reload. Does not touch any +existing user bot. +""" +import time +import requests +from playwright.sync_api import sync_playwright + +BASE = "http://localhost:3000" +EPHEMERAL_NAME = "rmbot_test" +EPHEMERAL_NICK = "RmBotTest" + + +def api(path, method="GET", **kw): + r = getattr(requests, method.lower())(f"{BASE}{path}", timeout=10, **kw) + r.raise_for_status() + return r.json() if r.text else None + + +def cleanup(): + for b in api("/api/bot/")["bots"]: + if b["name"] == EPHEMERAL_NAME: + try: + api(f"/api/bot/{b['id']}", method="DELETE") + except Exception: + pass + + +def main(): + cleanup() + new = api( + "/api/bot/", + method="POST", + json={ + "name": EPHEMERAL_NAME, + "serverAddress": "127.0.0.1", + "serverPort": 9987, + "nickname": EPHEMERAL_NICK, + "autoStart": False, + }, + ) + bot_id = new["id"] + print(f"[setup] created ephemeral bot {bot_id[:8]}") + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={"width": 1440, "height": 900}) + page.goto(BASE) + page.wait_for_load_state("networkidle") + time.sleep(0.8) + + # Open dropdown and confirm the new bot row is present + page.locator(".bot-selector-btn").click() + page.wait_for_selector(".bot-dropdown") + rows_before = page.locator(".bot-dropdown-row").count() + print(f"[ui] dropdown rows before remove: {rows_before}") + + # Match the ephemeral row by its name text + present = ( + page.locator(".bot-dropdown-row", has_text=EPHEMERAL_NAME).count() + ) + assert present == 1, f"ephemeral row not found (got {present})" + + # Delete via API + api(f"/api/bot/{bot_id}", method="DELETE") + print("[api] deleted bot") + + # Wait up to 4s for UI to drop the row + removed = False + for _ in range(40): + if ( + page.locator( + ".bot-dropdown-row", has_text=EPHEMERAL_NAME + ).count() + == 0 + ): + removed = True + break + time.sleep(0.1) + + rows_after = page.locator(".bot-dropdown-row").count() + print(f"[ui] dropdown rows after remove: {rows_after}") + assert removed, "ephemeral row did not disappear from UI after DELETE" + assert rows_after == rows_before - 1, ( + f"row count mismatch: before={rows_before} after={rows_after}" + ) + print("[PASS] bot removal propagates to UI via WS") + finally: + browser.close() + finally: + cleanup() + + +if __name__ == "__main__": + main() diff --git a/scripts/test_corner_cases.py b/scripts/test_corner_cases.py new file mode 100644 index 0000000..3815b7e --- /dev/null +++ b/scripts/test_corner_cases.py @@ -0,0 +1,171 @@ +"""Corner case regressions that go beyond Bugs A/B/C. + +A. Race — disconnect() called during connect()'s awaited handshake must + NOT leave the bot reporting connected=true afterwards. + +B. Config-only commands (vol, mode, clear) must work even when the bot is + disconnected (UI should stay usable while the bot is offline). + +C. After a disconnect mid-playback, the player must not be able to + auto-advance to the next queued song (trackEnd → resolveAndPlay). +""" +import time +import threading +import requests + +BASE = "http://localhost:3000" + + +def api(path, method="GET", **kw): + return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw) + + +def get_bot(bot_id): + return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id) + + +def wait_connected(bot_id, want, timeout=15): + end = time.time() + timeout + while time.time() < end: + if get_bot(bot_id)["connected"] is want: + return True + time.sleep(0.1) + return False + + +def test_config_commands_when_disconnected(bot_id): + """B. vol/mode/clear should succeed while bot is disconnected.""" + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + + # volume is the simplest config-only command + r = api( + f"/api/player/{bot_id}/volume", + method="POST", + json={"volume": 60}, + ) + assert r.status_code == 200, f"volume failed while disconnected: {r.status_code} {r.text[:100]}" + + r = api( + f"/api/player/{bot_id}/mode", + method="POST", + json={"mode": "seq"}, + ) + assert r.status_code == 200, f"mode failed while disconnected: {r.status_code} {r.text[:100]}" + + r = api(f"/api/player/{bot_id}/clear", method="POST") + assert r.status_code == 200, f"clear failed while disconnected: {r.status_code} {r.text[:100]}" + + print("[PASS] config commands (vol/mode/clear) work when disconnected") + + +def test_play_rejected_when_disconnected(bot_id): + """B (negative). play/add/next/prev should still be rejected.""" + r = api( + f"/api/player/{bot_id}/play", + method="POST", + json={"query": "test", "platform": "netease"}, + ) + assert r.status_code >= 400, f"play should fail while disconnected" + + r = api( + f"/api/player/{bot_id}/add", + method="POST", + json={"query": "test", "platform": "netease"}, + ) + assert r.status_code >= 400, f"add should fail while disconnected" + + r = api(f"/api/player/{bot_id}/next", method="POST") + assert r.status_code >= 400, f"next should fail while disconnected" + + print("[PASS] audio commands (play/add/next) rejected when disconnected") + + +def test_disconnect_during_connect_race(bot_id): + """A. disconnect() called while connect() is awaiting must win the race. + + Fires a stop 200ms into a start call; after things settle the bot's + connected state must be stable (either cleanly disconnected, or cleanly + connected if the stop happened after connect completed). It must NOT + end up in a weird state where connected=true but a subsequent query + shows inconsistent data. + """ + # Ensure disconnected first + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + time.sleep(1) # give TS server a moment to forget us + + def delayed_stop(): + time.sleep(0.2) + try: + api(f"/api/bot/{bot_id}/stop", method="POST") + except Exception: + pass + + threading.Thread(target=delayed_stop, daemon=True).start() + try: + r = api(f"/api/bot/{bot_id}/start", method="POST") + except Exception as e: + r = None + print(f"[info] start threw: {e}") + + # Wait for all state transitions to settle + time.sleep(2) + + b = get_bot(bot_id) + # The key invariant: if connected is false, playing must also be false; + # if connected is true, the transport is actually up (we can issue + # another command without error). + assert not (b["connected"] is False and b["playing"] is True), ( + f"inconsistent state: connected={b['connected']} playing={b['playing']}" + ) + print( + f"[PASS] disconnect-during-connect race — final state consistent " + f"(connected={b['connected']} playing={b['playing']})" + ) + + +def test_resolve_guard(bot_id): + """C. resolveAndPlay on a disconnected bot is a no-op. + + We can't directly invoke resolveAndPlay from the API, but we can + verify by checking that after a stop, the bot stays idle even if we + wait for a trackEnd-like event to fire. + """ + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + time.sleep(1.5) # more than a frame cycle + b = get_bot(bot_id) + assert not b["playing"], ( + f"player should stay stopped after disconnect: {b}" + ) + print("[PASS] player stays idle after disconnect (no ghost autoplay)") + + +def main(): + bots = api("/api/bot/").json()["bots"] + if not bots: + print("[skip] no bots") + return + bot_id = bots[0]["id"] + initial = bots[0]["connected"] + print(f"[init] bot={bot_id[:8]} initial connected={initial}") + + try: + test_config_commands_when_disconnected(bot_id) + test_play_rejected_when_disconnected(bot_id) + test_resolve_guard(bot_id) + test_disconnect_during_connect_race(bot_id) + print("ALL GREEN") + finally: + if initial: + api(f"/api/bot/{bot_id}/start", method="POST") + wait_connected(bot_id, True) + else: + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + print(f"[restore] connected={get_bot(bot_id)['connected']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_full_feature.py b/scripts/test_full_feature.py new file mode 100644 index 0000000..f364dde --- /dev/null +++ b/scripts/test_full_feature.py @@ -0,0 +1,852 @@ +"""Comprehensive feature + corner-case test for TSMusicBot against a +local TeamSpeak 3 server. + +Exercises every major HTTP endpoint, the WebSocket state-broadcast path, +all music providers, bot lifecycle transitions, and a handful of races +that have burned us in the past. Designed to be safe to run against a +real installation: captures the target bot's initial connected/volume/ +mode settings and restores them in `finally`. + +Usage: + "C:/Users/saopig1/miniforge3/python.exe" scripts/test_full_feature.py + +Exit code: 0 if every non-skipped test passed, 1 otherwise. +""" +from __future__ import annotations + +import json +import threading +import time +from dataclasses import dataclass +from typing import Any + +import requests + +BASE = "http://localhost:3000" +POLL_INTERVAL = 0.15 +CONNECT_TIMEOUT = 20 # tolerate occasional TS3 anti-flood grace +ANTIFLOOD_BREATHER = 1.5 # gap between rapid cycles so TS3 stays happy + + +# ----------------------------- HTTP helpers --------------------------------- + + +def api(path: str, method: str = "GET", json_body: Any = None): + """Return (status_code, body). Never raises.""" + try: + fn = getattr(requests, method.lower()) + r = fn(f"{BASE}{path}", json=json_body, timeout=30) + try: + return r.status_code, r.json() + except Exception: + return r.status_code, r.text + except Exception as e: + return None, f"<{type(e).__name__}: {e}>" + + +def get_bot(bot_id: str) -> dict | None: + _, data = api("/api/bot/") + if not isinstance(data, dict): + return None + return next((b for b in data.get("bots", []) if b["id"] == bot_id), None) + + +def wait_connected(bot_id: str, want: bool, timeout: float = CONNECT_TIMEOUT) -> bool: + end = time.time() + timeout + while time.time() < end: + b = get_bot(bot_id) + if b is not None and b["connected"] is want: + return True + time.sleep(POLL_INTERVAL) + return False + + +def start_and_wait(bot_id: str, retries: int = 2) -> bool: + """Start the bot, tolerating transient TS3 anti-flood by retrying with + exponential backoff. Returns True only when the bot reports connected.""" + for attempt in range(retries + 1): + s, _ = api(f"/api/bot/{bot_id}/start", method="POST") + if s == 200 and wait_connected(bot_id, True): + return True + # If /start returned an error (e.g. connect timeout from our 15s + # deadline), back off and retry — TS3 server-side anti-flood + # usually clears in a few seconds. + if attempt < retries: + time.sleep(3.0 * (attempt + 1)) + # Make sure we're fully stopped before the next attempt so + # oldBot.disconnect() doesn't double-fire + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False, timeout=5) + return False + + +def stop_and_wait(bot_id: str) -> bool: + api(f"/api/bot/{bot_id}/stop", method="POST") + return wait_connected(bot_id, False) + + +def assert_started(bot_id: str): + """Helper that raises with a clear message when start fails so test + output points at 'could not connect' rather than an empty assertion.""" + if not start_and_wait(bot_id): + raise AssertionError( + "could not bring bot online (TS3 server may be anti-flooding " + "or unreachable)" + ) + + +# ----------------------------- test runner ---------------------------------- + + +@dataclass +class TestResult: + name: str + status: str # PASS / FAIL / ERROR / SKIP + detail: str = "" + + +passed = 0 +failed = 0 +skipped = 0 +results: list[TestResult] = [] + + +def run(name: str, fn): + global passed, failed + try: + fn() + print(f" [PASS] {name}") + passed += 1 + results.append(TestResult(name, "PASS")) + except AssertionError as e: + print(f" [FAIL] {name}: {e}") + failed += 1 + results.append(TestResult(name, "FAIL", str(e))) + except Exception as e: + print(f" [ERROR] {name}: {type(e).__name__}: {e}") + failed += 1 + results.append(TestResult(name, "ERROR", f"{type(e).__name__}: {e}")) + + +def skip(name: str, reason: str): + global skipped + print(f" [SKIP] {name} ({reason})") + skipped += 1 + results.append(TestResult(name, "SKIP", reason)) + + +# ----------------------------- test groups ---------------------------------- + + +def group_infrastructure(bot_id: str): + print("\n== infrastructure ==") + + def t_health(): + s, d = api("/api/health") + assert s == 200, f"health returned {s}" + assert d.get("status") == "ok" + run("GET /api/health", t_health) + + def t_list_bots(): + s, d = api("/api/bot/") + assert s == 200 + assert isinstance(d.get("bots"), list) + assert any(b["id"] == bot_id for b in d["bots"]) + run("GET /api/bot/ lists target bot", t_list_bots) + + def t_bot_config(): + s, d = api(f"/api/bot/{bot_id}/config") + assert s == 200 + assert d["id"] == bot_id + assert "identity" in d + assert "serverAddress" in d and "nickname" in d + assert "serverPassword" in d, "serverPassword field missing" + run("GET /api/bot/:id/config returns all fields", t_bot_config) + + def t_404_on_unknown_bot(): + s, _ = api("/api/bot/does-not-exist/config") + assert s == 404 + run("404 on unknown bot id", t_404_on_unknown_bot) + + def t_quality_shape(): + s, d = api("/api/music/quality") + assert s == 200 + for p in ("netease", "qq", "bilibili"): + assert p in d, f"{p} missing from quality response" + run("GET /api/music/quality shape", t_quality_shape) + + +def group_auth_status(): + print("\n== auth status per platform ==") + + def t_netease_ok(): + s, d = api("/api/auth/status?platform=netease") + assert s == 200 + assert d.get("platform") == "netease" + assert "loggedIn" in d + run("auth status netease", t_netease_ok) + + def t_qq_ok(): + s, d = api("/api/auth/status?platform=qq") + assert s == 200 + assert d.get("platform") == "qq" + run("auth status qq", t_qq_ok) + + def t_bilibili_ok(): + s, d = api("/api/auth/status?platform=bilibili") + assert s == 200 + assert d.get("platform") == "bilibili" + run("auth status bilibili", t_bilibili_ok) + + def t_youtube_routed(): + # Regression: /auth/status?platform=youtube used to fall through + # to NetEase and leak the NetEase user's nickname/avatar. + s, d = api("/api/auth/status?platform=youtube") + assert s == 200 + assert d.get("platform") == "youtube", ( + f"youtube auth status leaked to {d.get('platform')}" + ) + run("auth status youtube routes correctly", t_youtube_routed) + + def t_youtube_cookie_rejected(): + s, d = api( + "/api/auth/cookie", + method="POST", + json_body={"platform": "youtube", "cookie": "fake"}, + ) + assert s == 400, f"youtube cookie should be rejected, got {s}: {d}" + run("POST /auth/cookie rejects youtube", t_youtube_cookie_rejected) + + +def group_search(): + print("\n== multi-platform search ==") + + def search(platform: str, query: str = "test"): + return api(f"/api/music/search?q={query}&platform={platform}&limit=1") + + def t_netease(): + s, d = search("netease") + assert s == 200 + assert isinstance(d.get("songs"), list) + run("netease search", t_netease) + + def t_qq(): + s, d = search("qq") + assert s == 200 + # QQ may return 0 results if no cookie, but shouldn't error + assert isinstance(d.get("songs"), list) + run("qq search (empty ok)", t_qq) + + def t_bilibili(): + s, d = search("bilibili") + assert s == 200 + assert isinstance(d.get("songs"), list) + run("bilibili search", t_bilibili) + + def t_missing_query(): + s, _ = api("/api/music/search?platform=netease") + assert s == 400, "missing q should 400" + run("400 on missing query", t_missing_query) + + _, auth = api("/api/auth/status?platform=youtube") + youtube_available = isinstance(auth, dict) and auth.get("loggedIn") is True + + if youtube_available: + def t_youtube(): + s, d = search("youtube", "lofi") + assert s == 200 + songs = d.get("songs", []) + assert len(songs) >= 1, "expected at least 1 YouTube result" + assert songs[0]["platform"] == "youtube" + run("youtube search (yt-dlp installed)", t_youtube) + else: + skip("youtube search", "yt-dlp not installed") + + +def group_lifecycle(bot_id: str): + print("\n== connection lifecycle ==") + + def t_stop_from_any_state(): + api(f"/api/bot/{bot_id}/stop", method="POST") + assert wait_connected(bot_id, False), "bot did not stop" + b = get_bot(bot_id) + assert not b["playing"], "playing should be false after stop" + run("stop from any state \u2192 disconnected+idle", t_stop_from_any_state) + + def t_start_completes_quickly(): + t0 = time.time() + s, d = api(f"/api/bot/{bot_id}/start", method="POST") + elapsed = time.time() - t0 + assert s == 200, f"start failed: {d}" + assert elapsed < 10, f"start took {elapsed:.1f}s (expected <10s)" + assert wait_connected(bot_id, True) + run("start completes well under 15s deadline", t_start_completes_quickly) + + def t_identity_persists(): + assert_started(bot_id) + _, cfg1 = api(f"/api/bot/{bot_id}/config") + id1 = cfg1["identity"] + assert id1, "identity empty after first start" + assert stop_and_wait(bot_id) + time.sleep(ANTIFLOOD_BREATHER) + assert_started(bot_id) + _, cfg2 = api(f"/api/bot/{bot_id}/config") + assert cfg2["identity"] == id1, ( + f"identity changed across restart: {id1} \u2192 {cfg2['identity']}" + ) + run("identity preserved across stop/start", t_identity_persists) + + +def group_playback(bot_id: str): + print("\n== playback ==") + + # Bring the bot online ONCE for the whole playback group, then only + # toggle player state (play/pause/stop) between tests. This keeps the + # TS3 reconnect count for this group at exactly 1. + assert_started(bot_id) + + def t_play_song(): + s, d = api( + f"/api/player/{bot_id}/play", + method="POST", + json_body={"query": "the mass", "platform": "netease"}, + ) + assert s == 200, f"play failed: {d}" + time.sleep(1.2) + b = get_bot(bot_id) + assert b["playing"] is True, f"not playing after /play: {b}" + assert b["currentSong"] is not None + run("play netease song \u2192 playing=true", t_play_song) + + def t_pause_resume(): + # Previous test left a song playing + api(f"/api/player/{bot_id}/pause", method="POST") + time.sleep(0.4) + b = get_bot(bot_id) + assert b["paused"] is True, f"pause failed: {b}" + api(f"/api/player/{bot_id}/resume", method="POST") + time.sleep(0.4) + b = get_bot(bot_id) + assert b["paused"] is False and b["playing"] is True, f"resume failed: {b}" + run("pause \u2192 paused, resume \u2192 playing", t_pause_resume) + + def t_volume_change(): + s, _ = api( + f"/api/player/{bot_id}/volume", + method="POST", + json_body={"volume": 42}, + ) + assert s == 200 + time.sleep(0.2) + b = get_bot(bot_id) + assert b["volume"] == 42, f"volume not applied: {b['volume']}" + run("volume change", t_volume_change) + + def t_mode_cycle(): + for m in ("seq", "loop", "random", "rloop"): + s, _ = api( + f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": m} + ) + assert s == 200 + b = get_bot(bot_id) + assert b["playMode"] == m, f"mode {m} not applied: {b['playMode']}" + run("all four play modes apply", t_mode_cycle) + + def t_queue_endpoint(): + s, d = api(f"/api/player/{bot_id}/queue") + assert s == 200 + assert isinstance(d.get("queue"), list) + assert "status" in d + run("GET /player/:id/queue returns queue+status", t_queue_endpoint) + + def t_elapsed_endpoint(): + s, d = api(f"/api/player/{bot_id}/elapsed") + assert s == 200 + elapsed = d.get("elapsed") + assert isinstance(elapsed, (int, float)) and elapsed >= 0, ( + f"elapsed should be non-negative number: {elapsed}" + ) + run("GET /player/:id/elapsed returns finite number", t_elapsed_endpoint) + + def t_add_autoplay_on_idle(): + # This specific test needs an IDLE bot — stop first (but keep + # connected), then add and confirm auto-play. + api(f"/api/player/{bot_id}/stop", method="POST") + time.sleep(0.4) + b = get_bot(bot_id) + assert not b["playing"] and b["queueSize"] == 0, f"setup failed: {b}" + s, d = api( + f"/api/player/{bot_id}/add", + method="POST", + json_body={"query": "the mass", "platform": "netease"}, + ) + assert s == 200 + msg = d.get("message", "") if isinstance(d, dict) else "" + assert "Now playing" in msg, ( + f"add on idle bot should auto-play, got: {msg!r}" + ) + time.sleep(0.8) + b = get_bot(bot_id) + assert b["playing"] is True, f"not playing after add: {b}" + run("add on idle bot auto-plays", t_add_autoplay_on_idle) + + # Leave the bot in a clean state for the next group + api(f"/api/player/{bot_id}/stop", method="POST") + + +def group_queue_ops(bot_id: str): + print("\n== queue operations ==") + assert_started(bot_id) + + def t_clear(): + api( + f"/api/player/{bot_id}/play", + method="POST", + json_body={"query": "the mass", "platform": "netease"}, + ) + time.sleep(0.8) + api( + f"/api/player/{bot_id}/add", + method="POST", + json_body={"query": "lemon tree", "platform": "netease"}, + ) + time.sleep(0.6) + b_before = get_bot(bot_id) + assert b_before["queueSize"] >= 2, f"expected \u22652 songs: {b_before}" + api(f"/api/player/{bot_id}/clear", method="POST") + time.sleep(0.4) + b_after = get_bot(bot_id) + assert b_after["queueSize"] == 0 + run("clear queue empties it", t_clear) + + def t_play_at_invalid_preserves_playback(): + api( + f"/api/player/{bot_id}/play", + method="POST", + json_body={"query": "the mass", "platform": "netease"}, + ) + time.sleep(1.2) + assert get_bot(bot_id)["playing"] + s, _ = api( + f"/api/player/{bot_id}/play-at", + method="POST", + json_body={"index": 9999}, + ) + assert s == 400, f"invalid index should 400, got {s}" + time.sleep(0.4) + b = get_bot(bot_id) + assert b["playing"], "invalid play-at killed the current song" + run("invalid play-at preserves current playback", t_play_at_invalid_preserves_playback) + + def t_play_at_negative_rejected(): + s, _ = api( + f"/api/player/{bot_id}/play-at", + method="POST", + json_body={"index": -1}, + ) + assert s == 400 + run("play-at with negative index rejected", t_play_at_negative_rejected) + + api(f"/api/player/{bot_id}/stop", method="POST") + + +def group_input_validation(bot_id: str): + print("\n== HTTP input validation ==") + + def t_volume_out_of_range(): + for bad in (150, -10, 1000, -1): + s, _ = api( + f"/api/player/{bot_id}/volume", + method="POST", + json_body={"volume": bad}, + ) + assert s == 400, f"volume={bad} should 400, got {s}" + run("volume out-of-range rejected (400)", t_volume_out_of_range) + + def t_volume_wrong_type(): + for bad in ("50", None, [50], {"v": 50}): + s, _ = api( + f"/api/player/{bot_id}/volume", + method="POST", + json_body={"volume": bad}, + ) + assert s == 400, f"volume={bad!r} should 400, got {s}" + run("volume wrong-type rejected (400)", t_volume_wrong_type) + + def t_volume_missing(): + s, _ = api( + f"/api/player/{bot_id}/volume", method="POST", json_body={} + ) + assert s == 400 + run("volume missing rejected (400)", t_volume_missing) + + def t_volume_valid(): + for good in (0, 1, 50, 100): + s, _ = api( + f"/api/player/{bot_id}/volume", + method="POST", + json_body={"volume": good}, + ) + assert s == 200, f"volume={good} should succeed, got {s}" + b = get_bot(bot_id) + assert b["volume"] == good, f"volume not applied: {b['volume']}" + run("valid volumes apply", t_volume_valid) + + def t_mode_invalid(): + for bad in ("bogus", "", None, 1, "SEQ"): + s, _ = api( + f"/api/player/{bot_id}/mode", + method="POST", + json_body={"mode": bad}, + ) + assert s == 400, f"mode={bad!r} should 400, got {s}" + run("mode invalid rejected (400)", t_mode_invalid) + + def t_mode_missing(): + s, _ = api(f"/api/player/{bot_id}/mode", method="POST", json_body={}) + assert s == 400 + run("mode missing rejected (400)", t_mode_missing) + + +def group_disconnect_corners(bot_id: str): + print("\n== disconnected-bot corners ==") + + def t_play_rejected(): + assert stop_and_wait(bot_id) + s, d = api( + f"/api/player/{bot_id}/play", + method="POST", + json_body={"query": "x", "platform": "netease"}, + ) + assert s >= 400 + err = (d.get("error") or "") if isinstance(d, dict) else "" + assert "not connected" in err.lower(), f"expected 'not connected' error: {d}" + run("play rejected while disconnected", t_play_rejected) + + def t_add_rejected(): + s, _ = api( + f"/api/player/{bot_id}/add", + method="POST", + json_body={"query": "x", "platform": "netease"}, + ) + assert s >= 400 + run("add rejected while disconnected", t_add_rejected) + + def t_next_rejected(): + s, _ = api(f"/api/player/{bot_id}/next", method="POST") + assert s >= 400 + run("next rejected while disconnected", t_next_rejected) + + def t_volume_allowed(): + s, _ = api( + f"/api/player/{bot_id}/volume", + method="POST", + json_body={"volume": 60}, + ) + assert s == 200, "volume should work while disconnected" + run("volume allowed while disconnected", t_volume_allowed) + + def t_mode_allowed(): + s, _ = api( + f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": "random"} + ) + assert s == 200, "mode should work while disconnected" + run("mode allowed while disconnected", t_mode_allowed) + + def t_clear_allowed(): + s, _ = api(f"/api/player/{bot_id}/clear", method="POST") + assert s == 200, "clear should work while disconnected" + run("clear allowed while disconnected", t_clear_allowed) + + def t_player_state_clean(): + b = get_bot(bot_id) + assert not b["playing"] and not b["paused"], f"state leak: {b}" + run("no player state leak while disconnected", t_player_state_clean) + + +def group_seek(bot_id: str): + print("\n== seek validation ==") + + def t_negative(): + s, _ = api( + f"/api/player/{bot_id}/seek", method="POST", json_body={"position": -5} + ) + assert s == 400 + run("negative seek rejected", t_negative) + + def t_string(): + s, _ = api( + f"/api/player/{bot_id}/seek", + method="POST", + json_body={"position": "abc"}, + ) + assert s == 400 + run("string seek rejected", t_string) + + def t_nan_literal(): + r = requests.post( + f"{BASE}/api/player/{bot_id}/seek", + data='{"position": NaN}', + headers={"Content-Type": "application/json"}, + timeout=10, + ) + assert r.status_code >= 400, f"NaN literal accepted: {r.status_code}" + run("NaN literal seek rejected", t_nan_literal) + + def t_valid_seek(): + # Seek needs a live connection + playing song. The disconnect- + # corners group right before this one left the bot disconnected. + assert_started(bot_id) + api( + f"/api/player/{bot_id}/play", + method="POST", + json_body={"query": "the mass", "platform": "netease"}, + ) + time.sleep(1.5) + s, _ = api( + f"/api/player/{bot_id}/seek", + method="POST", + json_body={"position": 25}, + ) + assert s == 200 + time.sleep(0.5) + _, d = api(f"/api/player/{bot_id}/elapsed") + elapsed = d.get("elapsed") + assert isinstance(elapsed, (int, float)) and 24 <= elapsed < 40, ( + f"elapsed after seek(25) wrong: {elapsed}" + ) + api(f"/api/player/{bot_id}/stop", method="POST") + run("valid seek produces finite elapsed", t_valid_seek) + + +def group_races(bot_id: str): + print("\n== race conditions ==") + + def t_disconnect_during_connect(): + assert stop_and_wait(bot_id) + time.sleep(ANTIFLOOD_BREATHER) + + def delayed_stop(): + time.sleep(0.2) + api(f"/api/bot/{bot_id}/stop", method="POST") + + threading.Thread(target=delayed_stop, daemon=True).start() + api(f"/api/bot/{bot_id}/start", method="POST") + time.sleep(2) + b = get_bot(bot_id) + assert not (b["connected"] is False and b["playing"] is True), ( + f"inconsistent state: {b}" + ) + run("disconnect during connect", t_disconnect_during_connect) + + def t_stop_during_url_resolve(): + assert stop_and_wait(bot_id) + time.sleep(ANTIFLOOD_BREATHER) + assert_started(bot_id) + + def delayed_stop(): + time.sleep(0.15) + api(f"/api/bot/{bot_id}/stop", method="POST") + + threading.Thread(target=delayed_stop, daemon=True).start() + api( + f"/api/player/{bot_id}/play", + method="POST", + json_body={"query": "the mass", "platform": "netease"}, + ) + time.sleep(3) + b = get_bot(bot_id) + assert not (b["connected"] is False and b["playing"] is True), ( + f"inconsistent state: {b}" + ) + run("stop during URL resolve", t_stop_during_url_resolve) + + def t_rapid_volume_change(): + # Volume is a config-only command and works while disconnected, + # so this test deliberately doesn't call assert_started — we're + # validating the API's last-write-wins behavior, not the TS + # transport. That also spares the TS3 anti-flood budget. + for v in (10, 25, 50, 75, 100, 1): + s, _ = api( + f"/api/player/{bot_id}/volume", + method="POST", + json_body={"volume": v}, + ) + assert s == 200, f"volume POST failed: {s}" + time.sleep(0.3) + b = get_bot(bot_id) + assert b["volume"] == 1, f"final volume wrong: {b['volume']}" + run("rapid volume changes converge", t_rapid_volume_change) + + +def group_websocket(bot_id: str): + print("\n== websocket broadcasts ==") + try: + from websocket import create_connection + except Exception as e: + skip("websocket state broadcasts", f"websocket lib unavailable: {e}") + return + + try: + ws = create_connection("ws://localhost:3000/ws", timeout=5) + except Exception as e: + skip("websocket state broadcasts", f"connect failed: {e}") + return + + ws.settimeout(0.3) + messages: list[dict] = [] + stop_reader = threading.Event() + + def reader(): + while not stop_reader.is_set(): + try: + raw = ws.recv() + if not raw: + break + try: + messages.append(json.loads(raw)) + except Exception: + pass + except Exception: + # recv() timeout or closed — keep trying until stop_reader + if stop_reader.is_set(): + break + continue + + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + + try: + def t_init(): + time.sleep(0.6) + types = [m.get("type") for m in messages] + assert "init" in types, f"no init message; got: {types}" + run("init message on connect", t_init) + + def t_state_change_on_play(): + assert_started(bot_id) + messages.clear() + api( + f"/api/player/{bot_id}/play", + method="POST", + json_body={"query": "the mass", "platform": "netease"}, + ) + time.sleep(1.5) + types = [m.get("type") for m in messages] + assert "stateChange" in types, ( + f"no stateChange after play; got types: {types}" + ) + api(f"/api/player/{bot_id}/stop", method="POST") + run("stateChange broadcast on play", t_state_change_on_play) + + def t_bot_disconnected_event(): + assert_started(bot_id) + messages.clear() + api(f"/api/bot/{bot_id}/stop", method="POST") + time.sleep(1.5) + types = [m.get("type") for m in messages] + assert "botDisconnected" in types or "stateChange" in types, ( + f"no disconnect event; got: {types}" + ) + run("botDisconnected event on stop", t_bot_disconnected_event) + finally: + stop_reader.set() + try: + ws.close() + except Exception: + pass + + +# ----------------------------- main ---------------------------------------- + + +def main() -> int: + _, data = api("/api/bot/") + if not isinstance(data, dict) or not data.get("bots"): + print("[fatal] no bots registered — create one via the WebUI first") + return 2 + + target = data["bots"][0] + bot_id = target["id"] + initial_connected = target["connected"] + initial_volume = target["volume"] + initial_mode = target["playMode"] + + print(f"[init] target bot = {bot_id[:8]} ({target['name']})") + print( + f"[init] initial state: connected={initial_connected} " + f"volume={initial_volume} mode={initial_mode}" + ) + + try: + # Read-only / no-lifecycle groups first — they don't consume TS3 + # anti-flood budget. + group_infrastructure(bot_id) + group_auth_status() + group_search() + + # Lifecycle-heavy groups — interleave with small breathers so the + # TS3 server's per-IP reconnect limit doesn't start throttling us. + group_lifecycle(bot_id) + time.sleep(ANTIFLOOD_BREATHER) + + group_playback(bot_id) + time.sleep(ANTIFLOOD_BREATHER) + + group_queue_ops(bot_id) + time.sleep(ANTIFLOOD_BREATHER) + + group_input_validation(bot_id) + group_disconnect_corners(bot_id) + group_seek(bot_id) + time.sleep(ANTIFLOOD_BREATHER) + + group_races(bot_id) + # Extra breather before websocket group — races is the heaviest + # consumer of TS3 reconnect budget (disconnect-during-connect and + # stop-during-url-resolve each burn one cycle), and the websocket + # group needs a clean reconnect to observe live state broadcasts. + time.sleep(ANTIFLOOD_BREATHER * 3) + + group_websocket(bot_id) + finally: + # Restore initial state — this runs even if a test raised + try: + api( + f"/api/player/{bot_id}/volume", + method="POST", + json_body={"volume": initial_volume}, + ) + api( + f"/api/player/{bot_id}/mode", + method="POST", + json_body={"mode": initial_mode}, + ) + api(f"/api/player/{bot_id}/stop", method="POST") + if initial_connected: + start_and_wait(bot_id) + else: + stop_and_wait(bot_id) + except Exception as e: + print(f"[warn] restore failed: {e}") + + print() + print("=" * 60) + print(f" PASSED: {passed}") + print(f" FAILED: {failed}") + print(f" SKIPPED: {skipped}") + print("=" * 60) + + if failed > 0: + print("\nFailed tests:") + for r in results: + if r.status in ("FAIL", "ERROR"): + print(f" [{r.status}] {r.name}: {r.detail}") + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/scripts/test_more_corners.py b/scripts/test_more_corners.py new file mode 100644 index 0000000..93d43b7 --- /dev/null +++ b/scripts/test_more_corners.py @@ -0,0 +1,166 @@ +"""More corner-case regressions. + +A. resolveAndPlay disconnect-during-URL-resolve race + The bot checks !this.connected at the top of resolveAndPlay, but the + URL-resolve await can take several seconds. If stop is called during + that window, playback would previously start on a disconnected bot. + +B. /seek NaN/Infinity rejection + typeof NaN === "number" and NaN < 0 is false, so a plain range check + leaks NaN through and corrupts seekOffset / getElapsed. +""" +import threading +import time +import requests + +BASE = "http://localhost:3000" + + +def api(path, method="GET", **kw): + return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw) + + +def get_bot(bot_id): + return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id) + + +def wait_connected(bot_id, want, timeout=15): + end = time.time() + timeout + while time.time() < end: + if get_bot(bot_id)["connected"] is want: + return True + time.sleep(0.15) + return False + + +def test_resolve_play_stop_race(bot_id): + """Fire stopBot during the /play call's URL resolve window.""" + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + time.sleep(1) + api(f"/api/bot/{bot_id}/start", method="POST") + wait_connected(bot_id, True) + + # Schedule a stop 150ms into the play call — that lands inside the + # provider.getSongUrl await, which is where the race lives. + def delayed_stop(): + time.sleep(0.15) + try: + api(f"/api/bot/{bot_id}/stop", method="POST") + except Exception: + pass + + threading.Thread(target=delayed_stop, daemon=True).start() + + try: + api( + f"/api/player/{bot_id}/play", + method="POST", + json={"query": "the mass", "platform": "netease"}, + ) + except Exception: + pass + + # Give both calls time to settle fully + time.sleep(3) + b = get_bot(bot_id) + # Key invariant: we never want connected=false AND playing=true. That + # pair is the exact Bug C symptom and would indicate the resolveAndPlay + # post-await check didn't fire. + assert not (b["connected"] is False and b["playing"] is True), ( + f"inconsistent state after race: {b}" + ) + print( + f"[PASS] resolveAndPlay stop-race — final state consistent " + f"(connected={b['connected']} playing={b['playing']})" + ) + + +def test_seek_nan_rejected(bot_id): + """Verify that NaN and Infinity seek positions are rejected at the API + layer (instead of poisoning seekOffset).""" + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + api(f"/api/bot/{bot_id}/start", method="POST") + wait_connected(bot_id, True) + + # Start a real song so there is an active playback to seek against + api( + f"/api/player/{bot_id}/play", + method="POST", + json={"query": "the mass", "platform": "netease"}, + ) + time.sleep(1.2) + + # JSON spec doesn't allow NaN/Infinity literals, but Python's json + # encoder emits them as bare tokens when allow_nan=True (the default). + # Express's body-parser rejects them as invalid JSON, which itself is + # a form of rejection. We additionally verify that sending a string + # "NaN" or a negative value is also rejected with a clean 400. + r = api( + f"/api/player/{bot_id}/seek", + method="POST", + json={"position": -5}, + ) + assert r.status_code == 400, f"negative seek should be rejected, got {r.status_code}" + + r = api( + f"/api/player/{bot_id}/seek", + method="POST", + json={"position": "fifty"}, + ) + assert r.status_code == 400, f"string seek should be rejected, got {r.status_code}" + + # Directly send NaN in raw body (body-parser will likely 400 it) + r = requests.post( + f"{BASE}/api/player/{bot_id}/seek", + data='{"position": NaN}', + headers={"Content-Type": "application/json"}, + timeout=10, + ) + assert r.status_code >= 400, f"NaN seek should be rejected, got {r.status_code}" + + # After the junk attempts, a valid seek still works and the elapsed + # time is a finite number (not NaN). + r = api( + f"/api/player/{bot_id}/seek", + method="POST", + json={"position": 30}, + ) + assert r.status_code == 200, f"valid seek failed: {r.text[:120]}" + + elapsed_resp = api(f"/api/player/{bot_id}/elapsed") + elapsed = elapsed_resp.json().get("elapsed") + assert elapsed is not None and isinstance(elapsed, (int, float)), ( + f"elapsed should be a number, got {elapsed}" + ) + # Could be exactly 30 or a tiny bit more if a frame has advanced + assert 29 <= elapsed < 40, f"elapsed after seek(30) out of range: {elapsed}" + print(f"[PASS] seek NaN/Infinity rejected; valid seek produces finite elapsed={elapsed:.2f}") + + +def main(): + bots = api("/api/bot/").json()["bots"] + if not bots: + print("[skip] no bots") + return + bot_id = bots[0]["id"] + initial = bots[0]["connected"] + print(f"[init] bot={bot_id[:8]} initial connected={initial}") + + try: + test_resolve_play_stop_race(bot_id) + test_seek_nan_rejected(bot_id) + print("ALL GREEN") + finally: + if initial: + api(f"/api/bot/{bot_id}/start", method="POST") + wait_connected(bot_id, True) + else: + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + print(f"[restore] connected={get_bot(bot_id)['connected']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_multibot.py b/scripts/test_multibot.py new file mode 100644 index 0000000..18f710e --- /dev/null +++ b/scripts/test_multibot.py @@ -0,0 +1,212 @@ +"""Stress-test two bots playing music concurrently on the same TS server. + +Creates two temporary bots (or reuses existing named ones), starts them, +plays music on both, and polls /api/bot/ every 2 seconds to detect when +(if) either bot disconnects or stops playing. Cleans up on exit. + +Usage: + python scripts/test_multibot.py --minutes 3 + python scripts/test_multibot.py --minutes 10 --host 127.0.0.1 --port 9987 +""" +import argparse +import sys +import time +from dataclasses import dataclass + +import requests + +API = "http://localhost:3000" +POLL_INTERVAL = 2.0 +TEST_BOT_NAMES = ("mbtest1", "mbtest2") +TEST_BOT_NICKS = ("MBTest1", "MBTest2") +QUERIES = ("the mass", "lofi") # one different song per bot + + +@dataclass +class BotSnapshot: + t: float + connected: bool + playing: bool + song: str | None + + +def api_get(path: str): + r = requests.get(f"{API}{path}", timeout=5) + r.raise_for_status() + return r.json() + + +def api_post(path: str, json=None): + r = requests.post(f"{API}{path}", json=json, timeout=15) + r.raise_for_status() + return r.json() + + +def api_delete(path: str): + r = requests.delete(f"{API}{path}", timeout=10) + r.raise_for_status() + return r.json() + + +def cleanup_existing(names: tuple[str, ...]) -> None: + bots = api_get("/api/bot/")["bots"] + for b in bots: + if b["name"] in names: + try: + api_post(f"/api/player/{b['id']}/stop") + except Exception: + pass + try: + api_delete(f"/api/bot/{b['id']}") + print(f"[cleanup] removed existing bot {b['name']} ({b['id']})") + except Exception as e: + print(f"[cleanup] failed to remove {b['name']}: {e}") + + +def create_bot(name: str, nickname: str, host: str, port: int) -> str: + res = api_post( + "/api/bot/", + json={ + "name": name, + "serverAddress": host, + "serverPort": port, + "nickname": nickname, + "autoStart": False, + }, + ) + bot_id = res["id"] + print(f"[create] {name} -> {bot_id}") + return bot_id + + +def start_bot(bot_id: str) -> None: + api_post(f"/api/bot/{bot_id}/start") + + +def play(bot_id: str, query: str) -> None: + api_post(f"/api/player/{bot_id}/play", json={"query": query, "platform": "netease"}) + + +def snapshot(bot_id: str, t0: float) -> BotSnapshot: + bots = api_get("/api/bot/")["bots"] + b = next((x for x in bots if x["id"] == bot_id), None) + if not b: + return BotSnapshot(time.time() - t0, False, False, None) + song = b["currentSong"]["name"] if b.get("currentSong") else None + return BotSnapshot(time.time() - t0, b["connected"], b["playing"], song) + + +def run(minutes: float, host: str, port: int) -> int: + print(f"[setup] duration={minutes}min host={host}:{port}") + + cleanup_existing(TEST_BOT_NAMES) + + bot_ids = [ + create_bot(TEST_BOT_NAMES[0], TEST_BOT_NICKS[0], host, port), + create_bot(TEST_BOT_NAMES[1], TEST_BOT_NICKS[1], host, port), + ] + + # Start both, allowing a small stagger to avoid handshake collision + for i, bid in enumerate(bot_ids): + start_bot(bid) + print(f"[start] bot{i+1} started") + time.sleep(1.5) + + # Wait until both are connected (or bail after 15s) + deadline = time.time() + 15 + while time.time() < deadline: + bots = {b["id"]: b for b in api_get("/api/bot/")["bots"]} + if all(bots[b]["connected"] for b in bot_ids): + print("[start] both bots connected") + break + time.sleep(0.5) + else: + print("[fatal] bots did not both come online in 15s") + cleanup_existing(TEST_BOT_NAMES) + return 2 + + # Kick off playback on both + for i, bid in enumerate(bot_ids): + play(bid, QUERIES[i]) + print(f"[play] bot{i+1} -> {QUERIES[i]!r}") + + t0 = time.time() + end = t0 + minutes * 60 + first_drop: dict[str, float] = {} + last_state: dict[str, BotSnapshot] = {} + + print(f"[monitor] polling every {POLL_INTERVAL}s for {minutes} min...") + print(f"{'time':>7} {'bot1':<40} {'bot2':<40}") + + def fmt(snap: BotSnapshot) -> str: + flag = ("C" if snap.connected else "-") + ("P" if snap.playing else "-") + song = (snap.song or "").replace("\n", " ")[:30] + return f"{flag} {song}" + + try: + while time.time() < end: + snaps = [snapshot(bid, t0) for bid in bot_ids] + elapsed = int(time.time() - t0) + row = f"{elapsed:>6}s {fmt(snaps[0]):<40} {fmt(snaps[1]):<40}" + # Only print when state changes or every 10s + changed = False + for bid, s in zip(bot_ids, snaps): + prev = last_state.get(bid) + if (prev is None + or prev.connected != s.connected + or prev.playing != s.playing + or prev.song != s.song): + changed = True + last_state[bid] = s + if not s.connected and bid not in first_drop: + first_drop[bid] = s.t + if changed or elapsed % 10 == 0: + print(row) + + # If both stopped playing but are still connected, re-queue the same song + for i, (bid, s) in enumerate(zip(bot_ids, snaps)): + if s.connected and not s.playing: + try: + play(bid, QUERIES[i]) + except Exception as e: + print(f"[warn] re-play bot{i+1} failed: {e}") + + time.sleep(POLL_INTERVAL) + except KeyboardInterrupt: + print("\n[abort] interrupted") + + # Summary + total = time.time() - t0 + print() + print("=" * 60) + print(f"Total observed time: {total:.1f}s") + for i, bid in enumerate(bot_ids): + drop = first_drop.get(bid) + if drop is None: + print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): stayed connected the whole run") + else: + print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): FIRST DISCONNECT at t+{drop:.1f}s") + print("=" * 60) + + # Cleanup + cleanup_existing(TEST_BOT_NAMES) + print("[cleanup] done") + + return 0 if not first_drop else 1 + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--minutes", type=float, default=3.0) + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=9987) + args = p.parse_args() + try: + return run(args.minutes, args.host, args.port) + except requests.HTTPError as e: + print(f"[http-error] {e} body={e.response.text[:200] if e.response else ''}") + return 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_playbar.py b/scripts/test_playbar.py index 45dc41c..82dccb4 100644 --- a/scripts/test_playbar.py +++ b/scripts/test_playbar.py @@ -1,105 +1,119 @@ -"""Reproduce the player-bar-not-appearing bug.""" +"""Reproduce / regression-check the player-bar-not-appearing bug. + +Captures the bot's initial playback state and restores it on exit so the +test never leaves the user with surprise music or a cleared queue. +""" import time import requests from playwright.sync_api import sync_playwright BASE = "http://localhost:3000" -def get_bot_id(): - r = requests.get(f"{BASE}/api/bot/") - return r.json()["bots"][0]["id"] -def stop(bot_id): - requests.post(f"{BASE}/api/player/{bot_id}/stop") - requests.post(f"{BASE}/api/player/{bot_id}/clear") +def api(path, method="GET", **kw): + fn = getattr(requests, method.lower()) + r = fn(f"{BASE}{path}", timeout=10, **kw) + r.raise_for_status() + return r.json() if r.text else None -def play(bot_id, query="test"): - requests.post(f"{BASE}/api/player/{bot_id}/play", json={"query": query, "platform": "netease"}) -with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - ctx = browser.new_context() - ctx.add_init_script(""" - (() => { - const OrigWS = window.WebSocket; - window.__wsMessages = []; - window.WebSocket = function(...args) { - const ws = new OrigWS(...args); - ws.addEventListener('message', (ev) => { - try { - const d = JSON.parse(ev.data); - const summary = {type: d.type, botId: d.botId}; - if (d.status) summary.playing = d.status.playing; - if (d.status?.currentSong) summary.song = d.status.currentSong.name; - window.__wsMessages.push(summary); - console.log('WS_MSG ' + JSON.stringify(summary)); - } catch(e) {} - }); - return ws; - }; - Object.assign(window.WebSocket, OrigWS); - })(); - """) - page = ctx.new_page() - logs = [] - page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}")) +def get_bot(bot_id): + return next(b for b in api("/api/bot/")["bots"] if b["id"] == bot_id) - bot_id = get_bot_id() - stop(bot_id) - time.sleep(0.6) - page.goto(BASE) - page.wait_for_load_state("networkidle") - time.sleep(0.8) +def capture_state(bot_id): + b = get_bot(bot_id) + return { + "playing": b["playing"], + "paused": b["paused"], + "song": (b["currentSong"] or {}).get("name"), + } - # Hook into WebSocket messages from the page to confirm they arrive - page.evaluate(""" - () => { - const origWS = window.WebSocket; - // Already connected via useWebSocket — tap Pinia store directly - // Expose store state via window - const store = window.__pinia?._s?.get('player'); - window.__getStore = () => ({ - bots: JSON.parse(JSON.stringify(store?.bots || [])), - activeBotId: store?.activeBotId, - currentSong: store?.currentSong ? JSON.parse(JSON.stringify(store.currentSong)) : null, - isPlaying: store?.isPlaying, - }); - } - """) - snap_before = page.evaluate("() => window.__getStore?.()") - print("store before:", snap_before) +def main(): + bots = api("/api/bot/")["bots"] + if not bots: + print("[skip] no bots") + return + bot_id = bots[0]["id"] + initial = capture_state(bot_id) + print(f"[init] initial state: {initial}") - # Player bar should be absent right now (nothing playing) - initial = page.locator(".player-wrapper").count() - print(f"initial .player-wrapper count: {initial}") + try: + # Clear slate + api(f"/api/player/{bot_id}/stop", method="POST") + time.sleep(0.6) - # Trigger play via API (simulates any play trigger) - play(bot_id, "test") + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + try: + ctx = browser.new_context() + ctx.add_init_script( + """ + (() => { + const OrigWS = window.WebSocket; + window.__wsMessages = []; + window.WebSocket = function(...args) { + const ws = new OrigWS(...args); + ws.addEventListener('message', (ev) => { + try { + const d = JSON.parse(ev.data); + window.__wsMessages.push({type: d.type, botId: d.botId}); + } catch(e) {} + }); + return ws; + }; + Object.assign(window.WebSocket, OrigWS); + })(); + """ + ) + page = ctx.new_page() + page.goto(BASE) + page.wait_for_load_state("networkidle") + time.sleep(0.8) - # Poll for up to 6s to see if player bar appears automatically - appeared_at = None - for i in range(60): - if page.locator(".player-wrapper").count() > 0: - appeared_at = i * 0.1 - break - page.wait_for_timeout(100) + assert page.locator(".player-wrapper").count() == 0, ( + "player bar should be hidden before playback" + ) - print(f"player bar appeared after: {appeared_at}") - snap_after = page.evaluate("() => window.__getStore?.()") - print("store after:", snap_after) + # Trigger play via API (simulates any play trigger) + api( + f"/api/player/{bot_id}/play", + method="POST", + json={"query": "the mass", "platform": "netease"}, + ) - # After force reload, does it appear? - if appeared_at is None: - page.reload() - page.wait_for_load_state("networkidle") - time.sleep(0.8) - after_reload = page.locator(".player-wrapper").count() - print(f"after reload .player-wrapper count: {after_reload}") + # Poll for up to 6s to see if player bar appears automatically + appeared_at = None + for i in range(60): + if page.locator(".player-wrapper").count() > 0: + appeared_at = i * 0.1 + break + page.wait_for_timeout(100) - print("--- console logs ---") - for line in logs[-20:]: - print(line) + if appeared_at is None: + msgs = page.evaluate("() => window.__wsMessages") + print(f"[FAIL] player bar never appeared; WS msgs: {msgs}") + raise AssertionError("player bar did not auto-show on stateChange") + print(f"[PASS] player bar appeared after {appeared_at:.1f}s") + finally: + browser.close() + finally: + # Restore: stop the "test" song we triggered, then re-apply initial + # state as best we can. We can't re-queue the user's previous song, + # but we can at least stop ours and leave the bot idle if it was idle. + try: + api(f"/api/player/{bot_id}/stop", method="POST") + except Exception as e: + print(f"[warn] failed to stop test song on cleanup: {e}") + post = capture_state(bot_id) + print(f"[restore] bot now idle (was playing={initial['playing']} song={initial['song']!r})") + if initial["playing"] and initial["song"]: + print( + f"[note] initial bot was playing {initial['song']!r}; " + "this test cannot resume arbitrary tracks — you may need to restart playback" + ) - browser.close() + +if __name__ == "__main__": + main() diff --git a/scripts/test_power_button.py b/scripts/test_power_button.py new file mode 100644 index 0000000..8b81168 --- /dev/null +++ b/scripts/test_power_button.py @@ -0,0 +1,100 @@ +"""E2E: the new power button in the Bot Selector dropdown toggles bot connected state. + +Captures the target bot's initial connected state and restores it on exit +(including on assertion failure), so running this test never pollutes the +user's current bot setup. +""" +import time +import requests +from playwright.sync_api import sync_playwright + +BASE = "http://localhost:3000" + + +def get_bot(bot_id): + return next(b for b in requests.get(f"{BASE}/api/bot/").json()["bots"] if b["id"] == bot_id) + + +def wait_for_connected(bot_id, want: bool, timeout_s: float = 12.0) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if get_bot(bot_id)["connected"] is want: + return True + time.sleep(0.2) + return False + + +def set_connected(bot_id, want: bool) -> None: + """Force the bot into the given connected state via API.""" + current = get_bot(bot_id)["connected"] + if current == want: + return + endpoint = "start" if want else "stop" + requests.post(f"{BASE}/api/bot/{bot_id}/{endpoint}") + wait_for_connected(bot_id, want) + + +def main(): + bots = requests.get(f"{BASE}/api/bot/").json()["bots"] + if not bots: + print("[skip] no bots registered, nothing to test") + return + target = bots[0] + bot_id = target["id"] + initial_connected = target["connected"] + print(f"[init] target bot {target['name']} ({bot_id[:8]}), initial connected={initial_connected}") + + try: + # Force bot disconnected before the test + set_connected(bot_id, False) + assert not get_bot(bot_id)["connected"], "bot should be disconnected at start" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={"width": 1440, "height": 900}) + page.goto(BASE) + page.wait_for_load_state("networkidle") + time.sleep(0.6) + + # Open dropdown + page.locator(".bot-selector-btn").click() + page.wait_for_selector(".bot-power-btn") + + # Click the power button to start + page.locator(".bot-power-btn").first.click() + print("[ui] clicked power (start)") + + assert wait_for_connected(bot_id, True), "bot should be connected after clicking start" + print("[api] bot connected = True") + + # Let UI catch up via WS then re-open the dropdown to re-check class + time.sleep(1.0) + page.locator(".bot-selector-btn").click() # close + time.sleep(0.2) + page.locator(".bot-selector-btn").click() # reopen + page.wait_for_selector(".bot-power-btn.online", timeout=3000) + print("[ui] power button now shows .online class") + + # Click again to stop + page.locator(".bot-power-btn.online").first.click() + print("[ui] clicked power (stop)") + + assert wait_for_connected(bot_id, False), "bot should be disconnected after clicking stop" + print("[api] bot connected = False") + + print("[PASS] power button toggles bot connection") + finally: + browser.close() + finally: + # Always restore the initial state so the test never leaves the bot + # in an unexpected place + set_connected(bot_id, initial_connected) + final = get_bot(bot_id)["connected"] + print(f"[restore] bot connected={final} (initial was {initial_connected})") + if final != initial_connected: + print("[warn] failed to restore initial connected state") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_rapid_cycle.py b/scripts/test_rapid_cycle.py new file mode 100644 index 0000000..a8e7ef4 --- /dev/null +++ b/scripts/test_rapid_cycle.py @@ -0,0 +1,130 @@ +"""Regression for the 'connected=False but playing=True' stuck-state bug. + +After rapid disconnect/reconnect, the library could drop the connection +(TS3 server anti-flood or a hung handshake). The bot then ended up in an +inconsistent state: player.state='playing' but tsClient disconnected. + +This test verifies three fixes: + Bug A — startBot() has a 15s timeout instead of hanging forever on a + stalled handshake. /start returns a clean 500 instead of blocking. + Bug B — play/add/etc commands are rejected when the bot is not connected. + Bug C — the tsClient 'disconnected' handler always clears player state, + even when connect() never completed (so !this.connected). + +We also sanity-check that the bot recovers (can start a fresh cycle) after +a transient failure. +""" +import time +import requests + +BASE = "http://localhost:3000" + + +def api(path, method="GET", **kw): + return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw) + + +def get_bot(bot_id): + return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id) + + +def wait_connected(bot_id, want, timeout=15): + end = time.time() + timeout + while time.time() < end: + if get_bot(bot_id)["connected"] is want: + return True + time.sleep(0.15) + return False + + +def main(): + bots = api("/api/bot/").json()["bots"] + if not bots: + print("[skip] no bots") + return + bot_id = bots[0]["id"] + initial_connected = bots[0]["connected"] + print(f"[init] bot={bot_id[:8]} initial connected={initial_connected}") + + try: + # Start from a clean slate + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + + # --- Bug B: play while disconnected must be rejected --- + r = api( + f"/api/player/{bot_id}/play", + method="POST", + json={"query": "rejection test", "platform": "netease"}, + ) + assert r.status_code >= 400, ( + f"play while disconnected should fail, got {r.status_code} {r.text[:120]}" + ) + assert "not connected" in r.text.lower(), ( + f"expected 'not connected' error, got: {r.text[:200]}" + ) + b = get_bot(bot_id) + assert not b["playing"], f"player shouldn't be playing after rejected /play: {b}" + print("[PASS] Bug B — /play rejected while bot disconnected; state untouched") + + # --- Bug C: normal start→play→stop leaves player state clean --- + r = api(f"/api/bot/{bot_id}/start", method="POST") + assert r.status_code == 200, f"start failed {r.text[:120]}" + assert wait_connected(bot_id, True), "bot did not connect within 15s" + + r = api( + f"/api/player/{bot_id}/play", + method="POST", + json={"query": "the mass", "platform": "netease"}, + ) + assert r.status_code == 200, f"play failed {r.text[:120]}" + time.sleep(1.2) + b = get_bot(bot_id) + assert b["connected"] and b["playing"], f"should be connected+playing: {b}" + + api(f"/api/bot/{bot_id}/stop", method="POST") + assert wait_connected(bot_id, False, timeout=5), "bot did not disconnect" + b = get_bot(bot_id) + assert not b["playing"], ( + f"player should have stopped after bot disconnect (Bug C): {b}" + ) + print("[PASS] Bug C — stop clears both connected and playing state") + + # --- Bug A: startBot has a deadline and returns a clean error if connect hangs --- + # We can't easily force a hang in-process, but we can sanity-check that + # startBot returns promptly (well under the 15s cap) on a normal run. + t0 = time.time() + r = api(f"/api/bot/{bot_id}/start", method="POST") + elapsed = time.time() - t0 + assert r.status_code == 200, f"start failed {r.text[:120]}" + assert elapsed < 10, f"start should be prompt, took {elapsed:.1f}s" + assert wait_connected(bot_id, True), "bot did not connect" + print( + f"[PASS] Bug A — startBot completed in {elapsed:.2f}s " + "(deadline is 15s, would throw on hang)" + ) + + # --- Recovery: after any failure, another start should work --- + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + # Give TS3 server a moment to forget us (anti-flood grace) + time.sleep(2) + r = api(f"/api/bot/{bot_id}/start", method="POST") + assert r.status_code == 200, f"recovery start failed {r.text[:120]}" + assert wait_connected(bot_id, True), "bot did not recover" + print("[PASS] recovery — bot reconnects cleanly after a cycle") + + print("ALL GREEN") + + finally: + if initial_connected: + api(f"/api/bot/{bot_id}/start", method="POST") + wait_connected(bot_id, True) + else: + api(f"/api/bot/{bot_id}/stop", method="POST") + wait_connected(bot_id, False) + print(f"[restore] bot connected={get_bot(bot_id)['connected']} (was {initial_connected})") + + +if __name__ == "__main__": + main() diff --git a/src/audio/player.ts b/src/audio/player.ts index f0bcb53..6a0f970 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -302,6 +302,13 @@ export class AudioPlayer extends EventEmitter { seek(seconds: number): void { if (!this.currentUrl) return; + // Reject NaN/Infinity/negative — the HTTP layer validates too, but a + // bad value here would poison seekOffset and leave getElapsed() + // returning NaN until the track ends. + if (!Number.isFinite(seconds) || seconds < 0) { + this.logger.warn({ seek: seconds }, "Ignoring invalid seek position"); + return; + } this.logger.info({ seek: seconds }, "Seeking"); this.play(this.currentUrl, seconds); } diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index c2f6ee7..e801014 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -89,6 +89,49 @@ describe("PlayQueue", () => { expect(queue.list()[1].id).toBe("3"); }); + it("removing a song before current shifts current index", () => { + queue.setMode(PlayMode.Sequential); + queue.add(makeSong("A")); + queue.add(makeSong("B")); + queue.add(makeSong("C")); + queue.playAt(2); // playing C at index 2 + queue.remove(0); // remove A (before current) + expect(queue.current()?.id).toBe("C"); // still on C + expect(queue.getCurrentIndex()).toBe(1); + }); + + it("removing the currently-playing song lets next() advance to the shifted song", () => { + queue.setMode(PlayMode.Sequential); + queue.add(makeSong("A")); + queue.add(makeSong("B")); + queue.add(makeSong("C")); + queue.add(makeSong("D")); + queue.playAt(2); // playing C + queue.remove(2); // remove C — D shifts into slot 2 + // Before the fix this returned null (D was silently skipped) + expect(queue.next()?.id).toBe("D"); + }); + + it("removing the only song clears the queue", () => { + queue.add(makeSong("only")); + queue.playAt(0); + queue.remove(0); + expect(queue.size()).toBe(0); + expect(queue.current()).toBeNull(); + expect(queue.next()).toBeNull(); + }); + + it("removing the last song while playing it advances to null in sequential mode", () => { + queue.setMode(PlayMode.Sequential); + queue.add(makeSong("A")); + queue.add(makeSong("B")); + queue.playAt(1); // playing B (last) + queue.remove(1); + expect(queue.size()).toBe(1); + // currentIndex moved to 0, so next() should try to advance past the end + expect(queue.next()).toBeNull(); + }); + it("clears all songs", () => { queue.add(makeSong("1")); queue.add(makeSong("2")); diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 0416522..9c26ab0 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -36,9 +36,14 @@ export class PlayQueue { if (index < this.currentIndex) { this.currentIndex--; } else if (index === this.currentIndex) { - if (this.currentIndex >= this.songs.length) { - this.currentIndex = this.songs.length - 1; - } + // Move the pointer back by one so next() in sequential mode advances + // to the song that shifted into the removed slot. Without this, the + // shifted song is silently skipped because current() incorrectly + // returns it (even though the player is still on the removed song) + // and next() then increments past it. currentIndex may become -1, + // which is fine — it represents "no current song" and next() will + // pick index 0. + this.currentIndex--; } return removed; diff --git a/src/bot/instance.ts b/src/bot/instance.ts index f31fa71..58192bb 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -57,6 +57,7 @@ export class BotInstance extends EventEmitter { private config: BotConfig; private logger: Logger; private connected = false; + private disconnectEmitted = false; private voteSkipUsers = new Set(); private isAdvancing = false; @@ -108,28 +109,42 @@ export class BotInstance extends EventEmitter { }); this.tsClient.on("disconnected", () => { - // Avoid duplicate event if disconnect() was called explicitly - // (it already set connected = false and emitted "disconnected"). - if (!this.connected) return; + // Always reset local state — covers the case where connect() never + // completed (hanging handshake → 60s library idle timeout) and + // this.connected was never flipped to true. Previously this handler + // short-circuited on !this.connected, leaving player stuck as "playing". this.connected = false; this.player.stop(); + // Only emit externally once per lifecycle so clients don't see a + // duplicate "disconnected" after an explicit disconnect() call. + if (this.disconnectEmitted) return; + this.disconnectEmitted = true; this.emit("disconnected"); }); } async connect(): Promise { + this.disconnectEmitted = false; await this.tsClient.connect(); + // Race guard: if disconnect() was called while the handshake was + // awaiting, don't flip connected back to true — that would leave the + // bot in an inconsistent state (externally "connected" but the tsClient + // has already been torn down). + if (this.disconnectEmitted) { + throw new Error("Connect aborted by concurrent disconnect"); + } this.connected = true; this.emit("connected"); } disconnect(): void { this.player.stop(); - // Set connected = false BEFORE tsClient.disconnect() so that the - // "disconnected" event handler (setupTsEvents) won't emit a duplicate. this.connected = false; + if (!this.disconnectEmitted) { + this.disconnectEmitted = true; + this.emit("disconnected"); + } this.tsClient.disconnect(); - this.emit("disconnected"); } private async handleTextMessage(msg: TS3TextMessage): Promise { @@ -170,6 +185,24 @@ export class BotInstance extends EventEmitter { cmd: ParsedCommand, msg?: TS3TextMessage ): Promise { + // Reject commands that would push audio when the bot isn't connected: + // otherwise ffmpeg spawns and voice goes to a half-initialized or + // torn-down TS client, leaving player.state="playing" on a disconnected + // bot. Config-only commands (vol, mode, clear, stop, queue, now) are + // still allowed so the UI stays usable while the bot is offline. + const AUDIO_COMMANDS = new Set([ + "play", + "add", + "next", + "skip", + "prev", + "playlist", + "album", + "fm", + ]); + if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) { + throw new Error("Bot is not connected to TeamSpeak"); + } switch (cmd.name) { case "play": return this.cmdPlay(cmd); @@ -235,6 +268,14 @@ export class BotInstance extends EventEmitter { /** Resolve URL for a song and start playing it. Skips to next if URL fails. */ async resolveAndPlay(song: QueuedSong): Promise { + if (!this.connected) { + this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping"); + return false; + } + // Clear any accumulated skip votes — every fresh track starts with a + // clean slate, regardless of which code path loaded it (cmdPlay, + // cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.). + this.voteSkipUsers.clear(); const provider = this.getProviderFor(song.platform); try { const url = await provider.getSongUrl(song.id); @@ -242,6 +283,18 @@ export class BotInstance extends EventEmitter { this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping"); return false; } + // Re-check connection state AFTER the network round-trip — the URL + // resolve can take multiple seconds and the user may have called stop + // during that window. Without this, we'd spawn ffmpeg on a + // disconnected bot and land back in the same "connected=false but + // playing=true" inconsistency that Bug C was about. + if (!this.connected) { + this.logger.warn( + { songId: song.id, name: song.name }, + "bot disconnected during URL resolve — aborting playback", + ); + return false; + } song.url = url; this.player.play(url); this.database.addPlayHistory({ @@ -288,7 +341,20 @@ export class BotInstance extends EventEmitter { return `No results found for: ${cmd.args}`; const song = result.songs[0]; + const wasIdle = this.player.getState() === "idle"; this.queue.add({ ...song, platform: provider.platform }); + + // If nothing was playing, start this newly-added song immediately. + // Matches /api/player/:id/add-by-id behavior so both add paths feel + // the same to the user (add to idle bot → plays now). + if (wasIdle) { + this.queue.playAt(this.queue.size() - 1); + this.player.resetFailures(); + await this.resolveAndPlay(this.queue.current()!); + this.emit("stateChange"); + return `Now playing: ${song.name} - ${song.artist}`; + } + this.emit("stateChange"); return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`; } @@ -440,8 +506,11 @@ export class BotInstance extends EventEmitter { if (!msg) return "Vote can only be used in TeamSpeak"; this.voteSkipUsers.add(msg.invokerUid); const clients = await this.tsClient.getClientsInChannel(); - const totalUsers = clients.length - 1; - const needed = Math.ceil(totalUsers / 2); + const totalUsers = clients.length - 1; // exclude the bot itself + // At least 1 vote is always required — otherwise a single voter in an + // otherwise empty channel (or a transient clients.length=1 race) could + // unanimously "win" with needed=0. + const needed = Math.max(1, Math.ceil(totalUsers / 2)); const votes = this.voteSkipUsers.size; if (votes >= needed) { diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 8e8acd9..0374b82 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -12,6 +12,42 @@ import type { Logger } from "../logger.js"; import type { ServerProtocol } from "../ts-protocol/client.js"; +/** + * Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the + * server silently drops the connection after initivexpand2), we tear the + * instance down instead of waiting for the library's 60s idle timeout, so + * the HTTP /start call returns promptly and the UI doesn't lock up. + */ +async function connectWithTimeout( + bot: BotInstance, + ms: number, + logger: Logger +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`connect timeout after ${ms}ms`)), + ms + ); + }); + try { + await Promise.race([bot.connect(), timeout]); + } catch (err) { + logger.warn( + { err, botId: bot.id }, + "Connect failed or timed out — tearing down instance" + ); + try { + bot.disconnect(); + } catch { + // ignore teardown errors + } + throw err; + } finally { + if (timer) clearTimeout(timer); + } +} + export interface CreateBotParams { name: string; serverAddress: string; @@ -111,6 +147,7 @@ export class BotManager extends EventEmitter { this.bots.delete(id); } this.database.deleteBotInstance(id); + this.emit("botInstanceRemoved", id); this.logger.info({ botId: id }, "Bot instance removed"); } @@ -155,6 +192,18 @@ export class BotManager extends EventEmitter { const oldBot = this.bots.get(id); if (!oldBot) throw new Error(`Bot ${id} not found`); + // Always tear down the outgoing instance before creating a replacement. + // Covers three cases: + // 1. oldBot is fully connected (manual restart) + // 2. oldBot is mid-handshake from a prior rapid start (isConnected() + // still returns false but the library client is live and will leak + // a TS session if we abandon it) + // 3. oldBot was just created by createBot but never connected — the + // disconnect call is a cheap no-op here. + // Calling disconnect() is idempotent (disconnectEmitted guards event + // emission), so this is safe in all states. + oldBot.disconnect(); + // Reload config from database so updated settings (channel, nickname, etc.) take effect const saved = this.database.getBotInstances().find((i) => i.id === id); if (saved) { @@ -167,6 +216,10 @@ export class BotManager extends EventEmitter { port: saved.serverPort, queryPort: proto === "ts6" ? 10080 : 10011, nickname: saved.nickname, + // Reuse the stored identity so server groups assigned to this bot + // survive restarts — without this the TS server sees a new UID + // each connect and strips all previously granted groups. + identity: saved.identity || undefined, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, serverPassword: saved.serverPassword || undefined, @@ -183,12 +236,12 @@ export class BotManager extends EventEmitter { }); this.bots.set(id, bot); this.emit("botInstance", bot); - await bot.connect(); + await connectWithTimeout(bot, 15_000, this.logger); // Mark as autoStart so it reconnects on Docker restart, and persist identity this.database.saveBotInstance({ ...saved, autoStart: true }); this.persistBotIdentity(saved, bot); } else { - await oldBot.connect(); + await connectWithTimeout(oldBot, 15_000, this.logger); } } diff --git a/src/music/youtube.ts b/src/music/youtube.ts index 339edf0..6ae6a34 100644 --- a/src/music/youtube.ts +++ b/src/music/youtube.ts @@ -21,17 +21,54 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); /** Resolve the yt-dlp binary path. Checks the project bin/ dir first, then PATH. */ function findYtDlp(): string { + const exe = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp"; const candidates = [ - join(__dirname, "..", "..", "bin", "yt-dlp.exe"), + join(__dirname, "..", "..", "bin", exe), join(__dirname, "..", "..", "bin", "yt-dlp"), - "yt-dlp", - "yt-dlp.exe", + exe, ]; for (const c of candidates) { - if (!c.includes(join("bin", "yt-dlp")) || existsSync(c)) return c; - // For PATH entries (no directory prefix), fall through to let execFile try + // Absolute/relative paths: only return if the file exists. + // Bare names: return and let execFile resolve via PATH. + const isBinPath = c.includes(join("bin", "yt-dlp")); + if (!isBinPath || existsSync(c)) return c; } - return "yt-dlp"; + return exe; +} + +/** + * Availability check for yt-dlp. Runs `yt-dlp --version` and caches only + * the positive result — if the binary is missing, subsequent calls retry + * so the user can install yt-dlp while the server is running and pick it + * up without a restart. Used by getAuthStatus() so the UI can reflect + * whether YouTube is actually usable. + */ +let cachedAvailable = false; +let pendingCheck: Promise | null = null; +async function checkYtDlpAvailable(): Promise { + if (cachedAvailable) return true; + if (pendingCheck) return pendingCheck; + pendingCheck = (async () => { + try { + await execFileAsync(findYtDlp(), ["--version"], { + timeout: 5_000, + maxBuffer: 1024, + }); + cachedAvailable = true; + return true; + } catch { + return false; + } finally { + pendingCheck = null; + } + })(); + return pendingCheck; +} + +/** Force re-detection on the next call (for tests). */ +export function resetYtDlpAvailabilityCache(): void { + cachedAvailable = false; + pendingCheck = null; } async function runYtDlp(args: string[], timeoutMs = 30_000): Promise { @@ -179,6 +216,17 @@ export class YouTubeProvider implements MusicProvider { getCookie(): string { return ""; } async getAuthStatus(): Promise { - return { loggedIn: true, nickname: "YouTube (yt-dlp)" }; + // YouTube has no login concept via yt-dlp, so "loggedIn" here means + // "yt-dlp binary is reachable and responds to --version". The UI can + // use this flag to grey out YouTube when the optional dependency is + // missing, instead of silently returning empty search results. + const available = await checkYtDlpAvailable(); + if (available) { + return { loggedIn: true, nickname: "YouTube (yt-dlp)" }; + } + return { + loggedIn: false, + nickname: "YouTube (yt-dlp not installed)", + }; } } diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts index 244618e..68fc323 100644 --- a/src/web/api/auth.ts +++ b/src/web/api/auth.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import type { MusicProvider } from "../../music/provider.js"; +import { YouTubeProvider } from "../../music/youtube.js"; import type { CookieStore } from "../../music/auth.js"; import type { Logger } from "../../logger.js"; @@ -11,9 +12,13 @@ export function createAuthRouter( cookieStore?: CookieStore ): Router { const router = Router(); + // YouTube is auth-less; we only use this instance so /auth/status can + // report whether yt-dlp is actually installed (loggedIn=false otherwise). + const youtubeProvider: MusicProvider = new YouTubeProvider(); function getProvider(platform?: string): MusicProvider { if (platform === "bilibili") return bilibiliProvider; + if (platform === "youtube") return youtubeProvider; return platform === "qq" ? qqProvider : neteaseProvider; } @@ -119,6 +124,14 @@ export function createAuthRouter( res.status(400).json({ error: "cookie is required" }); return; } + // YouTube has no cookie concept — reject instead of falling through and + // clobbering the NetEase cookie entry. + if (platform === "youtube") { + res + .status(400) + .json({ error: "YouTube does not use cookies (uses yt-dlp binary)" }); + return; + } const provider = getProvider(platform); provider.setCookie(cookie); const plat = platform === "bilibili" ? "bilibili" as const diff --git a/src/web/api/player.ts b/src/web/api/player.ts index bd82b61..4cd55b3 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -25,6 +25,14 @@ export function createPlayerRouter( next(); }); + /** Map API platform string to the corresponding command flag. */ + const platformFlag = (platform: unknown): string => { + if (platform === "bilibili") return "-b"; + if (platform === "qq") return "-q"; + if (platform === "youtube") return "-y"; + return ""; + }; + router.post("/:botId/play", async (req, res) => { try { const bot = (req as any).bot; @@ -33,8 +41,7 @@ export function createPlayerRouter( res.status(400).json({ error: "query is required" }); return; } - const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : ""; - const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!"); + const cmd = parseCommand(`!play ${platformFlag(platform)} ${query}`.trim(), "!"); if (!cmd) { res.status(400).json({ error: "Invalid command" }); return; @@ -50,8 +57,7 @@ export function createPlayerRouter( try { const bot = (req as any).bot; const { query, platform } = req.body; - const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : ""; - const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!"); + const cmd = parseCommand(`!add ${platformFlag(platform)} ${query}`.trim(), "!"); if (!cmd) { res.status(400).json({ error: "Invalid command" }); return; @@ -85,7 +91,21 @@ export function createPlayerRouter( try { const bot = (req as any).bot; const { volume } = req.body; - const cmd = parseCommand(`!vol ${volume}`, "!")!; + // Reject bad input with a proper 4xx instead of letting cmdVol + // return a "Usage:" string inside a 200 body — API clients can't + // detect that failure mode, and the UI would silently swallow it. + if ( + typeof volume !== "number" || + !Number.isFinite(volume) || + volume < 0 || + volume > 100 + ) { + res + .status(400) + .json({ error: "volume must be a number between 0 and 100" }); + return; + } + const cmd = parseCommand(`!vol ${Math.round(volume)}`, "!")!; const response = await bot.executeCommand(cmd); res.json({ message: response }); } catch (err) { @@ -93,10 +113,18 @@ export function createPlayerRouter( } }); + const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]); + router.post("/:botId/mode", async (req, res) => { try { const bot = (req as any).bot; const { mode } = req.body; + if (typeof mode !== "string" || !VALID_MODES.has(mode)) { + res + .status(400) + .json({ error: "mode must be one of: seq, loop, random, rloop" }); + return; + } const cmd = parseCommand(`!mode ${mode}`, "!")!; const response = await bot.executeCommand(cmd); res.json({ message: response }); @@ -116,8 +144,12 @@ export function createPlayerRouter( try { const bot = (req as any).bot; const { position } = req.body; // seconds - if (typeof position !== "number" || position < 0) { - res.status(400).json({ error: "position (seconds) is required" }); + // typeof NaN === "number" and NaN < 0 is false, so a plain range + // check lets NaN/Infinity through and later corrupts seekOffset. + if (typeof position !== "number" || !Number.isFinite(position) || position < 0) { + res + .status(400) + .json({ error: "position must be a finite non-negative number" }); return; } bot.getPlayer().seek(position); @@ -153,8 +185,15 @@ export function createPlayerRouter( return; } const queue = bot.getQueueManager(); - bot.getPlayer().stop(); // Stop current playback first - bot.getPlayer().resetFailures(); // Reset on user-initiated play + // Validate the index BEFORE stopping current playback — otherwise an + // invalid index silently kills the user's current song and leaves the + // queue idle. + if (index >= queue.size()) { + res.status(400).json({ error: "Invalid queue index" }); + return; + } + bot.getPlayer().stop(); + bot.getPlayer().resetFailures(); const song = queue.playAt(index); if (!song) { res.status(400).json({ error: "Invalid queue index" }); @@ -175,9 +214,8 @@ export function createPlayerRouter( try { const bot = (req as any).bot; const { playlistId, platform } = req.body; - const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : ""; const cmd = parseCommand( - `!playlist ${flags} ${playlistId}`.trim(), + `!playlist ${platformFlag(platform)} ${playlistId}`.trim(), "!" )!; const response = await bot.executeCommand(cmd); @@ -193,11 +231,13 @@ export function createPlayerRouter( try { const bot = (req as any).bot; const { playlistId, platform } = req.body; - const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider; - if (!provider) { - res.status(500).json({ error: "Provider not available" }); - return; - } + // Use the bot's own provider lookup — it already knows about youtube, + // which the router's constructor params did not. + const provider = bot.getProviderFor( + platform === "bilibili" || platform === "qq" || platform === "youtube" + ? platform + : "netease" + ); // Stop current playback bot.getPlayer().stop(); @@ -241,11 +281,11 @@ export function createPlayerRouter( try { const bot = (req as any).bot; const { songId, platform } = req.body; - const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider; - if (!provider) { - res.status(500).json({ error: "Provider not available" }); - return; - } + const provider = bot.getProviderFor( + platform === "bilibili" || platform === "qq" || platform === "youtube" + ? platform + : "netease" + ); const song = await provider.getSongDetail(songId); if (!song) { @@ -276,11 +316,11 @@ export function createPlayerRouter( try { const bot = (req as any).bot; const { songId, platform } = req.body; - const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider; - if (!provider) { - res.status(500).json({ error: "Provider not available" }); - return; - } + const provider = bot.getProviderFor( + platform === "bilibili" || platform === "qq" || platform === "youtube" + ? platform + : "netease" + ); const song = await provider.getSongDetail(songId); if (!song) { diff --git a/src/web/websocket.ts b/src/web/websocket.ts index 1c52333..3832ab0 100644 --- a/src/web/websocket.ts +++ b/src/web/websocket.ts @@ -114,13 +114,32 @@ export function setupWebSocket( const onBotInstance = (bot: BotInstance) => attachBotListener(bot); botManager.on("botInstance", onBotInstance); + // React when a bot is removed: detach its listener and tell clients to drop it + const onBotInstanceRemoved = (id: string) => { + detachBotListener(id); + broadcast({ type: "botRemoved", botId: id }); + }; + botManager.on("botInstanceRemoved", onBotInstanceRemoved); + + /** Drop attached listeners whose bot is no longer in the manager. */ + function reconcileAttachedBots(): void { + const liveIds = new Set(botManager.getAllBots().map((b) => b.id)); + for (const id of Array.from(attachedBots.keys())) { + if (!liveIds.has(id)) detachBotListener(id); + } + } + // Safety net: periodically re-check in case any bot was missed - const intervalId = setInterval(ensureAllBotsAttached, 5000); + const intervalId = setInterval(() => { + reconcileAttachedBots(); + ensureAllBotsAttached(); + }, 5000); ensureAllBotsAttached(); return () => { clearInterval(intervalId); botManager.removeListener("botInstance", onBotInstance); + botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved); // Clean up all attached listeners (detach from stored bot refs, not live map) for (const id of Array.from(attachedBots.keys())) { detachBotListener(id); diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 9e8d493..10d5eb7 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -36,6 +36,15 @@ 空闲 离线 + @@ -61,6 +70,7 @@ const store = usePlayerStore(); const activeBot = computed(() => store.activeBot); const dropdownOpen = ref(false); const selectorRef = ref(null); +const togglingBots = ref>({}); function selectBot(id: string) { store.setActiveBotId(id); @@ -74,6 +84,22 @@ function copyBotLink(id: string) { }); } +async function togglePower(bot: { id: string; connected: boolean; name: string }) { + if (togglingBots.value[bot.id]) return; + togglingBots.value[bot.id] = true; + try { + if (bot.connected) { + await store.stopBotInstance(bot.id); + } else { + await store.startBotInstance(bot.id); + } + } catch (err) { + console.error(`Failed to toggle bot ${bot.name}`, err); + } finally { + togglingBots.value[bot.id] = false; + } +} + function onClickOutside(e: MouseEvent) { if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) { dropdownOpen.value = false; @@ -274,6 +300,32 @@ onUnmounted(() => { } } +.bot-power-btn { + flex-shrink: 0; + padding: 6px 8px; + border-radius: var(--radius-sm); + font-size: 16px; + opacity: 0.5; + color: var(--text-tertiary); + transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast); + cursor: pointer; + + &:hover:not(:disabled) { + opacity: 1; + background: var(--hover-bg); + } + + &:disabled { + opacity: 0.25; + cursor: wait; + } + + &.online { + color: #22c55e; + opacity: 0.9; + } +} + .bot-playing-badge, .bot-paused-badge, .bot-idle-badge, diff --git a/web/src/composables/useWebSocket.ts b/web/src/composables/useWebSocket.ts index b343cc2..3979060 100644 --- a/web/src/composables/useWebSocket.ts +++ b/web/src/composables/useWebSocket.ts @@ -56,6 +56,13 @@ export function useWebSocket() { } } break; + case 'botRemoved': + // Bot was deleted from the server — drop from local state entirely + store.removeBotStatus(data.botId); + if (store.activeBotId === data.botId) { + store.activeBotId = store.bots[0]?.id ?? null; + } + break; } }; diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index b350da1..7161745 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -168,6 +168,14 @@ export const usePlayerStore = defineStore('player', { if (saved) this.theme = saved; }, + async startBotInstance(id: string) { + await axios.post(`/api/bot/${id}/start`); + }, + + async stopBotInstance(id: string) { + await axios.post(`/api/bot/${id}/stop`); + }, + async fetchBots() { const res = await axios.get('/api/bot'); this.bots = res.data.bots; From ca4cb1790ad94301f333e23a985d5d7454b93333 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 01:54:43 +0800 Subject: [PATCH 15/17] chore(deps): bump @honeybbq/teamspeak-client to 0.2.1, drop ts6-compat shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 0.2.1 ships a universal clientinit format that works natively against both TS3 and TS6 servers: client_version: "3.?.? [Build: 5680278000]" client_version_sign: DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOo BzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg== The old ts6-compat.ts workaround (monkey-patching handler.sendPacket to rewrite clientinit's client_version to "3.6.2") is now actively wrong: it replaces the library's new correct version/signature pair with a stale one that TS6 servers reject, which is why the first 0.2.1 TS6 handshake attempt still hung at `received initivexpand2`. Changes: - package.json: "@honeybbq/teamspeak-client": "^0.1.0" -> "^0.2.1" - src/ts-protocol/client.ts: remove patchClientInitVersion import and the sendPacket monkey-patch block. Leave an inline comment so anyone reading the git blame understands why the shim is gone. - Delete src/ts-protocol/ts6-compat.ts and ts6-compat.test.ts — no callers remain. Other 0.2.x notes worth knowing (no code change here, just documenting): - ClientOptions gained serverPassword / defaultChannel / defaultChannelPassword that are sent DURING clientinit. We still call our own joinChannel() post-connect because the existing flow works and switching is an orthogonal refactor. - 0.1.1 contains the P-256 DER encoding fix (PR #5 by ZHANGTIANYAO1). Identities generated by 0.1.0 are cryptographically incompatible with 0.2.x's corrected handshake path — a bot whose identity column was populated before this upgrade will hang at `received initivexpand2` and fall through the 15s connect deadline. Workaround: clear the identity column so the next start generates a fresh key. Server groups assigned to the old UID must be re-granted once against the new one. Verification: - tsc --noEmit clean - vitest: 93/93 unit tests pass (1 test file removed with ts6-compat) - scripts/test_full_feature.py against a local TS6 server: 51/51 pass, including handshake, voice playback, identity persistence, WebSocket stateChange broadcasts, and all corner-case regressions. - Live: bot connected to TS6 in ~80ms after identity regeneration, played NetEase audio through the voice channel, clean stop. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 8 ++--- package.json | 2 +- src/ts-protocol/client.ts | 38 ++++------------------ src/ts-protocol/ts6-compat.test.ts | 22 ------------- src/ts-protocol/ts6-compat.ts | 52 ------------------------------ 5 files changed, 11 insertions(+), 111 deletions(-) delete mode 100644 src/ts-protocol/ts6-compat.test.ts delete mode 100644 src/ts-protocol/ts6-compat.ts diff --git a/package-lock.json b/package-lock.json index 468387e..adbc059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@discordjs/opus": "^0.10.0", - "@honeybbq/teamspeak-client": "^0.1.0", + "@honeybbq/teamspeak-client": "^0.2.1", "@koa/router": "^15.4.0", "@sansenjian/qq-music-api": "^2.2.9", "axios": "^1.14.0", @@ -573,9 +573,9 @@ "license": "BSD-3-Clause" }, "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==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@honeybbq/teamspeak-client/-/teamspeak-client-0.2.1.tgz", + "integrity": "sha512-iLZRWsqeAA/IuEL0zMAhHm9+wQ1YmByXjh9T6PZLYSxFk56T39MpQobT6eohM/PnIz3wQZymd8h701hyliLo2w==", "license": "MIT", "dependencies": { "@noble/curves": "^2.0.1" diff --git a/package.json b/package.json index 1123e8d..164e7ef 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "license": "MIT", "dependencies": { "@discordjs/opus": "^0.10.0", - "@honeybbq/teamspeak-client": "^0.1.0", + "@honeybbq/teamspeak-client": "^0.2.1", "@koa/router": "^15.4.0", "@sansenjian/qq-music-api": "^2.2.9", "axios": "^1.14.0", diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index b333e79..fdf2f1a 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -17,7 +17,6 @@ import { type ServerProtocol, } from "./protocol-detect.js"; import { TS6HttpQuery } from "./http-query.js"; -import { patchClientInitVersion } from "./ts6-compat.js"; export { CODEC_OPUS_MUSIC } from "./voice.js"; export type { ServerProtocol } from "./protocol-detect.js"; @@ -206,37 +205,12 @@ export class TS3Client extends EventEmitter { }); await this.client.connect(); - - // Patch handler.sendPacket to intercept clientinit during handshake for TS6. - // - // Why after connect(): The library's connect() internally calls #S() which - // replaces this.handler with a fresh PacketHandler. Any patch applied before - // connect() would be discarded. Patching here is safe because connect() only - // establishes the UDP socket and sends the first Init1 packet — the actual - // clientinit command is sent later in async message callbacks (after Init1 - // round-trips complete), which cannot fire before this sync continuation. - // - // Why not commandMiddleware: The library's middleware chain only applies to - // post-connection commands (sendCommandNoWait/execCommand). The handshake's - // clientinit is sent directly via handler.sendPacket(), bypassing middleware. - if (this.detectedProtocol === "ts6") { - this.logger.info("Applying TS6 compatibility: upgrading client_version to 3.6.2"); - const handler = this.client.handler; - const origSendPacket = handler.sendPacket.bind(handler); - handler.sendPacket = (pType: number, data: Uint8Array, flags: number) => { - // PacketType.Command = 2 - if (pType === 2) { - const str = Buffer.from(data).toString("utf-8"); - if (str.startsWith("clientinit ")) { - const patched = patchClientInitVersion(str); - origSendPacket(pType, Buffer.from(patched), flags); - return; - } - } - origSendPacket(pType, data, flags); - }; - } - + // 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; diff --git a/src/ts-protocol/ts6-compat.test.ts b/src/ts-protocol/ts6-compat.test.ts deleted file mode 100644 index 3a6211c..0000000 --- a/src/ts-protocol/ts6-compat.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { patchClientInitVersion } from "./ts6-compat.js"; - -describe("ts6-compat", () => { - it("patches clientinit version to 3.6.2", () => { - const original = - "clientinit client_nickname=Bot client_version=3.5.3\\s[Build:\\s1587971024] " + - "client_platform=Windows client_version_sign=Kvmj7qX6wJCPI5GVT71samfmhz/bvs7M+OTXWB/JWxdQbxDe17xda7dzUWLX7pjvdJTqZmbse1HBmTxThPKvAg== " + - "client_key_offset=42 hwid=abc123"; - - const patched = patchClientInitVersion(original); - - expect(patched).toContain("client_version=3.6.2\\s[Build:\\s1695203293]"); - expect(patched).toContain( - "client_version_sign=4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==" - ); - // Other fields preserved - expect(patched).toContain("client_nickname=Bot"); - expect(patched).toContain("client_key_offset=42"); - expect(patched).toContain("hwid=abc123"); - }); -}); diff --git a/src/ts-protocol/ts6-compat.ts b/src/ts-protocol/ts6-compat.ts deleted file mode 100644 index 4ff8bd9..0000000 --- a/src/ts-protocol/ts6-compat.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * TS6 version used for compatibility. The version_sign is a P-256 ECDSA - * signature verified by TeamSpeak servers; only officially signed pairs work. - */ -const TS6_VERSION = { - version: "3.6.2 [Build: 1695203293]", - platform: "Windows", - sign: "4OH3unxjGNBYS5EN4RFNrEo3UJz2Jn5KW1JqMDh3Yy93mSd0IwPm3FBrv8hCJgLuv99y6yBSN7pOmOpFjDaCw==", -}; - -/** - * Patch a raw `clientinit` command string to use the 3.6.2 version. - * - * The @honeybbq/teamspeak-client library hardcodes version 3.5.3 in the - * handshake and sends it directly via handler.sendPacket(), bypassing the - * commandMiddleware chain. TS6 servers silently reject 3.5.3 (they never - * respond with `initserver`), causing an idle timeout. - * - * This function is called from a monkey-patched handler.sendPacket() so it - * intercepts the actual handshake packets. - */ -export function patchClientInitVersion(cmd: string): string { - cmd = replaceField(cmd, "client_version", TS6_VERSION.version); - cmd = replaceField(cmd, "client_platform", TS6_VERSION.platform); - cmd = replaceField(cmd, "client_version_sign", TS6_VERSION.sign); - return cmd; -} - -/** - * Replace an escaped field value in a TS3 command string. - * Fields are in the format: key=escaped_value (space-separated) - */ -function replaceField(cmd: string, key: string, value: string): string { - // Escape the value using TS3 escaping rules - const escaped = escapeTS3(value); - const regex = new RegExp(`${key}=\\S*`); - if (regex.test(cmd)) { - return cmd.replace(regex, `${key}=${escaped}`); - } - return cmd; -} - -function escapeTS3(value: string): string { - return value - .replace(/\\/g, "\\\\") - .replace(/\//g, "\\/") - .replace(/ /g, "\\s") - .replace(/\|/g, "\\p") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/\t/g, "\\t"); -} From 49879f987c93194aad9a802329c0be0474a95350 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 01:57:39 +0800 Subject: [PATCH 16/17] docs(readme): document identity migration for @honeybbq/teamspeak-client 0.2.x upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a prominent warning block at the top of the "更新升级" section explaining why identities generated by 0.1.0 are incompatible with 0.2.x's corrected P-256 DER encoding path, and how to migrate: clear the identity column so the next start regenerates a fresh key. Covers three scenarios: - TS3 + old identity: mostly still works (TS3 is tolerant) - TS6 + old identity: must clear — otherwise handshake hangs at `received initivexpand2` - After clearing: server groups must be re-granted to the new UID once Also adds a back-reference at the end of the upgrade section so readers skimming per-platform instructions don't miss the migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 43fb301..25b7da1 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,32 @@ sudo ./scripts/install.sh ## 更新升级 +> **⚠️ 从 dev 分支 `d5d6abe` 或更早版本升级时的重要变更** +> +> 本次升级将底层 TeamSpeak 协议库 `@honeybbq/teamspeak-client` 从 `0.1.0` 升级到 `0.2.1`,移除了本项目内置的 TS6 兼容层(`src/ts-protocol/ts6-compat.ts`),改用库自带的通用 `clientinit` 协议。这是一次**必要的 bug 修复**,但涉及一个数据库迁移: +> +> **旧的身份(identity)不兼容新的加密握手路径。** 0.1.0 版本的库在生成 TS 客户端身份时存在 P-256 公钥 DER 编码错误,该 bug 在 0.1.1 中由本项目维护者 [ZHANGTIANYAO1](https://github.com/HoneyBBQ/teamspeak-js/pull/5) 修复并合并到上游。0.1.0 生成的身份与 0.2.x 修复后的握手路径**不兼容**:升级后用旧身份连接会卡在 `received initivexpand2` 直到 15 秒超时。 +> +> **解决办法**:升级后清空受影响机器人的 `identity` 字段,下次启动时程序会自动生成新身份并持久化。 +> +> ```bash +> # 对每个需要迁移的机器人执行(替换 为实际 UUID): +> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \ +> db.execute(\"UPDATE bot_instances SET identity=NULL WHERE id=''\"); \ +> db.commit()" +> +> # 或者清空所有机器人的身份: +> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \ +> db.execute('UPDATE bot_instances SET identity=NULL'); db.commit()" +> ``` +> +> **影响范围**: +> - ✅ TS3 服务器 + 旧身份:在多数情况下仍可正常工作(TS3 对 legacy 编码更宽容),可选择不清空 +> - ❌ TS6 服务器 + 旧身份:**必须**清空身份才能连接 +> - ⚠️ 清空身份后,TS 服务器会把机器人识别为**全新的客户端**。之前手动赋予机器人的**服务器组需要用新 UID 重新授予一次**,之后每次重启都会自动保留 +> +> 完成这一步后,按下面对应的系统升级步骤即可。 + ### Windows 用户 ``` @@ -232,7 +258,7 @@ npm run build sudo systemctl start tsmusicbot ``` -> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。 +> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。但请注意本节开头关于 **身份迁移** 的警告——从 0.1.x 版本升级时需要手动清空旧身份。 ## 使用说明 From 5692d043c9369a80b31e8fbe7d79b875eae85133 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Sat, 11 Apr 2026 02:15:08 +0800 Subject: [PATCH 17/17] =?UTF-8?q?docs(readme):=20prepare=20for=20merge=20t?= =?UTF-8?q?o=20main=20=E2=80=94=20remove=20dev-branch=20framing,=20add=20c?= =?UTF-8?q?hangelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the top-of-file "dev 分支" warning block and the whole "dev 分支最新变更" section. Content that was only a dev-branch changelog is now folded into the new "更新日志" section. - Reword the identity migration warning in the upgrade section so it refers to the library version (0.1.x → 0.2.x) instead of a specific dev-branch commit hash, and add a "how to tell if you need to migrate" note for users on fresh installs. - Remove "`dev` 分支已实现" phrasing from the TS6 FAQ entry. - Add a new "更新日志" section before "致谢" that summarizes recent changes in four buckets — protocol/stability, HTTP API hardening, connection state consistency, and feature improvements — plus a brief historical milestones block pointing at git log for full history. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 115 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 65 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 25b7da1..dc17d10 100644 --- a/README.md +++ b/README.md @@ -21,51 +21,6 @@

-> **`dev` 分支** — 活跃开发分支,包含最新特性和 bug 修复。 -> !!!如非必要!!!请使用稳定版本,请切换到 [`main`](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/tree/main) 分支。 - ---- - -## dev 分支最新变更 - -### TS3/TS6 双协议支持 - -本分支新增了 TeamSpeak 6 Server 的完整支持。TS6 Server([teamspeak/teamspeak6-server](https://github.com/teamspeak/teamspeak6-server))是 TeamSpeak 全新的自托管服务器,与 TS3 Server 存在协议层差异。 - -**新增模块:** - -| 文件 | 说明 | -|------|------| -| `src/ts-protocol/protocol-detect.ts` | 服务器协议自动检测(并行探测 TS3 port 10011 + TS6 port 10080) | -| `src/ts-protocol/http-query.ts` | TS6 HTTP Query 客户端(替代 TS3 已废弃的 raw TCP ServerQuery) | -| `src/ts-protocol/ts6-compat.ts` | TS6 兼容中间件(升级 clientinit 版本号 + 签名) | - -**关键改动:** - -- **自动协议检测** — 连接时自动判断目标服务器是 TS3 还是 TS6,无需手动配置 -- **TS6 HTTP Query** — TS6 用 HTTP API(10080/10443)替代了 TS3 的 raw TCP ServerQuery(10011),已适配 -- **clientinit 版本升级** — 通过 CommandMiddleware 将客户端版本从 3.5.3 升级到 3.6.2(含匹配 ECDSA 签名),避免 TS6 服务器拒绝连接 -- **License block type 8** — `@honeybbq/teamspeak-client` 已内置 `Ts5Server` 类型支持,握手兼容 TS6 -- **数据库持久化** — `serverProtocol` 和 `ts6ApiKey` 配置持久化到 SQLite,重启不丢失 - -**Bug 修复:** - -- 修复 `playNext()` 重试逻辑中成功重试后仍执行 `player.stop()` 的 bug -- 修复协议探测中 `probeTS3Query` 双重 resolve 竞态条件 -- 修复 `disconnect()` 未清理 `httpQuery` / `udpErrorTimer` 的内存泄漏 -- 修复 `TS6HttpQuery.request()` 双重 reject 问题 -- 添加重复 `connect()` 调用的保护(先断开旧连接) - -### YouTube 音源(可选) - -新增基于 `yt-dlp` 的 YouTube 音源,**默认未启用**。安装 `yt-dlp` 后可通过 `!play -y <关键词>` 或 WebUI 平台选项使用。详见 [可选:YouTube 音源](#可选youtube-音源) 章节的安装步骤。 - -- `src/music/youtube.ts` — YouTubeProvider(通过 `yt-dlp --dump-json` 搜索、`--get-url` 取直链) -- 未安装 `yt-dlp` 时搜索静默返回空结果,不影响其他音源 -- 服务器密码登录(`serverPassword` 字段)、Bot 选择器 UI 改进等特性见 git log - ---- - ## 功能特性 - **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源 @@ -172,11 +127,11 @@ sudo ./scripts/install.sh ## 更新升级 -> **⚠️ 从 dev 分支 `d5d6abe` 或更早版本升级时的重要变更** +> **⚠️ 从使用 `@honeybbq/teamspeak-client 0.1.x` 的旧版本升级时的重要变更** > -> 本次升级将底层 TeamSpeak 协议库 `@honeybbq/teamspeak-client` 从 `0.1.0` 升级到 `0.2.1`,移除了本项目内置的 TS6 兼容层(`src/ts-protocol/ts6-compat.ts`),改用库自带的通用 `clientinit` 协议。这是一次**必要的 bug 修复**,但涉及一个数据库迁移: +> 本项目已将底层 TeamSpeak 协议库升级到 `0.2.x` 并移除了内置的 TS6 兼容层,改用库自带的通用 `clientinit` 协议。这涉及一次**数据库迁移**: > -> **旧的身份(identity)不兼容新的加密握手路径。** 0.1.0 版本的库在生成 TS 客户端身份时存在 P-256 公钥 DER 编码错误,该 bug 在 0.1.1 中由本项目维护者 [ZHANGTIANYAO1](https://github.com/HoneyBBQ/teamspeak-js/pull/5) 修复并合并到上游。0.1.0 生成的身份与 0.2.x 修复后的握手路径**不兼容**:升级后用旧身份连接会卡在 `received initivexpand2` 直到 15 秒超时。 +> **旧的身份(identity)不兼容新的加密握手路径。** `0.1.0` 版本的库在生成 TS 客户端身份时存在 P-256 公钥 DER 编码错误,该 bug 在 `0.1.1` 中由本项目维护者 [ZHANGTIANYAO1](https://github.com/HoneyBBQ/teamspeak-js/pull/5) 修复并合并到上游。`0.1.0` 生成的身份与 `0.2.x` 修复后的握手路径**不兼容**:升级后用旧身份连接会卡在 `received initivexpand2` 直到 15 秒超时。 > > **解决办法**:升级后清空受影响机器人的 `identity` 字段,下次启动时程序会自动生成新身份并持久化。 > @@ -196,7 +151,7 @@ sudo ./scripts/install.sh > - ❌ TS6 服务器 + 旧身份:**必须**清空身份才能连接 > - ⚠️ 清空身份后,TS 服务器会把机器人识别为**全新的客户端**。之前手动赋予机器人的**服务器组需要用新 UID 重新授予一次**,之后每次重启都会自动保留 > -> 完成这一步后,按下面对应的系统升级步骤即可。 +> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。 ### Windows 用户 @@ -468,7 +423,7 @@ pip install -U yt-dlp ## 常见问题 **Q:支持 TeamSpeak 6 Server 吗?** -A:支持。`dev` 分支已实现 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。 +A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。 **Q:机器人连接了但 TeamSpeak 中听不到音乐?** A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。 @@ -512,6 +467,66 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm 4. 推送分支 (`git push origin feature/新功能`) 5. 提交 Pull Request +## 更新日志 + +> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。 + +### 最新版本 + +**协议层 & 稳定性** + +- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。 + - ⚠️ **破坏性**:`0.1.0` 生成的旧身份与新握手路径不兼容,升级时需要迁移。详见 [更新升级](#更新升级) 章节顶部的警告。 +- **修复 `startBot` 与 `stopBot` 之间的竞态**:mid-handshake 被替换的 BotInstance 不再泄漏 TS 会话,`disconnect()` 被 `connect()` 的 await 插队时不再错误地把 `connected` 翻回 `true`。 +- **修复播放条自动刷新 bug**:BotManager 现在在创建新 BotInstance 时 emit `botInstance` 事件,WebSocket 监听器会立即重新挂接到新实例,播放状态变化无需手动刷新页面。 +- **`connect()` 增加 15 秒超时**:握手卡住时会清理掉挂起的实例并返回 500,不再无限阻塞 HTTP 请求和 UI。 +- **识别持久化修复**:`startBot` 现在会从数据库读取 `identity` 传给新 BotInstance,服务器组在机器人重启后能保留。 + +**HTTP API 加固** + +- 新增输入校验,拒绝无效值并返回 **400**(之前会返回 200 包装 usage-text 字符串): + - `/volume`:非数字、`NaN`/`Infinity`、超出 `[0,100]` + - `/mode`:不在 `{seq, loop, random, rloop}` 中的值 + - `/seek`:`NaN`/`Infinity`、负数、字符串 + - `/play-at`:索引越界(**先**校验再停止当前播放,避免误杀正在播的歌) +- **修复 YouTube 平台路由**:`/play`、`/add`、`/playlist`、`/play-by-id`、`/add-by-id`、`/play-playlist` 现在都正确处理 `platform=youtube`(之前会静默回退到网易云)。 +- **修复 `/auth/status?platform=youtube` 数据泄漏**:之前会回退到网易云并返回网易云用户的昵称 + 头像 URL,现在正确路由到 YouTube provider 并报告 `yt-dlp` 的实际可用状态。 +- **`/auth/cookie` 拒绝 `platform=youtube`**,防止意外覆盖网易云 cookie。 + +**连接状态一致性** + +- 断开连接时,音频命令(`play`/`add`/`next`/`prev`/`playlist`/`album`/`fm`)返回 **400 "Bot is not connected to TeamSpeak"**;配置类命令(`volume`/`mode`/`clear`/`stop`/`queue`/`now`/`lyrics`)仍可正常工作,保持 UI 可用。 +- `resolveAndPlay` 在网络请求(URL 解析)前后都会检查 `this.connected`,防止在解析期间被 `stop()` 中断后仍然启动 ffmpeg。 +- `tsClient` 的 `disconnected` 事件处理器现在总是清理播放器状态,不再因为 `connect()` 从未完成而遗留 `playing=true` 的僵尸状态。 + +**功能改进** + +- **YouTube 音源(可选)**:新增基于 `yt-dlp` 的 YouTube provider,通过 `!play -y <关键词>` 或 WebUI 平台选项使用。未安装 `yt-dlp` 时静默降级、返回空结果,不影响其他音源。详见 [可选:YouTube 音源](#可选youtube-音源)。 +- **Bot Selector UI**: + - 始终可见(不再只有 ≥2 个机器人时才显示) + - 尺寸放大(更大的按钮、字体、状态图标) + - 每行增加 **电源按键**(一键启动/停止对应机器人,带禁用态与播放状态高亮) + - 每行增加 **链接按钮**(复制机器人专属 URL) + - 新路由 `/bot/:id`,打开后自动切换到对应机器人 +- **服务器密码登录**:`serverPassword` 字段已加入数据库与 Settings UI,支持加入需要密码的 TS 服务器。 +- **`!add` 一键开播**:在连接状态下向空队列 `!add` 歌曲时自动开始播放(之前只会入队,需要再 `!play` 或 `!next`)。 +- **WebSocket 新增 `botRemoved` 事件**:删除机器人后 UI 会立即从列表中移除(之前需要手动刷新页面)。 + +**内部修复** + +- **`PlayQueue.remove()` 当前歌曲移除 bug**:移除正在播放的歌曲时,`next()` 不再跳过紧跟其后的那首歌。 +- **投票跳过**:需要的票数现在至少为 1(避免 `needed=0` 时单人"全票通过"的边界情况);投票计数会在每首新歌开始时自动清零,不再跨歌曲泄漏。 +- 多处输入边界修复:`seek` 防止 `NaN` 毒化 `seekOffset` 导致 `getElapsed()` 永久返回 `NaN`;`play-at` 越界时不再误杀当前播放。 + +### 历史重要变更 + +更早的变更请查阅 git log。主要里程碑: + +- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。 +- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。 +- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。 +- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。 + ## 致谢 感谢以下项目和开发者: