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

+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 { TS3Client } from "./client.js";
@@ -83,3 +83,59 @@ describe("TS3Client.getClientServerGroups — live query + parse smoke test", ()
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 {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
getUidFromPublicKey,
identityFromString,
sendTextMessage,
listChannels,
@@ -75,8 +76,15 @@ export interface TS3TextMessage {
export interface TS3VoiceActivity {
clientId: 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
* 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 {
private client: TS3FullClient | null = null;
private identity: Identity;
private readonly clientUid: string;
private clientId = 0;
private readonly visibleClientUids = new Map<number, string>();
private readonly visibleClientUidReleaseTimers = new Map<
number,
ReturnType<typeof setTimeout>
>();
private logger: Logger;
private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown";
@@ -114,6 +128,7 @@ export class TS3Client extends EventEmitter {
} else {
this.identity = genTS3Identity(8);
}
this.clientUid = getUidFromPublicKey(this.identity.publicKeyBase64());
}
/** The detected (or forced) server protocol after connect(). */
@@ -128,6 +143,7 @@ export class TS3Client extends EventEmitter {
async connect(): Promise<void> {
this.voiceEndpointResolver.reset();
this.clearVisibleClientUids();
// Clean up any existing connection before creating a new one
if (this.client) {
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
// guard so a future protocol change cannot make a bot duck itself.
if (voice.clientId === this.clientId) return;
const clientUid = this.visibleClientUids.get(voice.clientId);
const activity: TS3VoiceActivity = {
clientId: voice.clientId,
codec: voice.codec,
...(clientUid ? { clientUid } : {}),
};
this.emit("voiceActivity", activity);
});
@@ -255,10 +273,12 @@ export class TS3Client extends EventEmitter {
this.client.on("disconnected", (err) => {
this.logger.warn({ err: err?.message }, "Connection closed");
this.clientId = 0;
this.clearVisibleClientUids();
this.emit("disconnected");
});
this.client.on("clientEnter", (info: ClientInfo) => {
this.rememberVisibleClientUid(info.id, info.uid);
this.logger.debug(
{ nickname: info.nickname, id: info.id },
"Client entered"
@@ -267,6 +287,7 @@ export class TS3Client extends EventEmitter {
});
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
this.releaseVisibleClientUid(ev.id);
this.logger.debug({ id: ev.id }, "Client left");
this.emit("clientLeave", ev);
});
@@ -461,6 +482,47 @@ export class TS3Client extends EventEmitter {
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 {
if (this.client && !this.disconnecting) {
this.disconnecting = true;
@@ -473,6 +535,7 @@ export class TS3Client extends EventEmitter {
});
}
this.clientId = 0;
this.clearVisibleClientUids();
this.httpQuery = null;
this.detectedProtocol = "unknown";
if (this.udpErrorTimer) {