mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix: harden voice ducking bot detection
This commit is contained in:
1 parent
97e8a87305
commit
bc711758b7
7 files changed
+329
-31
No files matched your search
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-9
@@ -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: resolvedEndpoint.host,
|
host:
|
||||||
voicePort: resolvedEndpoint.port,
|
resolvedEndpoint?.host ?? this.configuredVoiceServerScope.host,
|
||||||
|
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
|
||||||
|
|||||||
@@ -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])(
|
||||||
|
|||||||
@@ -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);
|
clients.delete(clientId);
|
||||||
if (clients.size === 0) this.clientsByScope.delete(key);
|
if (clients.size === 0) this.clientsByScope.delete(key);
|
||||||
return true;
|
removed = 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 {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
// 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 });
|
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,
|
||||||
|
|||||||
Reference in new issue
Block a user