mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 10:25:20 -06:00
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:
committed by
GitHub
parent
9b43f1c82e
commit
554dfbe0a2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2331,
|
||||
"channel": 3580,
|
||||
"channel": 3582,
|
||||
"plugin": 4002
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" }] },
|
||||
|
||||
@@ -119,6 +119,7 @@ const DiscordVoiceAutoJoinSchema = z
|
||||
.object({
|
||||
guildId: z.string().min(1),
|
||||
channelId: z.string().min(1),
|
||||
whenOccupied: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user