mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: add voice ducking
This commit is contained in:
1 parent
b51b5a2317
commit
97e8a87305
17 files changed
+1550
-10
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
type ClientInfo,
|
||||
type ClientLeftViewEvent,
|
||||
type ClientMovedEvent,
|
||||
type VoiceData,
|
||||
type FileUploadInfo,
|
||||
} from "@honeybbq/teamspeak-client";
|
||||
import type { Logger } from "../logger.js";
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
type ServerProtocol,
|
||||
} from "./protocol-detect.js";
|
||||
import { TS6HttpQuery } from "./http-query.js";
|
||||
import {
|
||||
TrackingVoiceEndpointResolver,
|
||||
type ResolvedVoiceEndpoint,
|
||||
} from "./voice-endpoint.js";
|
||||
|
||||
export { CODEC_OPUS_MUSIC } from "./voice.js";
|
||||
export type { ServerProtocol } from "./protocol-detect.js";
|
||||
@@ -65,6 +70,13 @@ export interface TS3TextMessage {
|
||||
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
|
||||
}
|
||||
|
||||
/** Lightweight voice-packet signal used for activity detection. The encoded
|
||||
* payload is intentionally not forwarded beyond this protocol wrapper. */
|
||||
export interface TS3VoiceActivity {
|
||||
clientId: number;
|
||||
codec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
|
||||
* sender's TS server groups), which the library populates only when the sender
|
||||
@@ -91,6 +103,7 @@ export class TS3Client extends EventEmitter {
|
||||
private detectedProtocol: ServerProtocol = "unknown";
|
||||
private httpQuery: TS6HttpQuery | null = null;
|
||||
private udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly voiceEndpointResolver = new TrackingVoiceEndpointResolver();
|
||||
|
||||
constructor(private options: TS3ClientOptions, logger: Logger) {
|
||||
super();
|
||||
@@ -114,6 +127,7 @@ export class TS3Client extends EventEmitter {
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.voiceEndpointResolver.reset();
|
||||
// Clean up any existing connection before creating a new one
|
||||
if (this.client) {
|
||||
this.logger.info("Cleaning up previous connection before reconnecting");
|
||||
@@ -213,6 +227,7 @@ export class TS3Client extends EventEmitter {
|
||||
// Forward server password to the protocol library so it can be
|
||||
// included in clientinit for password-protected servers
|
||||
serverPassword: this.options.serverPassword,
|
||||
resolver: this.voiceEndpointResolver,
|
||||
logger: {
|
||||
debug: (msg) => this.logger.debug(msg),
|
||||
info: (msg) => this.logger.info(msg),
|
||||
@@ -226,6 +241,17 @@ export class TS3Client extends EventEmitter {
|
||||
this.emit("textMessage", toTS3TextMessage(msg));
|
||||
});
|
||||
|
||||
this.client.on("voiceData", (voice: VoiceData) => {
|
||||
// The library normally suppresses our own packets; retain the explicit
|
||||
// guard so a future protocol change cannot make a bot duck itself.
|
||||
if (voice.clientId === this.clientId) return;
|
||||
const activity: TS3VoiceActivity = {
|
||||
clientId: voice.clientId,
|
||||
codec: voice.codec,
|
||||
};
|
||||
this.emit("voiceActivity", activity);
|
||||
});
|
||||
|
||||
this.client.on("disconnected", (err) => {
|
||||
this.logger.warn({ err: err?.message }, "Connection closed");
|
||||
this.clientId = 0;
|
||||
@@ -430,6 +456,11 @@ export class TS3Client extends EventEmitter {
|
||||
return this.clientId;
|
||||
}
|
||||
|
||||
/** Actual endpoint selected by the SDK's SRV/TSDNS discovery and DNS lookup. */
|
||||
getResolvedVoiceEndpoint(): ResolvedVoiceEndpoint | null {
|
||||
return this.voiceEndpointResolver.getEndpoint();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.client && !this.disconnecting) {
|
||||
this.disconnecting = true;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AddrResolver, ResolvedAddr } from "@honeybbq/teamspeak-client";
|
||||
import { TrackingVoiceEndpointResolver } from "./voice-endpoint.js";
|
||||
|
||||
function result(addr: string): ResolvedAddr {
|
||||
return { addr, source: "test", expiry: new Date(0) };
|
||||
}
|
||||
|
||||
function delegate(...addresses: string[]): AddrResolver {
|
||||
return {
|
||||
resolve: vi.fn(async () => addresses.map(result)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("TrackingVoiceEndpointResolver", () => {
|
||||
it("pins a DNS alias to the IPv4 endpoint used by the UDP connection", async () => {
|
||||
const resolveHost = vi.fn(async () => "203.0.113.20");
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("voice-alias.example.com:9987"),
|
||||
resolveHost,
|
||||
);
|
||||
|
||||
const resolved = await resolver.resolve("voice.example.com:9987");
|
||||
|
||||
expect(resolveHost).toHaveBeenCalledWith("voice-alias.example.com");
|
||||
expect(resolved[0]?.addr).toBe("203.0.113.20:9987");
|
||||
expect(resolver.getEndpoint()).toEqual({ host: "203.0.113.20", port: 9987 });
|
||||
});
|
||||
|
||||
it("preserves the port chosen by SRV/TSDNS discovery", async () => {
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("srv-target.example.com:12000"),
|
||||
async () => "198.51.100.8",
|
||||
);
|
||||
|
||||
expect((await resolver.resolve("voice.example.com:9987"))[0]?.addr).toBe(
|
||||
"198.51.100.8:12000",
|
||||
);
|
||||
expect(resolver.getEndpoint()?.port).toBe(12000);
|
||||
});
|
||||
|
||||
it("keeps the SDK target as a safe fallback when A-record lookup fails", async () => {
|
||||
const original = "voice.example.com:9987";
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate(original),
|
||||
async () => {
|
||||
throw new Error("dns unavailable");
|
||||
},
|
||||
);
|
||||
|
||||
expect((await resolver.resolve(original))[0]?.addr).toBe(original);
|
||||
expect(resolver.getEndpoint()).toEqual({ host: "voice.example.com", port: 9987 });
|
||||
});
|
||||
|
||||
it("does not mutate secondary SDK candidates", async () => {
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("first.example.com:9987", "second.example.com:9988"),
|
||||
async () => "192.0.2.4",
|
||||
);
|
||||
|
||||
const resolved = await resolver.resolve("voice.example.com:9987");
|
||||
expect(resolved.map((candidate) => candidate.addr)).toEqual([
|
||||
"192.0.2.4:9987",
|
||||
"second.example.com:9988",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears the observed endpoint before a reconnect", async () => {
|
||||
const resolver = new TrackingVoiceEndpointResolver(
|
||||
delegate("voice.example.com:9987"),
|
||||
async () => "192.0.2.5",
|
||||
);
|
||||
await resolver.resolve("voice.example.com:9987");
|
||||
|
||||
resolver.reset();
|
||||
|
||||
expect(resolver.getEndpoint()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { Resolver } from "@honeybbq/teamspeak-client/discovery";
|
||||
import type {
|
||||
AddrResolver,
|
||||
ResolvedAddr,
|
||||
} from "@honeybbq/teamspeak-client";
|
||||
|
||||
export interface ResolvedVoiceEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
type ResolveIpv4 = (host: string) => Promise<string>;
|
||||
|
||||
function parseVoiceAddress(address: string): ResolvedVoiceEndpoint | null {
|
||||
let host: string;
|
||||
let rawPort: string;
|
||||
|
||||
if (address.startsWith("[")) {
|
||||
const closingBracket = address.indexOf("]");
|
||||
if (closingBracket < 0 || address[closingBracket + 1] !== ":") return null;
|
||||
host = address.slice(1, closingBracket);
|
||||
rawPort = address.slice(closingBracket + 2);
|
||||
} else {
|
||||
const separator = address.lastIndexOf(":");
|
||||
if (separator <= 0) return null;
|
||||
host = address.slice(0, separator);
|
||||
rawPort = address.slice(separator + 1);
|
||||
}
|
||||
|
||||
const port = Number(rawPort);
|
||||
if (
|
||||
host.length === 0 ||
|
||||
!Number.isInteger(port) ||
|
||||
port < 1 ||
|
||||
port > 65_535
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function formatVoiceAddress(endpoint: ResolvedVoiceEndpoint): string {
|
||||
return endpoint.host.includes(":")
|
||||
? `[${endpoint.host}]:${endpoint.port}`
|
||||
: `${endpoint.host}:${endpoint.port}`;
|
||||
}
|
||||
|
||||
async function resolveIpv4(host: string): Promise<string> {
|
||||
if (isIP(host) === 4) return host;
|
||||
return (await lookup(host, { family: 4 })).address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the SDK's normal SRV/TSDNS discovery, then pins its selected hostname
|
||||
* to the IPv4 address that the UDP connection will use. Besides making the
|
||||
* connection target observable, this gives all bots a common registry scope
|
||||
* when one is configured with a DNS alias and another with the underlying IP.
|
||||
*/
|
||||
export class TrackingVoiceEndpointResolver implements AddrResolver {
|
||||
private endpoint: ResolvedVoiceEndpoint | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly delegate: AddrResolver = new Resolver(),
|
||||
private readonly resolveHost: ResolveIpv4 = resolveIpv4,
|
||||
) {}
|
||||
|
||||
async resolve(input: string, signal?: AbortSignal): Promise<ResolvedAddr[]> {
|
||||
this.endpoint = null;
|
||||
const candidates = await this.delegate.resolve(input, signal);
|
||||
const selected = candidates[0];
|
||||
if (!selected) return candidates;
|
||||
|
||||
const parsed = parseVoiceAddress(selected.addr);
|
||||
if (!parsed) return candidates;
|
||||
|
||||
try {
|
||||
const pinned = {
|
||||
host: await this.resolveHost(parsed.host),
|
||||
port: parsed.port,
|
||||
};
|
||||
this.endpoint = pinned;
|
||||
return [
|
||||
{ ...selected, addr: formatVoiceAddress(pinned) },
|
||||
...candidates.slice(1),
|
||||
];
|
||||
} catch {
|
||||
// Preserve the SDK's original target if local A-record resolution fails.
|
||||
// The connection may still succeed through platform-specific resolution;
|
||||
// the registry then falls back to the logical host + resolved port.
|
||||
this.endpoint = parsed;
|
||||
return candidates;
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.endpoint = null;
|
||||
}
|
||||
|
||||
getEndpoint(): ResolvedVoiceEndpoint | null {
|
||||
return this.endpoint ? { ...this.endpoint } : null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user