fix: harden voice ducking bot detection

This commit is contained in:
saopig1 committed 2026-07-21 22:05:15 +08:00
1 parent 97e8a87305
commit bc711758b7
7 files changed
+333 -35

No files matched your search

+46 -4
View File
@@ -137,6 +137,10 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
connect: vi.fn(() => connectPromise), connect: vi.fn(() => connectPromise),
getResolvedVoiceEndpoint: vi.fn(() => ({ host: "203.0.113.20", port: 12000 })), getResolvedVoiceEndpoint: vi.fn(() => ({ host: "203.0.113.20", port: 12000 })),
}, },
configuredVoiceServerScope: {
host: "voice-alias.example.com",
voicePort: 9987,
},
voiceServerScope: { host: "voice-alias.example.com", voicePort: 9987 }, voiceServerScope: { host: "voice-alias.example.com", voicePort: 9987 },
voiceDucking: { reset: vi.fn() }, voiceDucking: { reset: vi.fn() },
registerManagedVoiceClient: vi.fn(), registerManagedVoiceClient: vi.fn(),
@@ -172,6 +176,18 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
expect(ctx.voiceDucking.reset).not.toHaveBeenCalled(); expect(ctx.voiceDucking.reset).not.toHaveBeenCalled();
}); });
it("falls back to the configured endpoint when identity discovery is unavailable", async () => {
const ctx = makeConnectContext(Promise.resolve());
ctx.tsClient.getResolvedVoiceEndpoint.mockReturnValue(null as any);
await connect.call(ctx);
expect(ctx.voiceServerScope).toEqual({
host: "voice-alias.example.com",
voicePort: 9987,
});
});
it("routes human voice activity but filters another managed bot", () => { it("routes human voice activity but filters another managed bot", () => {
const tsClient = new EventEmitter() as EventEmitter & { const tsClient = new EventEmitter() as EventEmitter & {
getClientId(): number; getClientId(): number;
@@ -179,7 +195,13 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
tsClient.getClientId = () => 10; tsClient.getClientId = () => 10;
const managedVoiceClients = new ManagedVoiceClientRegistry(); const managedVoiceClients = new ManagedVoiceClientRegistry();
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 }; const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
managedVoiceClients.register(voiceServerScope, 20, {}); managedVoiceClients.register(
{ host: "192.168.1.10", voicePort: 20_000 },
20,
{},
"managed-bot-uid=",
);
managedVoiceClients.register(voiceServerScope, 22, {}, "fallback-bot-uid=");
const handleVoiceActivity = vi.fn(); const handleVoiceActivity = vi.fn();
const ctx = { const ctx = {
tsClient, tsClient,
@@ -194,8 +216,19 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
} as Record<string, any>; } as Record<string, any>;
(BotInstance.prototype as any).setupTsEvents.call(ctx); (BotInstance.prototype as any).setupTsEvents.call(ctx);
tsClient.emit("voiceActivity", { clientId: 20, codec: 5 }); tsClient.emit("voiceActivity", {
tsClient.emit("voiceActivity", { clientId: 21, codec: 5 }); clientId: 20,
codec: 5,
clientUid: "managed-bot-uid=",
});
// If a UID is momentarily unavailable, the scoped client-id registry is
// retained as a fallback for the common same-endpoint case.
tsClient.emit("voiceActivity", { clientId: 22, codec: 5 });
tsClient.emit("voiceActivity", {
clientId: 21,
codec: 5,
clientUid: "human-uid=",
});
expect(handleVoiceActivity).toHaveBeenCalledOnce(); expect(handleVoiceActivity).toHaveBeenCalledOnce();
expect(handleVoiceActivity).toHaveBeenCalledWith(21); expect(handleVoiceActivity).toHaveBeenCalledWith(21);
@@ -207,12 +240,19 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
const managedVoiceClients = new ManagedVoiceClientRegistry(); const managedVoiceClients = new ManagedVoiceClientRegistry();
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 }; const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
const owner = {}; const owner = {};
managedVoiceClients.register(voiceServerScope, 20, owner); managedVoiceClients.register(
voiceServerScope,
20,
owner,
"managed-bot-uid=",
);
const ctx = { const ctx = {
managedVoiceClients, managedVoiceClients,
voiceServerScope, voiceServerScope,
registeredVoiceClientId: 20, registeredVoiceClientId: 20,
registeredVoiceClientOwner: owner, registeredVoiceClientOwner: owner,
registeredVoiceClientScope: voiceServerScope,
registeredVoiceClientUid: "managed-bot-uid=",
}; };
(BotInstance.prototype as any).unregisterManagedVoiceClient.call(ctx, 1_000); (BotInstance.prototype as any).unregisterManagedVoiceClient.call(ctx, 1_000);
@@ -220,11 +260,13 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
// cleanup must still target the scope that owned the old client id. // cleanup must still target the scope that owned the old client id.
ctx.voiceServerScope = { host: "other.example.com", voicePort: 9987 }; ctx.voiceServerScope = { host: "other.example.com", voicePort: 9987 };
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true); expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(true);
vi.advanceTimersByTime(999); vi.advanceTimersByTime(999);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true); expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(false); expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(false);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(false);
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }
+27 -11
View File
@@ -155,9 +155,12 @@ export class BotInstance extends EventEmitter {
private player: AudioPlayer; private player: AudioPlayer;
private voiceDucking: VoiceDuckingController; private voiceDucking: VoiceDuckingController;
private managedVoiceClients: ManagedVoiceClientRegistry; private managedVoiceClients: ManagedVoiceClientRegistry;
private readonly configuredVoiceServerScope: ManagedVoiceClientScope;
private voiceServerScope: ManagedVoiceClientScope; private voiceServerScope: ManagedVoiceClientScope;
private registeredVoiceClientId = 0; private registeredVoiceClientId = 0;
private registeredVoiceClientOwner: ManagedVoiceClientOwnerToken | null = null; private registeredVoiceClientOwner: ManagedVoiceClientOwnerToken | null = null;
private registeredVoiceClientScope: ManagedVoiceClientScope | null = null;
private registeredVoiceClientUid: string | null = null;
private spotifyController: SpotifyController; private spotifyController: SpotifyController;
private queue: PlayQueue; private queue: PlayQueue;
private neteaseProvider: MusicProvider; private neteaseProvider: MusicProvider;
@@ -222,10 +225,11 @@ export class BotInstance extends EventEmitter {
); );
this.managedVoiceClients = this.managedVoiceClients =
options.managedVoiceClients ?? new ManagedVoiceClientRegistry(); options.managedVoiceClients ?? new ManagedVoiceClientRegistry();
this.voiceServerScope = { this.configuredVoiceServerScope = {
host: options.tsOptions.host, host: options.tsOptions.host,
voicePort: options.tsOptions.port, voicePort: options.tsOptions.port,
}; };
this.voiceServerScope = { ...this.configuredVoiceServerScope };
this.queue = new PlayQueue(); this.queue = new PlayQueue();
// Restore persisted per-bot player settings (#125): volume + play mode // Restore persisted per-bot player settings (#125): volume + play mode
@@ -422,7 +426,10 @@ export class BotInstance extends EventEmitter {
this.tsClient.on("voiceActivity", (activity: TS3VoiceActivity) => { this.tsClient.on("voiceActivity", (activity: TS3VoiceActivity) => {
if (!this.connected) return; if (!this.connected) return;
if (this.managedVoiceClients.has(this.voiceServerScope, activity.clientId)) { if (
this.managedVoiceClients.hasClientUid(activity.clientUid) ||
this.managedVoiceClients.has(this.voiceServerScope, activity.clientId)
) {
return; return;
} }
this.voiceDucking.handleVoiceActivity(activity.clientId); this.voiceDucking.handleVoiceActivity(activity.clientId);
@@ -461,22 +468,31 @@ export class BotInstance extends EventEmitter {
if (!Number.isSafeInteger(clientId) || clientId <= 0) return; if (!Number.isSafeInteger(clientId) || clientId <= 0) return;
const owner = {}; const owner = {};
if (this.managedVoiceClients.register(this.voiceServerScope, clientId, owner)) { const scope = { ...this.voiceServerScope };
const clientUid = this.tsClient.getClientUid();
if (this.managedVoiceClients.register(scope, clientId, owner, clientUid)) {
this.registeredVoiceClientId = clientId; this.registeredVoiceClientId = clientId;
this.registeredVoiceClientOwner = owner; this.registeredVoiceClientOwner = owner;
this.registeredVoiceClientScope = scope;
this.registeredVoiceClientUid = clientUid;
} }
} }
private unregisterManagedVoiceClient(graceMs = 0): void { private unregisterManagedVoiceClient(graceMs = 0): void {
const clientId = this.registeredVoiceClientId; const clientId = this.registeredVoiceClientId;
const owner = this.registeredVoiceClientOwner; const owner = this.registeredVoiceClientOwner;
const scope = { ...this.voiceServerScope }; const clientUid = this.registeredVoiceClientUid ?? undefined;
const scope = this.registeredVoiceClientScope
? { ...this.registeredVoiceClientScope }
: { ...this.voiceServerScope };
this.registeredVoiceClientId = 0; this.registeredVoiceClientId = 0;
this.registeredVoiceClientOwner = null; this.registeredVoiceClientOwner = null;
this.registeredVoiceClientScope = null;
this.registeredVoiceClientUid = null;
if (clientId <= 0 || owner === null) return; if (clientId <= 0 || owner === null) return;
const unregister = () => { const unregister = () => {
this.managedVoiceClients.unregister(scope, clientId, owner); this.managedVoiceClients.unregister(scope, clientId, owner, clientUid);
}; };
if (graceMs > 0) { if (graceMs > 0) {
const timer = setTimeout(unregister, graceMs); const timer = setTimeout(unregister, graceMs);
@@ -523,12 +539,12 @@ export class BotInstance extends EventEmitter {
this.disconnectEmitted = false; this.disconnectEmitted = false;
await this.tsClient.connect(); await this.tsClient.connect();
const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint(); const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint();
if (resolvedEndpoint) { this.voiceServerScope = {
this.voiceServerScope = { host:
host: resolvedEndpoint.host, resolvedEndpoint?.host ?? this.configuredVoiceServerScope.host,
voicePort: resolvedEndpoint.port, voicePort:
}; resolvedEndpoint?.port ?? this.configuredVoiceServerScope.voicePort,
} };
// Race guard: if disconnect() was called while the handshake was // Race guard: if disconnect() was called while the handshake was
// awaiting, don't flip connected back to true — that would leave the // awaiting, don't flip connected back to true — that would leave the
// bot in an inconsistent state (externally "connected" but the tsClient // bot in an inconsistent state (externally "connected" but the tsClient
+57 -4
View File
@@ -39,6 +39,7 @@ describe("managed voice client scope normalization", () => {
}), }),
).toBeNull(); ).toBeNull();
}); });
}); });
describe("ManagedVoiceClientRegistry", () => { describe("ManagedVoiceClientRegistry", () => {
@@ -74,6 +75,50 @@ describe("ManagedVoiceClientRegistry", () => {
).toBe(false); ).toBe(false);
}); });
it("finds a managed bot by stable client UID across network endpoints", () => {
const registry = new ManagedVoiceClientRegistry();
const owner = Symbol("connection");
registry.register(
{ host: "127.0.0.1", voicePort: 9987 },
17,
owner,
" managed-client-uid= ",
);
expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
expect(
registry.has({ host: "192.168.1.10", voicePort: 20_000 }, 17),
).toBe(false);
});
it("keeps a shared managed UID until its last owner unregisters", () => {
const registry = new ManagedVoiceClientRegistry();
const scope = { host: "203.0.113.4", voicePort: 9987 };
const first = Symbol("first connection");
const second = Symbol("second connection");
registry.register(scope, 18, first, "shared-client-uid=");
registry.register(scope, 19, second, "shared-client-uid=");
expect(registry.unregister(scope, 18, first, "shared-client-uid=")).toBe(true);
expect(registry.hasClientUid("shared-client-uid=")).toBe(true);
expect(registry.unregister(scope, 19, second, "shared-client-uid=")).toBe(true);
expect(registry.hasClientUid("shared-client-uid=")).toBe(false);
});
it("ignores missing or empty client UIDs", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
{ host: "203.0.113.4", voicePort: 9987 },
19,
Symbol("connection"),
" ",
);
expect(registry.hasClientUid(undefined)).toBe(false);
expect(registry.hasClientUid(" ")).toBe(false);
});
it("uses an IPv6-safe scope key", () => { it("uses an IPv6-safe scope key", () => {
const registry = new ManagedVoiceClientRegistry(); const registry = new ManagedVoiceClientRegistry();
registry.register( registry.register(
@@ -93,13 +138,21 @@ describe("ManagedVoiceClientRegistry", () => {
const oldConnection = Symbol("old connection"); const oldConnection = Symbol("old connection");
const newConnection = Symbol("new connection"); const newConnection = Symbol("new connection");
registry.register(scope, 12, oldConnection); registry.register(scope, 12, oldConnection, "managed-client-uid=");
registry.register(scope, 12, newConnection); registry.register(scope, 12, newConnection, "managed-client-uid=");
expect(registry.unregister(scope, 12, oldConnection)).toBe(false); // The old UID owner is removed, but the replacement still owns both the
// scoped id and the shared stable UID.
expect(
registry.unregister(scope, 12, oldConnection, "managed-client-uid="),
).toBe(true);
expect(registry.has(scope, 12)).toBe(true); expect(registry.has(scope, 12)).toBe(true);
expect(registry.unregister(scope, 12, newConnection)).toBe(true); expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
expect(
registry.unregister(scope, 12, newConnection, "managed-client-uid="),
).toBe(true);
expect(registry.has(scope, 12)).toBe(false); expect(registry.has(scope, 12)).toBe(false);
expect(registry.hasClientUid("managed-client-uid=")).toBe(false);
}); });
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
+49 -6
View File
@@ -80,8 +80,17 @@ function validClientId(clientId: number): boolean {
return Number.isSafeInteger(clientId) && clientId > 0; return Number.isSafeInteger(clientId) && clientId > 0;
} }
function normalizeClientUid(clientUid: string | undefined): string | null {
if (typeof clientUid !== "string") return null;
const normalized = clientUid.trim();
return normalized.length > 0 ? normalized : null;
}
/** /**
* Tracks voice client ids owned by bot connections in this process. * Tracks voice client ids and stable TeamSpeak identities owned by bot
* connections in this process. The UID path survives DNS aliases, NAT,
* multiple NICs, and dual-stack endpoints; scoped ids remain a fallback when
* a sender has not yet appeared in the receiving client's view cache.
* *
* This class intentionally has no module-level singleton. BotManager owns one * This class intentionally has no module-level singleton. BotManager owns one
* instance and injects it into its BotInstances so separate managers remain * instance and injects it into its BotInstances so separate managers remain
@@ -92,15 +101,21 @@ export class ManagedVoiceClientRegistry {
string, string,
Map<number, ManagedVoiceClientOwnerToken> Map<number, ManagedVoiceClientOwnerToken>
>(); >();
private readonly ownersByClientUid = new Map<
string,
Set<ManagedVoiceClientOwnerToken>
>();
/** /**
* Register (or replace) the connection that owns a client id. * Register (or replace) the connection that owns a client id and, when
* available, add its stable UID to the managed set.
* Returns false when the scope or client id is invalid. * Returns false when the scope or client id is invalid.
*/ */
register( register(
scope: ManagedVoiceClientScope, scope: ManagedVoiceClientScope,
clientId: number, clientId: number,
ownerToken: ManagedVoiceClientOwnerToken, ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean { ): boolean {
const key = scopeKey(scope); const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false; if (!key || !validClientId(clientId)) return false;
@@ -111,6 +126,16 @@ export class ManagedVoiceClientRegistry {
this.clientsByScope.set(key, clients); this.clientsByScope.set(key, clients);
} }
clients.set(clientId, ownerToken); clients.set(clientId, ownerToken);
const normalizedUid = normalizeClientUid(clientUid);
if (normalizedUid) {
let owners = this.ownersByClientUid.get(normalizedUid);
if (!owners) {
owners = new Set();
this.ownersByClientUid.set(normalizedUid, owners);
}
owners.add(ownerToken);
}
return true; return true;
} }
@@ -124,16 +149,26 @@ export class ManagedVoiceClientRegistry {
scope: ManagedVoiceClientScope, scope: ManagedVoiceClientScope,
clientId: number, clientId: number,
ownerToken: ManagedVoiceClientOwnerToken, ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean { ): boolean {
const key = scopeKey(scope); const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false; if (!key || !validClientId(clientId)) return false;
let removed = false;
const clients = this.clientsByScope.get(key); const clients = this.clientsByScope.get(key);
if (!clients || clients.get(clientId) !== ownerToken) return false; if (clients?.get(clientId) === ownerToken) {
clients.delete(clientId);
if (clients.size === 0) this.clientsByScope.delete(key);
removed = true;
}
clients.delete(clientId); const normalizedUid = normalizeClientUid(clientUid);
if (clients.size === 0) this.clientsByScope.delete(key); if (normalizedUid) {
return true; const owners = this.ownersByClientUid.get(normalizedUid);
if (owners?.delete(ownerToken)) removed = true;
if (owners?.size === 0) this.ownersByClientUid.delete(normalizedUid);
}
return removed;
} }
has(scope: ManagedVoiceClientScope, clientId: number): boolean { has(scope: ManagedVoiceClientScope, clientId: number): boolean {
@@ -141,4 +176,12 @@ export class ManagedVoiceClientRegistry {
if (!key || !validClientId(clientId)) return false; if (!key || !validClientId(clientId)) return false;
return this.clientsByScope.get(key)?.has(clientId) ?? false; return this.clientsByScope.get(key)?.has(clientId) ?? false;
} }
/** TeamSpeak client UIDs are stable across endpoint aliases and NAT paths. */
hasClientUid(clientUid: string | undefined): boolean {
const normalizedUid = normalizeClientUid(clientUid);
return normalizedUid
? (this.ownersByClientUid.get(normalizedUid)?.size ?? 0) > 0
: false;
}
} }
+57 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest"; import { afterEach, describe, it, expect, vi } from "vitest";
import pino from "pino"; import pino from "pino";
import { TS3Client } from "./client.js"; import { TS3Client } from "./client.js";
@@ -83,3 +83,59 @@ describe("TS3Client.getClientServerGroups — live query + parse smoke test", ()
expect(await ts.getClientServerGroups(5)).toEqual([]); expect(await ts.getClientServerGroups(5)).toEqual([]);
}); });
}); });
describe("TS3Client stable identity UID", () => {
it("derives the same client UID after exporting and restoring an identity", () => {
const first = makeClient();
const restored = new TS3Client(
{
host: "localhost",
port: 9987,
queryPort: 10011,
nickname: "RestoredBot",
identity: first.getIdentityExport(),
},
pino({ level: "silent" }),
);
expect(first.getClientUid()).toBeTruthy();
expect(restored.getClientUid()).toBe(first.getClientUid());
});
});
type VisibleUidHarness = {
visibleClientUids: Map<number, string>;
rememberVisibleClientUid(clientId: number, clientUid: string): void;
releaseVisibleClientUid(clientId: number): void;
clearVisibleClientUids(): void;
};
describe("TS3Client visible client UID grace", () => {
afterEach(() => vi.useRealTimers());
it("retains a leaving client's UID for final reordered voice packets", () => {
vi.useFakeTimers();
const cache = makeClient() as unknown as VisibleUidHarness;
cache.rememberVisibleClientUid(7, "managed-bot-uid=");
cache.releaseVisibleClientUid(7);
vi.advanceTimersByTime(999);
expect(cache.visibleClientUids.get(7)).toBe("managed-bot-uid=");
vi.advanceTimersByTime(1);
expect(cache.visibleClientUids.has(7)).toBe(false);
});
it("lets a new clientEnter overwrite a reused id and cancel stale cleanup", () => {
vi.useFakeTimers();
const cache = makeClient() as unknown as VisibleUidHarness;
cache.rememberVisibleClientUid(7, "old-managed-bot-uid=");
cache.releaseVisibleClientUid(7);
cache.rememberVisibleClientUid(7, "new-human-uid=");
vi.advanceTimersByTime(1_000);
expect(cache.visibleClientUids.get(7)).toBe("new-human-uid=");
cache.clearVisibleClientUids();
});
});
+63
View File
@@ -3,6 +3,7 @@ import { Readable } from "node:stream";
import { import {
Client as TS3FullClient, Client as TS3FullClient,
generateIdentity as genTS3Identity, generateIdentity as genTS3Identity,
getUidFromPublicKey,
identityFromString, identityFromString,
sendTextMessage, sendTextMessage,
listChannels, listChannels,
@@ -75,8 +76,15 @@ export interface TS3TextMessage {
export interface TS3VoiceActivity { export interface TS3VoiceActivity {
clientId: number; clientId: number;
codec: number; codec: number;
/** Stable TeamSpeak identity when the sender is present in the client view. */
clientUid?: string;
} }
// Command notifications and UDP voice packets can be reordered in flight.
// Retain a leaving client's UID briefly so its final packet is still
// attributable; a new clientEnter for the same id cancels and overwrites it.
const VISIBLE_CLIENT_UID_RELEASE_GRACE_MS = 1_000;
/** /**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the * Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender * sender's TS server groups), which the library populates only when the sender
@@ -97,7 +105,13 @@ export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
export class TS3Client extends EventEmitter { export class TS3Client extends EventEmitter {
private client: TS3FullClient | null = null; private client: TS3FullClient | null = null;
private identity: Identity; private identity: Identity;
private readonly clientUid: string;
private clientId = 0; private clientId = 0;
private readonly visibleClientUids = new Map<number, string>();
private readonly visibleClientUidReleaseTimers = new Map<
number,
ReturnType<typeof setTimeout>
>();
private logger: Logger; private logger: Logger;
private disconnecting = false; private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown"; private detectedProtocol: ServerProtocol = "unknown";
@@ -114,6 +128,7 @@ export class TS3Client extends EventEmitter {
} else { } else {
this.identity = genTS3Identity(8); this.identity = genTS3Identity(8);
} }
this.clientUid = getUidFromPublicKey(this.identity.publicKeyBase64());
} }
/** The detected (or forced) server protocol after connect(). */ /** The detected (or forced) server protocol after connect(). */
@@ -128,6 +143,7 @@ export class TS3Client extends EventEmitter {
async connect(): Promise<void> { async connect(): Promise<void> {
this.voiceEndpointResolver.reset(); this.voiceEndpointResolver.reset();
this.clearVisibleClientUids();
// Clean up any existing connection before creating a new one // Clean up any existing connection before creating a new one
if (this.client) { if (this.client) {
this.logger.info("Cleaning up previous connection before reconnecting"); this.logger.info("Cleaning up previous connection before reconnecting");
@@ -245,9 +261,11 @@ export class TS3Client extends EventEmitter {
// The library normally suppresses our own packets; retain the explicit // The library normally suppresses our own packets; retain the explicit
// guard so a future protocol change cannot make a bot duck itself. // guard so a future protocol change cannot make a bot duck itself.
if (voice.clientId === this.clientId) return; if (voice.clientId === this.clientId) return;
const clientUid = this.visibleClientUids.get(voice.clientId);
const activity: TS3VoiceActivity = { const activity: TS3VoiceActivity = {
clientId: voice.clientId, clientId: voice.clientId,
codec: voice.codec, codec: voice.codec,
...(clientUid ? { clientUid } : {}),
}; };
this.emit("voiceActivity", activity); this.emit("voiceActivity", activity);
}); });
@@ -255,10 +273,12 @@ export class TS3Client extends EventEmitter {
this.client.on("disconnected", (err) => { this.client.on("disconnected", (err) => {
this.logger.warn({ err: err?.message }, "Connection closed"); this.logger.warn({ err: err?.message }, "Connection closed");
this.clientId = 0; this.clientId = 0;
this.clearVisibleClientUids();
this.emit("disconnected"); this.emit("disconnected");
}); });
this.client.on("clientEnter", (info: ClientInfo) => { this.client.on("clientEnter", (info: ClientInfo) => {
this.rememberVisibleClientUid(info.id, info.uid);
this.logger.debug( this.logger.debug(
{ nickname: info.nickname, id: info.id }, { nickname: info.nickname, id: info.id },
"Client entered" "Client entered"
@@ -267,6 +287,7 @@ export class TS3Client extends EventEmitter {
}); });
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => { this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
this.releaseVisibleClientUid(ev.id);
this.logger.debug({ id: ev.id }, "Client left"); this.logger.debug({ id: ev.id }, "Client left");
this.emit("clientLeave", ev); this.emit("clientLeave", ev);
}); });
@@ -461,6 +482,47 @@ export class TS3Client extends EventEmitter {
return this.voiceEndpointResolver.getEndpoint(); return this.voiceEndpointResolver.getEndpoint();
} }
/** Stable identity of this managed TeamSpeak client. */
getClientUid(): string {
return this.clientUid;
}
private rememberVisibleClientUid(clientId: number, clientUid: string): void {
const pendingRelease = this.visibleClientUidReleaseTimers.get(clientId);
if (pendingRelease) clearTimeout(pendingRelease);
this.visibleClientUidReleaseTimers.delete(clientId);
if (clientId > 0 && clientUid) {
this.visibleClientUids.set(clientId, clientUid);
} else {
this.visibleClientUids.delete(clientId);
}
}
private releaseVisibleClientUid(clientId: number): void {
const clientUid = this.visibleClientUids.get(clientId);
if (!clientUid) return;
const previous = this.visibleClientUidReleaseTimers.get(clientId);
if (previous) clearTimeout(previous);
const timer = setTimeout(() => {
if (this.visibleClientUids.get(clientId) === clientUid) {
this.visibleClientUids.delete(clientId);
}
this.visibleClientUidReleaseTimers.delete(clientId);
}, VISIBLE_CLIENT_UID_RELEASE_GRACE_MS);
timer.unref?.();
this.visibleClientUidReleaseTimers.set(clientId, timer);
}
private clearVisibleClientUids(): void {
for (const timer of this.visibleClientUidReleaseTimers.values()) {
clearTimeout(timer);
}
this.visibleClientUidReleaseTimers.clear();
this.visibleClientUids.clear();
}
disconnect(): void { disconnect(): void {
if (this.client && !this.disconnecting) { if (this.client && !this.disconnecting) {
this.disconnecting = true; this.disconnecting = true;
@@ -473,6 +535,7 @@ export class TS3Client extends EventEmitter {
}); });
} }
this.clientId = 0; this.clientId = 0;
this.clearVisibleClientUids();
this.httpQuery = null; this.httpQuery = null;
this.detectedProtocol = "unknown"; this.detectedProtocol = "unknown";
if (this.udpErrorTimer) { if (this.udpErrorTimer) {
+34 -9
View File
@@ -787,7 +787,7 @@
v-model="voiceDuckingEnabled" v-model="voiceDuckingEnabled"
type="checkbox" type="checkbox"
class="profile-toggle-switch" class="profile-toggle-switch"
:disabled="voiceDuckingSaving" :disabled="voiceDuckingControlsDisabled"
@change="saveVoiceDucking" @change="saveVoiceDucking"
/> />
</label> </label>
@@ -808,7 +808,7 @@
max="100" max="100"
step="0.1" step="0.1"
class="voice-ducking-range" class="voice-ducking-range"
:disabled="voiceDuckingSaving" :disabled="voiceDuckingControlsDisabled"
aria-label="说话时保留原音量百分比" aria-label="说话时保留原音量百分比"
/> />
<div class="prefix-input-wrap"> <div class="prefix-input-wrap">
@@ -820,17 +820,24 @@
step="0.1" step="0.1"
class="input input-sm" class="input input-sm"
style="max-width:80px" style="max-width:80px"
:disabled="voiceDuckingSaving" :disabled="voiceDuckingControlsDisabled"
aria-label="说话时保留原音量百分比" aria-label="说话时保留原音量百分比"
@blur="normalizeVoiceDuckingVolume" @blur="normalizeVoiceDuckingVolume"
/> />
<span class="voice-ducking-unit">%</span> <span class="voice-ducking-unit">%</span>
<button class="btn-primary" :disabled="voiceDuckingSaving" @click="saveVoiceDucking"> <button class="btn-primary" :disabled="voiceDuckingControlsDisabled" @click="saveVoiceDucking">
{{ voiceDuckingSaving ? '保存中…' : '保存比例' }} {{ !voiceDuckingLoaded ? '加载设置…' : voiceDuckingSaving ? '保存中…' : '保存比例' }}
</button> </button>
</div> </div>
</div> </div>
<p v-if="voiceDuckingMessage" class="voice-ducking-message" :class="`tone-${voiceDuckingMessageTone}`"> <p
v-if="voiceDuckingMessage"
class="voice-ducking-message"
:class="`tone-${voiceDuckingMessageTone}`"
:role="voiceDuckingMessageTone === 'warn' ? 'alert' : 'status'"
:aria-live="voiceDuckingMessageTone === 'warn' ? 'assertive' : 'polite'"
aria-atomic="true"
>
{{ voiceDuckingMessage }} {{ voiceDuckingMessage }}
</p> </p>
</div> </div>
@@ -1655,10 +1662,15 @@ const autoPauseOnEmpty = ref(false);
// Voice ducking defaults OFF and retains 30% of the configured player volume. // Voice ducking defaults OFF and retains 30% of the configured player volume.
const voiceDuckingEnabled = ref(false); const voiceDuckingEnabled = ref(false);
const voiceDuckingVolumePercent = ref(30); const voiceDuckingVolumePercent = ref(30);
const voiceDuckingLoaded = ref(false);
const voiceDuckingSaving = ref(false); const voiceDuckingSaving = ref(false);
const voiceDuckingControlsDisabled = computed(
() => !voiceDuckingLoaded.value || voiceDuckingSaving.value,
);
const voiceDuckingMessage = ref(''); const voiceDuckingMessage = ref('');
const voiceDuckingMessageTone = ref<'ok' | 'warn'>('ok'); const voiceDuckingMessageTone = ref<'ok' | 'warn'>('ok');
let savedVoiceDucking = { enabled: false, volumePercent: 30 }; let savedVoiceDucking = { enabled: false, volumePercent: 30 };
let voiceDuckingRequestRevision = 0;
const localAudioEnabled = ref(true); const localAudioEnabled = ref(true);
// Saved-queues + play-keeps-queue toggles (#119), both default OFF. // Saved-queues + play-keeps-queue toggles (#119), both default OFF.
const savedQueuesEnabled = ref(false); const savedQueuesEnabled = ref(false);
@@ -1688,11 +1700,18 @@ function applyVoiceDuckingConfig(config: unknown) {
} }
async function loadIdleTimeout() { async function loadIdleTimeout() {
const voiceDuckingLoadRevision = voiceDuckingRequestRevision;
try { try {
const res = await axios.get('/api/bot/settings'); const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0; idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false; autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
applyVoiceDuckingConfig(res.data.voiceDucking ?? { enabled: false, volumePercent: 30 }); // A later save owns the state. Do not let an older GET response overwrite
// it if this loader is ever re-entered while a POST is in flight.
if (voiceDuckingLoadRevision === voiceDuckingRequestRevision) {
applyVoiceDuckingConfig(res.data.voiceDucking ?? { enabled: false, volumePercent: 30 });
voiceDuckingLoaded.value = true;
voiceDuckingMessage.value = '';
}
localAudioEnabled.value = res.data.localAudioEnabled ?? true; localAudioEnabled.value = res.data.localAudioEnabled ?? true;
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false; savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false;
playKeepsQueue.value = res.data.playKeepsQueue ?? false; playKeepsQueue.value = res.data.playKeepsQueue ?? false;
@@ -1708,7 +1727,12 @@ async function loadIdleTimeout() {
} }
// null (unset) → "" so the select shows "自动(按优先级)". // null (unset) → "" so the select shows "自动(按优先级)".
defaultPlatformForm.value = res.data.defaultPlatform ?? ''; defaultPlatformForm.value = res.data.defaultPlatform ?? '';
} catch { /* ignore */ } } catch {
if (!voiceDuckingLoaded.value) {
voiceDuckingMessageTone.value = 'warn';
voiceDuckingMessage.value = '语音闪避设置加载失败,请刷新页面重试';
}
}
} }
async function saveIdleTimeout() { async function saveIdleTimeout() {
@@ -1724,8 +1748,9 @@ async function saveAutoPause() {
} }
async function saveVoiceDucking() { async function saveVoiceDucking() {
if (voiceDuckingSaving.value) return; if (!voiceDuckingLoaded.value || voiceDuckingSaving.value) return;
voiceDuckingSaving.value = true; voiceDuckingSaving.value = true;
voiceDuckingRequestRevision++;
voiceDuckingMessage.value = ''; voiceDuckingMessage.value = '';
const submitted = { const submitted = {
enabled: voiceDuckingEnabled.value, enabled: voiceDuckingEnabled.value,