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
+329 -31

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();
}
+25 -9
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,
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])(
+48 -5
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);
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 {
@@ -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;
}
}
+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) {
+33 -8
View File
@@ -787,7 +787,7 @@
v-model="voiceDuckingEnabled"
type="checkbox"
class="profile-toggle-switch"
:disabled="voiceDuckingSaving"
:disabled="voiceDuckingControlsDisabled"
@change="saveVoiceDucking"
/>
</label>
@@ -808,7 +808,7 @@
max="100"
step="0.1"
class="voice-ducking-range"
:disabled="voiceDuckingSaving"
:disabled="voiceDuckingControlsDisabled"
aria-label="说话时保留原音量百分比"
/>
<div class="prefix-input-wrap">
@@ -820,17 +820,24 @@
step="0.1"
class="input input-sm"
style="max-width:80px"
:disabled="voiceDuckingSaving"
:disabled="voiceDuckingControlsDisabled"
aria-label="说话时保留原音量百分比"
@blur="normalizeVoiceDuckingVolume"
/>
<span class="voice-ducking-unit">%</span>
<button class="btn-primary" :disabled="voiceDuckingSaving" @click="saveVoiceDucking">
{{ voiceDuckingSaving ? '保存中…' : '保存比例' }}
<button class="btn-primary" :disabled="voiceDuckingControlsDisabled" @click="saveVoiceDucking">
{{ !voiceDuckingLoaded ? '加载设置…' : voiceDuckingSaving ? '保存中…' : '保存比例' }}
</button>
</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 }}
</p>
</div>
@@ -1655,10 +1662,15 @@ const autoPauseOnEmpty = ref(false);
// Voice ducking defaults OFF and retains 30% of the configured player volume.
const voiceDuckingEnabled = ref(false);
const voiceDuckingVolumePercent = ref(30);
const voiceDuckingLoaded = ref(false);
const voiceDuckingSaving = ref(false);
const voiceDuckingControlsDisabled = computed(
() => !voiceDuckingLoaded.value || voiceDuckingSaving.value,
);
const voiceDuckingMessage = ref('');
const voiceDuckingMessageTone = ref<'ok' | 'warn'>('ok');
let savedVoiceDucking = { enabled: false, volumePercent: 30 };
let voiceDuckingRequestRevision = 0;
const localAudioEnabled = ref(true);
// Saved-queues + play-keeps-queue toggles (#119), both default OFF.
const savedQueuesEnabled = ref(false);
@@ -1688,11 +1700,18 @@ function applyVoiceDuckingConfig(config: unknown) {
}
async function loadIdleTimeout() {
const voiceDuckingLoadRevision = voiceDuckingRequestRevision;
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
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 });
voiceDuckingLoaded.value = true;
voiceDuckingMessage.value = '';
}
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false;
playKeepsQueue.value = res.data.playKeepsQueue ?? false;
@@ -1708,7 +1727,12 @@ async function loadIdleTimeout() {
}
// null (unset) → "" so the select shows "自动(按优先级)".
defaultPlatformForm.value = res.data.defaultPlatform ?? '';
} catch { /* ignore */ }
} catch {
if (!voiceDuckingLoaded.value) {
voiceDuckingMessageTone.value = 'warn';
voiceDuckingMessage.value = '语音闪避设置加载失败,请刷新页面重试';
}
}
}
async function saveIdleTimeout() {
@@ -1724,8 +1748,9 @@ async function saveAutoPause() {
}
async function saveVoiceDucking() {
if (voiceDuckingSaving.value) return;
if (!voiceDuckingLoaded.value || voiceDuckingSaving.value) return;
voiceDuckingSaving.value = true;
voiceDuckingRequestRevision++;
voiceDuckingMessage.value = '';
const submitted = {
enabled: voiceDuckingEnabled.value,