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),
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 },
voiceDucking: { reset: vi.fn() },
registerManagedVoiceClient: vi.fn(),
@@ -172,6 +176,18 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
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", () => {
const tsClient = new EventEmitter() as EventEmitter & {
getClientId(): number;
@@ -179,7 +195,13 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
tsClient.getClientId = () => 10;
const managedVoiceClients = new ManagedVoiceClientRegistry();
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 ctx = {
tsClient,
@@ -194,8 +216,19 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
} as Record<string, any>;
(BotInstance.prototype as any).setupTsEvents.call(ctx);
tsClient.emit("voiceActivity", { clientId: 20, codec: 5 });
tsClient.emit("voiceActivity", { clientId: 21, codec: 5 });
tsClient.emit("voiceActivity", {
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).toHaveBeenCalledWith(21);
@@ -207,12 +240,19 @@ describe("BotInstance voice-ducking lifecycle integration", () => {
const managedVoiceClients = new ManagedVoiceClientRegistry();
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
const owner = {};
managedVoiceClients.register(voiceServerScope, 20, owner);
managedVoiceClients.register(
voiceServerScope,
20,
owner,
"managed-bot-uid=",
);
const ctx = {
managedVoiceClients,
voiceServerScope,
registeredVoiceClientId: 20,
registeredVoiceClientOwner: owner,
registeredVoiceClientScope: voiceServerScope,
registeredVoiceClientUid: "managed-bot-uid=",
};
(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.
ctx.voiceServerScope = { host: "other.example.com", voicePort: 9987 };
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(true);
vi.advanceTimersByTime(999);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
vi.advanceTimersByTime(1);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(false);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(false);
} finally {
vi.useRealTimers();
}
+27 -11
View File
@@ -155,9 +155,12 @@ export class BotInstance extends EventEmitter {
private player: AudioPlayer;
private voiceDucking: VoiceDuckingController;
private managedVoiceClients: ManagedVoiceClientRegistry;
private readonly configuredVoiceServerScope: ManagedVoiceClientScope;
private voiceServerScope: ManagedVoiceClientScope;
private registeredVoiceClientId = 0;
private registeredVoiceClientOwner: ManagedVoiceClientOwnerToken | null = null;
private registeredVoiceClientScope: ManagedVoiceClientScope | null = null;
private registeredVoiceClientUid: string | null = null;
private spotifyController: SpotifyController;
private queue: PlayQueue;
private neteaseProvider: MusicProvider;
@@ -222,10 +225,11 @@ export class BotInstance extends EventEmitter {
);
this.managedVoiceClients =
options.managedVoiceClients ?? new ManagedVoiceClientRegistry();
this.voiceServerScope = {
this.configuredVoiceServerScope = {
host: options.tsOptions.host,
voicePort: options.tsOptions.port,
};
this.voiceServerScope = { ...this.configuredVoiceServerScope };
this.queue = new PlayQueue();
// 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) => {
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;
}
this.voiceDucking.handleVoiceActivity(activity.clientId);
@@ -461,22 +468,31 @@ export class BotInstance extends EventEmitter {
if (!Number.isSafeInteger(clientId) || clientId <= 0) return;
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.registeredVoiceClientOwner = owner;
this.registeredVoiceClientScope = scope;
this.registeredVoiceClientUid = clientUid;
}
}
private unregisterManagedVoiceClient(graceMs = 0): void {
const clientId = this.registeredVoiceClientId;
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.registeredVoiceClientOwner = null;
this.registeredVoiceClientScope = null;
this.registeredVoiceClientUid = null;
if (clientId <= 0 || owner === null) return;
const unregister = () => {
this.managedVoiceClients.unregister(scope, clientId, owner);
this.managedVoiceClients.unregister(scope, clientId, owner, clientUid);
};
if (graceMs > 0) {
const timer = setTimeout(unregister, graceMs);
@@ -523,12 +539,12 @@ export class BotInstance extends EventEmitter {
this.disconnectEmitted = false;
await this.tsClient.connect();
const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint();
if (resolvedEndpoint) {
this.voiceServerScope = {
host: resolvedEndpoint.host,
voicePort: resolvedEndpoint.port,
};
}
this.voiceServerScope = {
host:
resolvedEndpoint?.host ?? this.configuredVoiceServerScope.host,
voicePort:
resolvedEndpoint?.port ?? this.configuredVoiceServerScope.voicePort,
};
// 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
+57 -4
View File
@@ -39,6 +39,7 @@ describe("managed voice client scope normalization", () => {
}),
).toBeNull();
});
});
describe("ManagedVoiceClientRegistry", () => {
@@ -74,6 +75,50 @@ describe("ManagedVoiceClientRegistry", () => {
).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", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
@@ -93,13 +138,21 @@ describe("ManagedVoiceClientRegistry", () => {
const oldConnection = Symbol("old connection");
const newConnection = Symbol("new connection");
registry.register(scope, 12, oldConnection);
registry.register(scope, 12, newConnection);
registry.register(scope, 12, oldConnection, "managed-client-uid=");
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.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.hasClientUid("managed-client-uid=")).toBe(false);
});
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;
}
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
* instance and injects it into its BotInstances so separate managers remain
@@ -92,15 +101,21 @@ export class ManagedVoiceClientRegistry {
string,
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.
*/
register(
scope: ManagedVoiceClientScope,
clientId: number,
ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
@@ -111,6 +126,16 @@ export class ManagedVoiceClientRegistry {
this.clientsByScope.set(key, clients);
}
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;
}
@@ -124,16 +149,26 @@ export class ManagedVoiceClientRegistry {
scope: ManagedVoiceClientScope,
clientId: number,
ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
let removed = false;
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);
if (clients.size === 0) this.clientsByScope.delete(key);
return true;
const normalizedUid = normalizeClientUid(clientUid);
if (normalizedUid) {
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 {
@@ -141,4 +176,12 @@ export class ManagedVoiceClientRegistry {
if (!key || !validClientId(clientId)) return 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;
}
}