feat(discord): auto-join occupied voice rooms (#125974)

* feat(discord): auto-join occupied voice rooms

Add opt-in voice.autoJoin[].whenOccupied residency so Discord voice bots join for the first human and leave when the room becomes empty while preserving existing always-on, manual, transcript, and follow-user behavior.\n\nCloses #125973

* test(discord): isolate process runtime mocks

Use stable hoisted runtime-env mocks so isolate=false Discord test ordering cannot turn sleepWithAbort back into an unmocked function.

* fix(discord): defer unknown voice occupancy

Treat memberless voice states as unresolved instead of human so bot-only rooms cannot trigger occupancy-managed auto-join. Add cache-to-listener and manager regressions.\n\nCloses #125973

* test: isolate shared module mocks

Replace ineffective non-isolated module spies with stable hoisted mocks and a child-process SQLite connection-reuse probe so gateway and Discord shards are order-independent.

* test(gateway): inline connection reuse probe

Keep the child-process SQLite ownership probe in its owning Vitest file so Knip sees the full test surface without weakening process isolation or the original order regression.
This commit is contained in:
Peter Steinberger
2026-08-19 00:08:54 -07:00
committed by GitHub
parent 9b43f1c82e
commit 554dfbe0a2
28 changed files with 719 additions and 302 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"core": 2331,
"channel": 3580,
"channel": 3582,
"plugin": 4002
}
+4 -4
View File
@@ -1,4 +1,4 @@
8b44e5b0cb5bb0e24c79b0a3ab65f61b0ff99898aed6bcff17d46f9630060908 config-baseline.json
6cace71978d23543d1f52e45c00da4a77924d2446885e8d736e13dc896cde46d config-baseline.core.json
1a18ca1f2c5970a1af9b3b6748fc62fdb2e83a4ec524b8de84f7ccd019159e9c config-baseline.channel.json
7a90113c970cceaab8f8867e0cd1f4ff43b83b47ca3be985da4580e33cbf402b config-baseline.plugin.json
f3a4a4b1d0c693e1ecfe2f3aa4a45ff523939d5492ae66b651fcdac2fe423a46 config-baseline.json
4d5d61165789f03d5a2a98a5a096a2be423f1ffed229b9120153ad1f6269f324 config-baseline.core.json
2b756ea29fdcb1cd3e7fc22b989ce27fe4213b9e0fe86c55dd80b3e88991a2c1 config-baseline.channel.json
2eb56b8a47ab34006bd56d95f7379ac859661bed2f9335ae40b681793afaded4 config-baseline.plugin.json
+4 -1
View File
@@ -1208,6 +1208,7 @@ Auto-join example:
{
guildId: "123456789012345678",
channelId: "234567890123456789",
whenOccupied: true,
},
],
allowedChannels: [
@@ -1251,6 +1252,8 @@ Notes:
- When OpenClaw joins a voice channel, the routed agent session receives a silent system event with the current participant roster. Later participant joins and leaves update that session without triggering an unsolicited spoken reply; Discord display names are treated as untrusted labels. Authorized voice turns also receive a fresh roster snapshot.
- Voice transcript turns and `/vc` commands use Discord entries in `commands.ownerAllowFrom` for owner status. When no Discord command owner is configured, the selected Discord account's `allowFrom` (or legacy `dm.allowFrom`) can still authorize voice access without granting owner status. Agent tool visibility follows the configured tool policy for the routed session.
- If `voice.autoJoin` has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild.
- `voice.autoJoin[].whenOccupied` defaults to `false`. Set it to `true` for an auto-managed room that should contain the bot only while at least one human is present. OpenClaw joins on the first human arrival and leaves after the last human departs; the OpenClaw bot and other bots do not count. Startup, fresh gateway sessions, and resumed gateway sessions reconcile from Discord's voice-state roster.
- Occupancy management owns only sessions that it joined. A manual `/vc join`, transcript capture, follow-user session, active session in another channel, or other ad-hoc join is not moved or disconnected when the configured room empties.
- `voice.allowedChannels` is an optional residency allowlist. Leave it unset to allow `/vc join` into any authorized Discord voice channel. When set, `/vc join`, startup auto-join, and bot voice-state moves are restricted to the listed `{ guildId, channelId }` entries. Set it to an empty array to deny all Discord voice joins. If Discord moves the bot outside the allowlist, OpenClaw leaves that channel and rejoins the configured auto-join target when one is available.
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options; the upstream defaults are `daveEncryption=true` and `decryptionFailureTolerance=24`.
- OpenClaw uses the bundled `libopus-wasm` codec for Discord voice receive and realtime raw PCM playback. It ships a pinned libopus WebAssembly build and does not require native opus addons.
@@ -1305,7 +1308,7 @@ Behavior:
Choose between the join modes:
- Use `followUsers` for personal or operator setups where the bot should automatically be in voice when you are.
- Use `autoJoin` for fixed-room bots that should be present even when no tracked user is in voice.
- Use `autoJoin` for fixed rooms. Add `whenOccupied: true` when the bot should be present only while humans are in that room; omit it for always-on voice presence.
- Use `/vc join` for one-off joins or rooms where automatic voice presence would be surprising.
Discord voice codec:
+2
View File
@@ -298,6 +298,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
{
guildId: "123456789012345678",
channelId: "234567890123456789",
whenOccupied: true,
},
],
daveEncryption: true,
@@ -342,6 +343,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
- `channels.discord.agentComponents.ttlMs` controls how long sent Discord component callbacks remain registered. Default `1800000` (30 minutes), maximum `86400000` (24 hours). Per-account overrides live under `channels.discord.accounts.<accountId>.agentComponents.ttlMs`. Prefer the shortest TTL that fits the workflow.
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. Text-only Discord configs leave voice off by default; set `channels.discord.voice.enabled=true` to opt in.
- `channels.discord.voice.autoJoin[].whenOccupied` keeps an auto-managed voice channel disconnected until a human is present, then leaves when the last human departs. It defaults to `false`; bots do not count as occupants, and manual or ad-hoc voice sessions are not managed by this policy.
- `channels.discord.voice.model` optionally overrides the LLM model used for Discord voice channel responses.
- `channels.discord.voice.daveEncryption` (default `true`) and `channels.discord.voice.decryptionFailureTolerance` (default `24`) pass through to `@discordjs/voice` DAVE options.
- `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (default `30000`).
@@ -342,6 +342,16 @@ describe("discord config schema", () => {
expect(cfg.voice?.allowedChannels).toEqual([{ guildId: "123", channelId: "456" }]);
});
it("accepts occupancy-managed Discord voice auto-join channels", () => {
const cfg = expectValidDiscordConfig({
voice: {
autoJoin: [{ guildId: "123", channelId: "456", whenOccupied: true }],
},
});
expect(cfg.voice?.autoJoin).toEqual([{ guildId: "123", channelId: "456", whenOccupied: true }]);
});
it("rejects invalid Discord voice allowed channels", () => {
for (const voice of [
{ allowedChannels: [{ guildId: "", channelId: "456" }] },
+1
View File
@@ -119,6 +119,7 @@ const DiscordVoiceAutoJoinSchema = z
.object({
guildId: z.string().min(1),
channelId: z.string().min(1),
whenOccupied: z.boolean().optional(),
})
.strict();
+5 -1
View File
@@ -172,7 +172,11 @@ export const discordChannelConfigUiHints = {
},
"voice.autoJoin": {
label: "Discord Voice Auto-Join",
help: "Voice channels to auto-join on startup (list of guildId/channelId entries).",
help: "Voice channels to auto-join (list of guildId/channelId entries). Set whenOccupied on an entry to connect only while humans are present.",
},
"voice.autoJoin.*.whenOccupied": {
label: "Discord Voice Auto-Join When Occupied",
help: "Join and remain in this auto-managed voice channel only while at least one human is present. The OpenClaw bot and other bots do not count. Default: false.",
},
"voice.allowedChannels": {
label: "Discord Voice Allowed Channels",
@@ -22,10 +22,10 @@ export class DiscordGatewayVoiceStateCache {
this.transitionsByState = new WeakMap();
}
listVoiceChannelStates(guildId: string, channelId: string): APIVoiceState[] {
listVoiceChannelStates(guildId: string, channelId: string): APIVoiceState[] | null {
const states = this.statesByGuild.get(guildId);
if (!states) {
return [];
return null;
}
const result: APIVoiceState[] = [];
for (const state of states.values()) {
@@ -11,6 +11,7 @@ import {
type GatewaySendPayload,
} from "discord-api-types/v10";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DiscordVoiceStateUpdateListener } from "../voice/listeners.js";
import { sharedGatewayIdentifyLimiter } from "./gateway-identify-limiter.js";
import { GatewayPlugin } from "./gateway.js";
@@ -375,7 +376,10 @@ describe("GatewayPlugin", () => {
},
});
const initialStates = gateway.listVoiceChannelStates("g1", "c1");
const initialStates = expectDefined(
gateway.listVoiceChannelStates("g1", "c1"),
"initial guild voice snapshot",
);
expect(initialStates.map((state) => state.user_id)).toEqual(["u1", "u2"]);
expect(initialStates.map((state) => state.member?.user.username)).toEqual(["owner", "friend"]);
@@ -420,13 +424,46 @@ describe("GatewayPlugin", () => {
});
expect(gateway.listVoiceChannelStates("g1", "c1")).toEqual([]);
expect(gateway.listVoiceChannelStates("g1", "c2").map((state) => state.user_id)).toEqual([
"u1",
"u3",
]);
expect(
expectDefined(gateway.listVoiceChannelStates("g1", "c2"), "updated guild voice snapshot").map(
(state) => state.user_id,
),
).toEqual(["u1", "u3"]);
await handleDispatch({ t: GatewayDispatchEvents.GuildDelete, d: { id: "g1" } });
expect(gateway.listVoiceChannelStates("g1", "c2")).toEqual([]);
expect(gateway.listVoiceChannelStates("g1", "c2")).toBeNull();
});
it("keeps newly memberless voice updates unknown through the voice listener", async () => {
const gateway = new GatewayPlugin({ autoInteractions: false });
const client = {
dispatchGatewayEvent: vi.fn(async () => {}),
getPlugin: vi.fn((id: string) => (id === "gateway" ? gateway : undefined)),
};
(gateway as unknown as { client: unknown }).client = client;
const handleDispatch = (payload: { t: string; d: unknown }): Promise<void> =>
(
gateway as unknown as {
handleDispatch(payload: { t: string; d: unknown }): Promise<void>;
}
).handleDispatch(payload);
await handleDispatch({
t: GatewayDispatchEvents.GuildCreate,
d: { id: "g1", voice_states: [], members: [] },
});
const memberlessState = { guild_id: "g1", user_id: "unknown-user", channel_id: "c1" };
await handleDispatch({ t: GatewayDispatchEvents.VoiceStateUpdate, d: memberlessState });
const handleVoiceStateUpdate = vi.fn(async () => {});
const listener = new DiscordVoiceStateUpdateListener({
handleVoiceStateUpdate,
} as unknown as ConstructorParameters<typeof DiscordVoiceStateUpdateListener>[0]);
await listener.handle(memberlessState as never, client as never);
expect(handleVoiceStateUpdate).toHaveBeenCalledWith(memberlessState, null);
expect(gateway.listVoiceChannelStates("g1", "c1")).toEqual([
expect.not.objectContaining({ member: expect.anything() }),
]);
});
it("clears cached voice states when a fresh gateway session becomes ready", async () => {
@@ -451,7 +488,7 @@ describe("GatewayPlugin", () => {
d: { session_id: "session-2", resume_gateway_url: "wss://gateway.discord.gg" },
});
expect(gateway.listVoiceChannelStates("g1", "c1")).toEqual([]);
expect(gateway.listVoiceChannelStates("g1", "c1")).toBeNull();
});
it("marks successful gateway resumes connected", async () => {
+1 -1
View File
@@ -118,7 +118,7 @@ export class GatewayPlugin extends Plugin {
return null;
}
listVoiceChannelStates(guildId: string, channelId: string): APIVoiceState[] {
listVoiceChannelStates(guildId: string, channelId: string): APIVoiceState[] | null {
return this.voiceStateCache.listVoiceChannelStates(guildId, channelId);
}
@@ -1,12 +1,20 @@
// Discord tests cover message handler.process plugin behavior.
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { setReplyPayloadMetadata } from "openclaw/plugin-sdk/reply-payload-testing";
import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { afterEach, beforeAll, beforeEach, vi } from "vitest";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { resetThreadBindingsForTests } from "./thread-bindings.test-support.js";
vi.mock("openclaw/plugin-sdk/runtime-env", { spy: true });
const runtimeEnvMocks = vi.hoisted(() => ({
logVerbose: vi.fn(),
sleepWithAbort: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>()),
logVerbose: runtimeEnvMocks.logVerbose,
sleepWithAbort: runtimeEnvMocks.sleepWithAbort,
}));
const getGlobalHookRunner = vi.hoisted(() => vi.fn());
@@ -20,8 +28,8 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
export const getGlobalHookRunnerForTest = getGlobalHookRunner;
export const logVerboseForTest = logVerbose;
export const sleepWithAbortForTest = sleepWithAbort;
export const logVerboseForTest = runtimeEnvMocks.logVerbose;
export const sleepWithAbortForTest = runtimeEnvMocks.sleepWithAbort;
const sendMocks = vi.hoisted(() => ({
reactMessageDiscord: vi.fn<
@@ -594,6 +602,8 @@ export function registerDiscordProcessTestLifecycle() {
beforeEach(() => {
vi.useRealTimers();
runtimeEnvMocks.logVerbose.mockReset();
runtimeEnvMocks.sleepWithAbort.mockReset().mockResolvedValue(undefined);
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
typingMocks.sendTyping.mockClear();
@@ -1,11 +1,13 @@
// Discord tests cover thread title.generate plugin behavior.
import { generateConversationLabel } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", { spy: true });
const generateConversationLabelMock = vi.hoisted(() => vi.fn());
const generateConversationLabelMock = vi.fn<typeof generateConversationLabel>();
vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/reply-dispatch-runtime")>()),
generateConversationLabel: generateConversationLabelMock,
}));
let generateThreadTitle: typeof import("./thread-title.js").generateThreadTitle;
function hasLoneSurrogate(value: string): boolean {
@@ -29,12 +31,8 @@ beforeAll(async () => {
});
beforeEach(() => {
vi.restoreAllMocks();
generateConversationLabelMock.mockReset();
generateConversationLabelMock.mockResolvedValue("Generated title");
vi.mocked(generateConversationLabel).mockImplementation((...args) =>
generateConversationLabelMock(...args),
);
});
describe("generateThreadTitle", () => {
+11 -8
View File
@@ -17,6 +17,7 @@ const logger = createSubsystemLogger("discord/voice");
// Keep this leaf contract structural so manager.ts can re-export listeners without a cycle.
type DiscordVoiceListenerManager = {
autoJoin: () => Promise<unknown>;
reconcileAutoJoinGuild: (guildId: string) => Promise<unknown>;
refreshGuildRoster: (guildId: string) => void;
handleVoiceStateUpdate: (
state: APIVoiceState,
@@ -24,12 +25,10 @@ type DiscordVoiceListenerManager = {
) => Promise<void>;
};
function startAutoJoin(manager: Pick<DiscordVoiceListenerManager, "autoJoin">) {
void manager
.autoJoin()
.catch((err: unknown) =>
logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`),
);
function startAutoJoin(operation: () => Promise<unknown>, context = "") {
void operation().catch((err: unknown) =>
logger.warn(`discord voice: autoJoin${context} failed: ${formatErrorMessage(err)}`),
);
}
export class DiscordVoiceReadyListener extends ReadyListener {
@@ -38,7 +37,7 @@ export class DiscordVoiceReadyListener extends ReadyListener {
}
async handle(_data: unknown, _client: Client): Promise<void> {
startAutoJoin(this.manager);
startAutoJoin(() => this.manager.autoJoin());
}
}
@@ -48,7 +47,7 @@ export class DiscordVoiceResumedListener extends ResumedListener {
}
async handle(_data: unknown, _client: Client): Promise<void> {
startAutoJoin(this.manager);
startAutoJoin(() => this.manager.autoJoin());
}
}
@@ -60,6 +59,10 @@ export class DiscordVoiceGuildCreateListener {
async handle(data: GatewayGuildCreateDispatchData, _client: Client): Promise<void> {
if (!data.unavailable) {
this.manager.refreshGuildRoster(data.id);
startAutoJoin(
() => this.manager.reconcileAutoJoinGuild(data.id),
` occupancy reconciliation guild=${data.id}`,
);
}
}
}
@@ -43,8 +43,10 @@ describe("DiscordVoiceReadyListener", () => {
it("refreshes active voice rosters after an available guild snapshot", async () => {
const refreshGuildRoster = vi.fn();
const reconcileAutoJoinGuild = vi.fn(async () => {});
const listener = new DiscordVoiceGuildCreateListener({
refreshGuildRoster,
reconcileAutoJoinGuild,
} as unknown as ConstructorParameters<typeof DiscordVoiceGuildCreateListener>[0]);
await expect(
@@ -53,12 +55,15 @@ describe("DiscordVoiceReadyListener", () => {
expect(listener.type).toBe(GatewayDispatchEvents.GuildCreate);
expect(refreshGuildRoster).toHaveBeenCalledWith("g1");
expect(reconcileAutoJoinGuild).toHaveBeenCalledWith("g1");
});
it("ignores unavailable guild snapshots", async () => {
const refreshGuildRoster = vi.fn();
const reconcileAutoJoinGuild = vi.fn(async () => {});
const listener = new DiscordVoiceGuildCreateListener({
refreshGuildRoster,
reconcileAutoJoinGuild,
} as unknown as ConstructorParameters<typeof DiscordVoiceGuildCreateListener>[0]);
await expect(
@@ -66,6 +71,7 @@ describe("DiscordVoiceReadyListener", () => {
).resolves.toBeUndefined();
expect(refreshGuildRoster).not.toHaveBeenCalled();
expect(reconcileAutoJoinGuild).not.toHaveBeenCalled();
});
it("forwards bot voice state updates to the voice manager", async () => {
@@ -42,12 +42,15 @@ describe("countDiscordVoiceHumanParticipants", () => {
},
{
user_id: "owner",
member: { user: { id: "owner", bot: false } },
member: { user: { id: "owner" } },
},
{
user_id: "helper-bot",
member: { user: { id: "helper-bot", bot: true } },
},
{
user_id: "unknown-member",
},
] as never,
botUserId: "agent",
}),
@@ -142,7 +142,7 @@ export function countDiscordVoiceHumanParticipants(params: {
continue;
}
knownUserIds.add(userId);
if (state.member?.user?.bot !== true) {
if (state.member?.user && state.member.user.bot !== true) {
count += 1;
}
}
+2
View File
@@ -30,6 +30,7 @@ export type VoiceOperationResult = {
export type VoiceJoinOptions = {
preserveFollowState?: boolean;
autoJoinWhenOccupied?: boolean;
transcripts?: VoiceSessionEntry["transcripts"];
};
@@ -91,6 +92,7 @@ type VoiceRealtimeLifecycle =
export type VoiceSessionEntry = {
generation: number;
autoJoinWhenOccupied: boolean;
sessionLifecycle: { status: "active" } | { status: "stopped"; reason: string };
guildId: string;
guildName?: string;
@@ -0,0 +1,328 @@
import { DiscordError } from "../internal/discord.js";
import type { MockCallSource } from "./manager.e2e.test-support.js";
import { defineDiscordVoiceTests } from "./voice-test-harness.test-support.js";
defineDiscordVoiceTests(
({
expect,
it,
vi,
ChannelType,
requireRecord,
mockCall,
joinVoiceChannelMock,
entersStateMock,
resolveAgentRouteMock,
resolveConfiguredRealtimeVoiceProviderMock,
createRealtimeVoiceBridgeSessionMock,
realtimeSessionMock,
managerModule,
configureVoiceStateGateway,
createClient,
createManager,
makeVoiceConfig,
expectConnectedStatus,
updateVoiceState,
}) => {
it("autoJoin uses the last configured channel for duplicate guild entries", async () => {
const manager = createManager({
voice: {
enabled: true,
autoJoin: [
{ guildId: "g1", channelId: "1001" },
{ guildId: "g1", channelId: "1002" },
],
},
});
await manager.autoJoin();
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
const joinOptions = requireRecord(
mockCall(joinVoiceChannelMock as unknown as MockCallSource, 0, "join voice call")[0],
"join voice options",
);
expect(joinOptions.guildId).toBe("g1");
expect(joinOptions.channelId).toBe("1002");
expectConnectedStatus(manager, "1002");
});
it("auto-joins an occupied room after the startup guild snapshot arrives", async () => {
const client = createClient();
let voiceStates: Array<Record<string, unknown>> = [];
configureVoiceStateGateway(client, () => voiceStates);
const manager = createManager(
makeVoiceConfig({
autoJoin: [{ guildId: "g1", channelId: "1001", whenOccupied: true }],
}),
client,
{},
"default",
"bot-user",
);
await manager.autoJoin();
expect(manager.status()).toEqual([]);
voiceStates = [
{
guild_id: "g1",
user_id: "u-owner",
channel_id: "1001",
member: { user: { id: "u-owner", bot: false } },
},
];
const { DiscordVoiceGuildCreateListener } = managerModule;
const listener = new DiscordVoiceGuildCreateListener(manager);
await listener.handle({ id: "g1", unavailable: false } as never, client as never);
await vi.waitFor(() => expectConnectedStatus(manager, "1001"));
});
it("does not join for a participant whose human identity is unresolved", async () => {
const client = createClient();
configureVoiceStateGateway(client, () => [
{
guild_id: "g1",
user_id: "unknown-member",
channel_id: "1001",
},
]);
const manager = createManager(
makeVoiceConfig({
autoJoin: [{ guildId: "g1", channelId: "1001", whenOccupied: true }],
}),
client,
{},
"default",
"bot-user",
);
await manager.autoJoin();
expect(manager.status()).toEqual([]);
expect(joinVoiceChannelMock).not.toHaveBeenCalled();
});
it("joins on the first human arrival and leaves when only bots remain", async () => {
const client = createClient();
const botState = {
guild_id: "g1",
user_id: "helper-bot",
channel_id: "1001",
member: { user: { id: "helper-bot", bot: true } },
};
const humanState = {
guild_id: "g1",
user_id: "u-owner",
channel_id: "1001",
member: { user: { id: "u-owner", bot: false } },
};
let voiceStates: Array<Record<string, unknown>> = [botState];
configureVoiceStateGateway(client, () => voiceStates);
const manager = createManager(
makeVoiceConfig({
autoJoin: [{ guildId: "g1", channelId: "1001", whenOccupied: true }],
}),
client,
{},
"default",
"bot-user",
);
await manager.autoJoin();
expect(manager.status()).toEqual([]);
voiceStates = [botState, humanState];
await updateVoiceState(manager, "u-owner", "1001", humanState.member);
expectConnectedStatus(manager, "1001");
voiceStates = [botState];
await updateVoiceState(manager, "u-owner", null, humanState.member);
expect(manager.status()).toEqual([]);
});
it.each(["1001", "1002"])(
"does not claim or disconnect a manual session in channel %s",
async (manualChannelId) => {
const client = createClient();
const humanState = {
guild_id: "g1",
user_id: "u-owner",
channel_id: "1001",
member: { user: { id: "u-owner", bot: false } },
};
let voiceStates: Array<Record<string, unknown>> = [humanState];
configureVoiceStateGateway(client, () => voiceStates);
const manager = createManager(
makeVoiceConfig({
autoJoin: [{ guildId: "g1", channelId: "1001", whenOccupied: true }],
}),
client,
{},
"default",
"bot-user",
);
await manager.join({ guildId: "g1", channelId: manualChannelId });
await manager.autoJoin();
voiceStates = [];
await updateVoiceState(manager, "u-owner", null, humanState.member);
expectConnectedStatus(manager, manualChannelId);
},
);
it("applies the latest occupancy after a pending auto-join finishes", async () => {
const client = createClient();
const humanState = {
guild_id: "g1",
user_id: "u-owner",
channel_id: "1001",
member: { user: { id: "u-owner", bot: false } },
};
let voiceStates: Array<Record<string, unknown>> = [humanState];
configureVoiceStateGateway(client, () => voiceStates);
let resolveReady!: () => void;
entersStateMock.mockImplementationOnce(
async () =>
await new Promise<undefined>((resolve) => {
resolveReady = () => resolve(undefined);
}),
);
const manager = createManager(
makeVoiceConfig({
autoJoin: [{ guildId: "g1", channelId: "1001", whenOccupied: true }],
}),
client,
{},
"default",
"bot-user",
);
const joining = manager.autoJoin();
await vi.waitFor(() => expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1));
voiceStates = [];
const leaving = updateVoiceState(manager, "u-owner", null, humanState.member);
resolveReady();
await Promise.all([joining, leaving]);
expect(manager.status()).toEqual([]);
});
it("preserves the routed agent through realtime autoJoin startup", async () => {
resolveAgentRouteMock.mockReturnValue({
agentId: "molty",
sessionKey: "agent:molty:discord:channel:g1:1001",
});
resolveConfiguredRealtimeVoiceProviderMock.mockImplementation((params?: unknown) => {
if (requireRecord(params, "provider resolution params").agentId !== "molty") {
throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty");
}
return {
provider: { id: "openai", capabilities: { supportsActivationNameGating: true } },
providerConfig: { model: "gpt-realtime-2", voice: "cedar" },
};
});
createRealtimeVoiceBridgeSessionMock.mockImplementation((params?: unknown) => {
if (requireRecord(params, "bridge session params").agentId !== "molty") {
throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty");
}
return realtimeSessionMock;
});
const manager = createManager(
makeVoiceConfig({
mode: "agent-proxy",
autoJoin: [{ guildId: "g1", channelId: "1001" }],
realtime: { provider: "openai" },
}),
undefined,
{ agents: { list: [{ id: "helper" }, { id: "molty" }] } },
);
await manager.autoJoin();
expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledTimes(1);
expect(createRealtimeVoiceBridgeSessionMock).toHaveBeenCalledTimes(1);
expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1);
expectConnectedStatus(manager, "1001");
});
it("suppresses repeated autoJoin attempts after fatal realtime startup failures", async () => {
realtimeSessionMock.connect.mockRejectedValueOnce(new Error("Incorrect API key provided"));
const manager = createManager(
makeVoiceConfig({
mode: "agent-proxy",
autoJoin: [{ guildId: "g1", channelId: "1001" }],
}),
);
await manager.autoJoin();
await manager.autoJoin();
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1);
expect(manager.status()).toStrictEqual([]);
});
it("rejects joins outside configured allowed voice channels", async () => {
const manager = createManager(
makeVoiceConfig({ allowedChannels: [{ guildId: "g1", channelId: "1001" }] }),
);
const result = await manager.join({ guildId: "g1", channelId: "1002" });
expect(result.ok).toBe(false);
expect(result.message).toBe(
"<#1002> is not allowed by channels.discord.voice.allowedChannels.",
);
expect(joinVoiceChannelMock).not.toHaveBeenCalled();
});
it("allows joins inside configured allowed voice channels", async () => {
const manager = createManager(
makeVoiceConfig({ allowedChannels: [{ guildId: "g1", channelId: "1001" }] }),
);
const result = await manager.join({ guildId: "g1", channelId: "1001" });
expect(result.ok).toBe(true);
expectConnectedStatus(manager, "1001");
});
it("continues autoJoin after a channel resolution failure", async () => {
const missingAccessError = new DiscordError(new Response(null, { status: 403 }), {
message: "Missing Access",
code: 50001,
});
const client = createClient();
client.fetchChannel.mockRejectedValueOnce(missingAccessError).mockResolvedValueOnce({
id: "2001",
guildId: "g2",
guild: { id: "g2", name: "Guild 2" },
type: ChannelType.GuildVoice,
});
const manager = createManager(
makeVoiceConfig({
autoJoin: [
{ guildId: "g1", channelId: "1001" },
{ guildId: "g2", channelId: "2001" },
],
}),
client,
);
await expect(manager.autoJoin()).resolves.toBeUndefined();
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
expect(manager.status()).toEqual([
{
ok: true,
message: "connected: guild g2 channel 2001",
guildId: "g2",
channelId: "2001",
},
]);
});
},
);
@@ -751,6 +751,25 @@ defineDiscordVoiceTests(
expectConnectedStatus(manager, "1001");
});
it("does not rejoin an empty occupancy-managed target after the bot is moved", async () => {
const manager = createManager(
makeVoiceConfig({
autoJoin: [{ guildId: "g1", channelId: "1001", whenOccupied: true }],
allowedChannels: [{ guildId: "g1", channelId: "1001" }],
}),
undefined,
{},
"default",
"bot-user",
);
await manager.join({ guildId: "g1", channelId: "1001" });
await updateVoiceState(manager, "bot-user", "1002");
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
expect(manager.status()).toEqual([]);
});
it("skips destroying stale tracked voice connections that are already destroyed", async () => {
const staleConnection = createConnectionMock();
staleConnection.state.status = "destroyed";
@@ -20,6 +20,7 @@ const FOLLOW_USERS_RECONCILE_MAX_REST_LOOKUPS_PER_RUN = 32;
export type VoiceChannelResidency = {
guildId: string;
channelId: string;
whenOccupied?: boolean;
};
type FollowUserReconcileGuildPlan = {
@@ -35,14 +36,18 @@ type FollowUserReconcileUserSelection = {
};
export function normalizeVoiceChannelResidencies(
entries: Array<{ guildId?: string; channelId?: string }> | undefined,
entries: Array<{ guildId?: string; channelId?: string; whenOccupied?: boolean }> | undefined,
): VoiceChannelResidency[] {
const normalized: VoiceChannelResidency[] = [];
for (const entry of entries ?? []) {
const guildId = entry.guildId?.trim();
const channelId = entry.channelId?.trim();
if (guildId && channelId) {
normalized.push({ guildId, channelId });
normalized.push({
guildId,
channelId,
...(entry.whenOccupied === true ? { whenOccupied: true } : {}),
});
}
}
return normalized;
@@ -648,6 +653,9 @@ export class DiscordVoiceFollowing {
const autoJoinTarget = this.params.autoJoinChannels
.toReversed()
.find((entry) => entry.guildId === guildId);
if (autoJoinTarget?.whenOccupied) {
return null;
}
if (autoJoinTarget && this.params.isAllowedVoiceChannel(autoJoinTarget)) {
return autoJoinTarget;
}
@@ -22,6 +22,7 @@ defineDiscordVoiceTests(
createClient,
createManager,
makeVoiceConfig,
configureVoiceStateGateway,
createAgentProxyManager,
createFollowManager,
expectConnectedStatus,
@@ -627,6 +628,42 @@ defineDiscordVoiceTests(
expect(manager.status()).toEqual([]);
});
it("preserves occupied auto-join ownership through DAVE receive recovery", async () => {
const firstConnection = createConnectionMock();
joinVoiceChannelMock
.mockReturnValueOnce(firstConnection)
.mockReturnValueOnce(createConnectionMock());
const client = createClient();
const humanState = {
guild_id: "g1",
user_id: "u-owner",
channel_id: "1001",
member: { user: { id: "u-owner", bot: false } },
};
let voiceStates: Array<Record<string, unknown>> = [humanState];
configureVoiceStateGateway(client, () => voiceStates);
const manager = createManager(
makeVoiceConfig({
autoJoin: [{ guildId: "g1", channelId: "1001", whenOccupied: true }],
}),
client,
{},
"default",
"bot-user",
);
await manager.autoJoin();
emitDecryptFailure(manager);
emitDecryptFailure(manager);
emitDecryptFailure(manager);
await vi.waitFor(() => expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2));
voiceStates = [];
await updateVoiceState(manager, "u-owner", null, humanState.member);
expect(manager.status()).toEqual([]);
});
it("resets DAVE receive recovery after realtime audio decodes", async () => {
const connection = createConnectionMock();
joinVoiceChannelMock.mockReturnValueOnce(connection);
@@ -59,7 +59,7 @@ export class DiscordVoiceReceive {
isFollowOwnedGuild: (guildId: string) => boolean;
join: (
params: { guildId: string; channelId: string },
options?: { preserveFollowState?: boolean },
options?: { preserveFollowState?: boolean; autoJoinWhenOccupied?: boolean },
) => Promise<VoiceOperationResult>;
leave: (
params: { guildId: string },
@@ -539,7 +539,7 @@ export class DiscordVoiceReceive {
}
const result = await this.params.join(
{ guildId: entry.guildId, channelId: entry.channelId },
{ preserveFollowState },
{ preserveFollowState, autoJoinWhenOccupied: entry.autoJoinWhenOccupied },
);
if (!result.ok) {
logger.warn(`discord voice: rejoin after decrypt failures failed: ${result.message}`);
+130 -57
View File
@@ -7,6 +7,10 @@ import { resolveFetchedDiscordThreadLikeChannelContext } from "../monitor/thread
import { resolveDiscordVoiceEnabled } from "./config.js";
import { DiscordVoiceMembershipTracker } from "./membership.js";
import { resolveDiscordVoiceAccess } from "./owner-access.js";
import {
countDiscordVoiceHumanParticipants,
listDiscordVoiceParticipantStates,
} from "./participant-context.js";
import {
logVoiceVerbose,
type VoiceJoinOptions,
@@ -71,7 +75,7 @@ export class DiscordVoiceManager {
private readonly botUserId?: string;
private readonly client: Client;
private readonly voiceEnabled: boolean;
private autoJoinTask: Promise<void> | null = null;
private readonly autoJoinTasks = new Map<string, Promise<void>>();
private readonly fatalAutoJoinFailures = new Map<
string,
{ message: string; skipLogged: boolean }
@@ -188,66 +192,39 @@ export class DiscordVoiceManager {
if (!this.voiceEnabled || this.destroyed) {
return;
}
if (this.autoJoinTask) {
return this.autoJoinTask;
const entriesByGuild = new Map<string, VoiceChannelResidency>();
const duplicateGuilds = new Set<string>();
for (const entry of this.autoJoinChannels) {
if (entriesByGuild.has(entry.guildId)) {
duplicateGuilds.add(entry.guildId);
}
entriesByGuild.set(entry.guildId, entry);
}
this.autoJoinTask = (async () => {
const entries = this.autoJoinChannels;
const entriesByGuild = new Map<string, { guildId: string; channelId: string }>();
const duplicateGuilds = new Set<string>();
for (const entry of entries) {
const guildId = entry.guildId.trim();
const channelId = entry.channelId.trim();
if (!guildId || !channelId) {
continue;
}
if (entriesByGuild.has(guildId)) {
duplicateGuilds.add(guildId);
}
entriesByGuild.set(guildId, { guildId, channelId });
}
logVoiceVerbose(`autoJoin: ${entries.length} entries, ${entriesByGuild.size} guilds`);
for (const guildId of duplicateGuilds) {
const selected = entriesByGuild.get(guildId);
if (selected) {
logger.warn(
`discord voice: autoJoin has multiple entries for guild ${guildId}; using channel ${selected.channelId}`,
);
}
logVoiceVerbose(
`autoJoin: ${this.autoJoinChannels.length} entries, ${entriesByGuild.size} guilds`,
);
for (const guildId of duplicateGuilds) {
const selected = entriesByGuild.get(guildId);
if (selected) {
logger.warn(
`discord voice: autoJoin has multiple entries for guild ${guildId}; using channel ${selected.channelId}`,
);
}
}
for (const entry of entriesByGuild.values()) {
const failureKey = formatAutoJoinFailureKey(entry);
const fatalFailure = this.fatalAutoJoinFailures.get(failureKey);
if (fatalFailure) {
if (!fatalFailure.skipLogged) {
logger.warn(
`discord voice: autoJoin suppressed guild=${entry.guildId} channel=${entry.channelId} after fatal startup failure; retry with /vc join or reload config after fixing credentials: ${fatalFailure.message}`,
);
fatalFailure.skipLogged = true;
}
continue;
}
logVoiceVerbose(`autoJoin: joining guild ${entry.guildId} channel ${entry.channelId}`);
const result = await this.join(entry);
if (!result.ok) {
logger.warn(
`discord voice: autoJoin skipped guild=${entry.guildId} channel=${entry.channelId}: ${result.message}`,
);
if (isFatalAutoJoinFailure(result.message)) {
this.fatalAutoJoinFailures.set(failureKey, {
message: result.message,
skipLogged: false,
});
}
}
}
await this.following.startReconciliation();
})().finally(() => {
this.autoJoinTask = null;
});
return this.autoJoinTask;
for (const entry of entriesByGuild.values()) {
await this.enqueueAutoJoin(entry);
}
await this.following.startReconciliation();
}
async reconcileAutoJoinGuild(guildId: string): Promise<void> {
const entry = this.resolveAutoJoinTarget(guildId);
if (!entry?.whenOccupied || !this.voiceEnabled || this.destroyed) {
return;
}
await this.enqueueAutoJoin(entry);
}
status(): VoiceOperationResult[] {
@@ -446,12 +423,17 @@ export class DiscordVoiceManager {
}
if (this.botUserId && userId === this.botUserId) {
await this.following.handleBotVoiceStateUpdate({ guildId, channelId });
await this.reconcileAutoJoinGuild(guildId);
return;
}
this.membership.track(this.sessions.get(guildId), data, previousVoiceState);
if (this.following.isFollowedUser(userId)) {
await this.following.handleFollowedUserVoiceStateUpdate({ guildId, channelId, userId });
}
const autoJoinTarget = this.resolveAutoJoinTarget(guildId);
if (autoJoinTarget?.whenOccupied) {
await this.enqueueAutoJoin(autoJoinTarget);
}
}
async destroy(): Promise<void> {
@@ -480,6 +462,97 @@ export class DiscordVoiceManager {
entry.sessionLifecycle.status === "active"
);
}
private resolveAutoJoinTarget(guildId: string): VoiceChannelResidency | undefined {
return this.autoJoinChannels.toReversed().find((entry) => entry.guildId === guildId.trim());
}
private enqueueAutoJoin(entry: VoiceChannelResidency): Promise<void> {
const previous = this.autoJoinTasks.get(entry.guildId) ?? Promise.resolve();
const task = previous
.catch(() => undefined)
.then(async () => await this.reconcileAutoJoinEntry(entry))
.finally(() => {
if (this.autoJoinTasks.get(entry.guildId) === task) {
this.autoJoinTasks.delete(entry.guildId);
}
});
this.autoJoinTasks.set(entry.guildId, task);
return task;
}
private async reconcileAutoJoinEntry(entry: VoiceChannelResidency): Promise<void> {
if (this.destroyed) {
return;
}
const failureKey = formatAutoJoinFailureKey(entry);
const fatalFailure = this.fatalAutoJoinFailures.get(failureKey);
if (fatalFailure) {
if (!fatalFailure.skipLogged) {
logger.warn(
`discord voice: autoJoin suppressed guild=${entry.guildId} channel=${entry.channelId} after fatal startup failure; retry with /vc join or reload config after fixing credentials: ${fatalFailure.message}`,
);
fatalFailure.skipLogged = true;
}
return;
}
if (entry.whenOccupied) {
const states = listDiscordVoiceParticipantStates({
client: this.client,
guildId: entry.guildId,
channelId: entry.channelId,
});
if (states === null) {
logVoiceVerbose(
`autoJoin waiting for guild voice snapshot guild=${entry.guildId} channel=${entry.channelId}`,
);
return;
}
const humanCount = countDiscordVoiceHumanParticipants({
states,
botUserId: this.botUserId,
});
const existing = this.sessions.get(entry.guildId);
if (humanCount === 0) {
if (!existing?.autoJoinWhenOccupied || existing.channelId !== entry.channelId) {
return;
}
logger.info(
`discord voice: occupied autoJoin leaving empty channel guild=${entry.guildId} channel=${entry.channelId}`,
);
const result = await this.leave({ guildId: entry.guildId, channelId: entry.channelId });
if (!result.ok) {
logger.warn(
`discord voice: occupied autoJoin failed to leave guild=${entry.guildId} channel=${entry.channelId}: ${result.message}`,
);
}
return;
}
const lifecycle = this.guildLifecycles.get(entry.guildId);
if (existing || lifecycle?.status === "starting" || lifecycle?.status === "active") {
return;
}
logger.info(
`discord voice: occupied autoJoin joining guild=${entry.guildId} channel=${entry.channelId} humans=${humanCount}`,
);
} else {
logVoiceVerbose(`autoJoin: joining guild ${entry.guildId} channel ${entry.channelId}`);
}
const result = await this.join(entry, { autoJoinWhenOccupied: entry.whenOccupied === true });
if (!result.ok) {
logger.warn(
`discord voice: autoJoin skipped guild=${entry.guildId} channel=${entry.channelId}: ${result.message}`,
);
if (isFatalAutoJoinFailure(result.message)) {
this.fatalAutoJoinFailures.set(failureKey, {
message: result.message,
skipLogged: false,
});
}
}
}
}
export {
@@ -17,15 +17,12 @@ defineDiscordVoiceTests(
joinVoiceChannelMock,
entersStateMock,
createAudioPlayerMock,
resolveAgentRouteMock,
resolveRealtimeBootstrapContextInstructionsMock,
resolveConfiguredRealtimeVoiceProviderMock,
createRealtimeVoiceBridgeSessionMock,
realtimeSessionMock,
managerModule,
createClient,
createManager,
makeVoiceConfig,
createAgentProxyManager,
expectConnectedStatus,
getSessionEntry,
@@ -402,109 +399,6 @@ defineDiscordVoiceTests(
);
});
it("autoJoin uses the last configured channel for duplicate guild entries", async () => {
const manager = createManager({
voice: {
enabled: true,
autoJoin: [
{ guildId: "g1", channelId: "1001" },
{ guildId: "g1", channelId: "1002" },
],
},
});
await manager.autoJoin();
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
const joinOptions = requireRecord(
mockCall(joinVoiceChannelMock as unknown as MockCallSource, 0, "join voice call")[0],
"join voice options",
);
expect(joinOptions.guildId).toBe("g1");
expect(joinOptions.channelId).toBe("1002");
expectConnectedStatus(manager, "1002");
});
it("preserves the routed agent through realtime autoJoin startup", async () => {
resolveAgentRouteMock.mockReturnValue({
agentId: "molty",
sessionKey: "agent:molty:discord:channel:g1:1001",
});
resolveConfiguredRealtimeVoiceProviderMock.mockImplementation((params?: unknown) => {
if (requireRecord(params, "provider resolution params").agentId !== "molty") {
throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty");
}
return {
provider: { id: "openai", capabilities: { supportsActivationNameGating: true } },
providerConfig: { model: "gpt-realtime-2", voice: "cedar" },
};
});
createRealtimeVoiceBridgeSessionMock.mockImplementation((params?: unknown) => {
if (requireRecord(params, "bridge session params").agentId !== "molty") {
throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty");
}
return realtimeSessionMock;
});
const manager = createManager(
makeVoiceConfig({
mode: "agent-proxy",
autoJoin: [{ guildId: "g1", channelId: "1001" }],
realtime: { provider: "openai" },
}),
undefined,
{ agents: { list: [{ id: "helper" }, { id: "molty" }] } },
);
await manager.autoJoin();
expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledTimes(1);
expect(createRealtimeVoiceBridgeSessionMock).toHaveBeenCalledTimes(1);
expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1);
expectConnectedStatus(manager, "1001");
});
it("suppresses repeated autoJoin attempts after fatal realtime startup failures", async () => {
realtimeSessionMock.connect.mockRejectedValueOnce(new Error("Incorrect API key provided"));
const manager = createManager(
makeVoiceConfig({
mode: "agent-proxy",
autoJoin: [{ guildId: "g1", channelId: "1001" }],
}),
);
await manager.autoJoin();
await manager.autoJoin();
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1);
expect(manager.status()).toStrictEqual([]);
});
it("rejects joins outside configured allowed voice channels", async () => {
const manager = createManager(
makeVoiceConfig({ allowedChannels: [{ guildId: "g1", channelId: "1001" }] }),
);
const result = await manager.join({ guildId: "g1", channelId: "1002" });
expect(result.ok).toBe(false);
expect(result.message).toBe(
"<#1002> is not allowed by channels.discord.voice.allowedChannels.",
);
expect(joinVoiceChannelMock).not.toHaveBeenCalled();
});
it("allows joins inside configured allowed voice channels", async () => {
const manager = createManager(
makeVoiceConfig({ allowedChannels: [{ guildId: "g1", channelId: "1001" }] }),
);
const result = await manager.join({ guildId: "g1", channelId: "1001" });
expect(result.ok).toBe(true);
expectConnectedStatus(manager, "1001");
});
const missingAccessError = new DiscordError(new Response(null, { status: 403 }), {
message: "Missing Access",
code: 50001,
@@ -584,37 +478,6 @@ defineDiscordVoiceTests(
await expect(manager.join({ guildId: "g1", channelId: "1001" })).resolves.toEqual(expected);
});
it("continues autoJoin after a channel resolution failure", async () => {
const client = createClient();
client.fetchChannel.mockRejectedValueOnce(missingAccessError).mockResolvedValueOnce({
id: "2001",
guildId: "g2",
guild: { id: "g2", name: "Guild 2" },
type: ChannelType.GuildVoice,
});
const manager = createManager(
makeVoiceConfig({
autoJoin: [
{ guildId: "g1", channelId: "1001" },
{ guildId: "g2", channelId: "2001" },
],
}),
client,
);
await expect(manager.autoJoin()).resolves.toBeUndefined();
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
expect(manager.status()).toEqual([
{
ok: true,
message: "connected: guild g2 channel 2001",
guildId: "g2",
channelId: "2001",
},
]);
});
it("keeps cancellation authoritative when channel lookup later rejects", async () => {
let rejectChannelLookup!: (reason: unknown) => void;
const client = createClient();
@@ -167,6 +167,7 @@ export class DiscordVoiceSessions {
const existing = this.params.sessions.get(guildId);
if (existing && existing.channelId === channelId) {
existing.autoJoinWhenOccupied = options?.autoJoinWhenOccupied === true;
if (authority) {
existing.generation = authority.generation;
}
@@ -430,6 +431,7 @@ export class DiscordVoiceSessions {
const entry: VoiceSessionEntry = {
generation: authority?.generation ?? 0,
autoJoinWhenOccupied: options?.autoJoinWhenOccupied === true,
sessionLifecycle: { status: "active" },
guildId,
guildName:
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -130,6 +130,8 @@ export type DiscordVoiceAutoJoinConfig = {
guildId: string;
/** Voice channel ID to join. */
channelId: string;
/** Join and remain connected only while at least one human is in the channel. Default: false. */
whenOccupied?: boolean;
};
export type DiscordVoiceAllowedChannelConfig = {
@@ -196,7 +198,7 @@ export type DiscordVoiceConfig = {
model?: string;
/** Realtime provider settings for agent-proxy or bidi modes. */
realtime?: DiscordVoiceRealtimeConfig;
/** Voice channels to auto-join on startup. */
/** Voice channels to join automatically, optionally only while occupied. */
autoJoin?: DiscordVoiceAutoJoinConfig[];
/** If false, configured followUsers are ignored without removing the saved user list. */
followUsersEnabled?: boolean;
@@ -1,56 +1,62 @@
/**
* Read-only session reads reuse a handle this process already holds.
* Opening a connection per call made sessions.list cost scale with row count,
* because row projection and sharing resolution each read per row.
* sessions.list resolves row owners through the session SQLite target path.
* That owner read must reuse a process-held handle instead of opening per row.
*/
import { expect, test, vi } from "vitest";
import * as nodeSqlite from "../infra/node-sqlite.js";
import { writeSessionStore } from "./test-helpers.js";
import {
directSessionReq,
sessionStoreEntry,
setupGatewaySessionsHandlerTestHarness,
} from "./test/server-sessions.test-helpers.js";
import { execFile } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const { createSessionStoreDir } = setupGatewaySessionsHandlerTestHarness();
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(import.meta.dirname, "../..");
const LIST_PARAMS = {
agentId: "main",
configuredAgentsOnly: true,
includeDerivedTitles: true,
includeGlobal: true,
includeUnknown: true,
limit: 100,
};
async function countConnectionOpensForRows(rows: number): Promise<number> {
await createSessionStoreDir();
const entries: Record<string, ReturnType<typeof sessionStoreEntry>> = {
main: sessionStoreEntry("sess-main"),
};
for (let index = 0; index < rows; index++) {
entries[`agent:main:row-${index}`] = sessionStoreEntry(`sess-row-${index}`, {
updatedAt: 1_781_000_000_000 - index * 1_000,
});
function buildConnectionReuseProbe(): string {
const agentDbUrl = pathToFileURL(path.join(repoRoot, "src/state/openclaw-agent-db.ts")).href;
const stateDbUrl = pathToFileURL(path.join(repoRoot, "src/state/openclaw-state-db.ts")).href;
return `
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const agentDb = await import(${JSON.stringify(agentDbUrl)});
const stateDb = await import(${JSON.stringify(stateDbUrl)});
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-connection-reuse-"));
const env = { OPENCLAW_STATE_DIR: stateDir };
let databasePath;
let movedPath;
try {
const database = agentDb.openOpenClawAgentDatabase({ agentId: "main", env });
databasePath = database.path;
movedPath = databasePath + ".connection-reuse-probe";
// The live handle survives this rename; a fresh pathname open does not.
fs.renameSync(databasePath, movedPath);
const inspections = Array.from({ length: 40 }, () =>
agentDb.inspectOpenClawAgentDatabaseOwner(databasePath),
);
if (inspections.some((entry) => entry.status !== "owned" || entry.agentId !== "main")) {
throw new Error("unexpected ownership inspections: " + JSON.stringify(inspections));
}
await writeSessionStore({ entries });
// Warm lazily-initialized module state so only steady-state reads are counted.
await directSessionReq("sessions.list", LIST_PARAMS);
const spy = vi.spyOn(nodeSqlite, "openNodeSqliteDatabase");
try {
const result = await directSessionReq("sessions.list", LIST_PARAMS);
expect(result.ok).toBe(true);
return spy.mock.calls.length;
} finally {
spy.mockRestore();
process.stdout.write(JSON.stringify({ inspections: inspections.length }) + "\\n");
} finally {
if (databasePath && movedPath && fs.existsSync(movedPath)) {
fs.renameSync(movedPath, databasePath);
}
agentDb.closeOpenClawAgentDatabasesForTest();
stateDb.closeOpenClawStateDatabaseForTest();
fs.rmSync(stateDir, { recursive: true, force: true });
}
`;
}
test("sessions.list connection opens do not scale with row count", async () => {
const small = await countConnectionOpensForRows(5);
const large = await countConnectionOpensForRows(40);
test.runIf(process.platform !== "win32")(
"sessions.list owner reads reuse the process-held connection",
async () => {
const result = await execFileAsync(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", buildConnectionReuseProbe()],
{ cwd: repoRoot, maxBuffer: 1024 * 1024 },
);
// A connection per read would put `large` roughly 35 opens above `small`.
expect(large).toBeLessThanOrEqual(small + 2);
});
expect(JSON.parse(result.stdout) as unknown).toEqual({ inspections: 40 });
},
);