fix: recover Discord voice auto-join after resume

This commit is contained in:
Peter Steinberger
2026-05-01 12:28:29 +01:00
parent 7719dd8804
commit 4a4353e33f
8 changed files with 168 additions and 9 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582)
- Discord/voice: rerun configured voice auto-join after Discord gateway RESUMED events and ignore already-destroyed stale voice connections during reconnect cleanup, so health-monitor account restarts can rejoin configured channels. Fixes #40665. Thanks @liz709.
- Discord/voice: lengthen the default voice join Ready wait, add configurable `voice.connectTimeoutMs`/`voice.reconnectGraceMs`, and warn before destroying unrecovered disconnected sessions so slow Discord voice handshakes and reconnects no longer fail silently. Fixes #63098; refs #39825 and #65039. Thanks @darealgege, @kzicherman, and @ayochim.
- Gateway/health: refresh cached health RPC snapshots when channel runtime state diverges, so Discord and other channel status reads no longer report stale running or connected values until the cache TTL expires. (#75423) Thanks @clawsweeper.
- Discord/voice: merge configured media-understanding providers such as Deepgram into partial active provider registries, so follow-up voice turns keep transcribing after another media plugin is already active. Fixes #65687. Thanks @OneMintJulep.

View File

@@ -45,6 +45,10 @@ export abstract class ReadyListener extends BaseListener {
readonly type = GatewayDispatchEvents.Ready;
}
export abstract class ResumedListener extends BaseListener {
readonly type = GatewayDispatchEvents.Resumed;
}
export abstract class MessageCreateListener extends BaseListener {
readonly type = GatewayDispatchEvents.MessageCreate;
abstract override handle(data: DiscordMessageDispatchData, client: Client): Promise<void> | void;

View File

@@ -106,6 +106,7 @@ vi.mock("../voice/manager.runtime.js", () => {
return {
DiscordVoiceManager: function DiscordVoiceManager() {},
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
};
});
describe("monitorDiscordProvider", () => {
@@ -222,6 +223,7 @@ describe("monitorDiscordProvider", () => {
return {
DiscordVoiceManager: function DiscordVoiceManager() {},
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
} as never;
});
providerTesting.setLoadDiscordProviderSessionRuntime(

View File

@@ -526,7 +526,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
if (voiceEnabled) {
const { DiscordVoiceManager, DiscordVoiceReadyListener } = await loadDiscordVoiceRuntime();
const { DiscordVoiceManager, DiscordVoiceReadyListener, DiscordVoiceResumedListener } =
await loadDiscordVoiceRuntime();
voiceManager = new DiscordVoiceManager({
client,
cfg,
@@ -537,6 +538,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
});
voiceManagerRef.current = voiceManager;
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
registerDiscordListener(client.listeners, new DiscordVoiceResumedListener(voiceManager));
}
const messageHandler = discordProviderSessionRuntime.createDiscordMessageHandler({

View File

@@ -5,6 +5,7 @@ import { createVoiceReceiveRecoveryState } from "./receive-recovery.js";
const {
createConnectionMock,
getVoiceConnectionMock,
joinVoiceChannelMock,
entersStateMock,
createAudioPlayerMock,
@@ -83,8 +84,11 @@ const {
return connection;
};
const getVoiceConnectionMock = vi.fn((): MockConnection | undefined => undefined);
return {
createConnectionMock,
getVoiceConnectionMock,
joinVoiceChannelMock: vi.fn(() => createConnectionMock()),
entersStateMock: vi.fn(async (_target?: unknown, _state?: string, _timeoutMs?: number) => {
return undefined;
@@ -118,6 +122,7 @@ vi.mock("./sdk-runtime.js", () => ({
createAudioPlayer: createAudioPlayerMock,
createAudioResource: vi.fn(),
entersState: entersStateMock,
getVoiceConnection: getVoiceConnectionMock,
joinVoiceChannel: joinVoiceChannelMock,
}),
}));
@@ -189,6 +194,8 @@ describe("DiscordVoiceManager", () => {
});
beforeEach(() => {
getVoiceConnectionMock.mockReset();
getVoiceConnectionMock.mockReturnValue(undefined);
joinVoiceChannelMock.mockReset();
joinVoiceChannelMock.mockImplementation(() => createConnectionMock());
entersStateMock.mockReset();
@@ -313,6 +320,52 @@ describe("DiscordVoiceManager", () => {
expectConnectedStatus(manager, "1002");
});
it("destroys stale tracked voice connections before joining", async () => {
const staleConnection = createConnectionMock();
const connection = createConnectionMock();
getVoiceConnectionMock.mockReturnValueOnce(staleConnection);
joinVoiceChannelMock.mockReturnValueOnce(connection);
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "1001" });
expect(getVoiceConnectionMock).toHaveBeenCalledWith("g1");
expect(staleConnection.destroy).toHaveBeenCalledTimes(1);
expectConnectedStatus(manager, "1001");
});
it("does not throw when stale tracked voice connections are already destroyed", async () => {
const staleConnection = createConnectionMock();
staleConnection.state.status = "destroyed";
staleConnection.destroy.mockImplementation(() => {
throw new Error("Cannot destroy VoiceConnection - it has already been destroyed");
});
getVoiceConnectionMock.mockReturnValueOnce(staleConnection);
joinVoiceChannelMock.mockReturnValueOnce(createConnectionMock());
const manager = createManager();
await expect(manager.join({ guildId: "g1", channelId: "1001" })).resolves.toMatchObject({
ok: true,
});
expect(staleConnection.destroy).not.toHaveBeenCalled();
});
it("does not throw when leaving an already destroyed voice connection", async () => {
const connection = createConnectionMock();
connection.destroy.mockImplementation(() => {
throw new Error("Cannot destroy VoiceConnection - it has already been destroyed");
});
joinVoiceChannelMock.mockReturnValueOnce(connection);
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "1001" });
connection.state.status = "destroyed";
await expect(manager.leave({ guildId: "g1" })).resolves.toMatchObject({ ok: true });
expect(connection.destroy).not.toHaveBeenCalled();
});
it("removes voice listeners on leave", async () => {
const connection = createConnectionMock();
joinVoiceChannelMock.mockReturnValueOnce(connection);
@@ -850,4 +903,15 @@ describe("DiscordVoiceManager", () => {
await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow();
expect(autoJoinSpy).toHaveBeenCalledTimes(1);
});
it("DiscordVoiceResumedListener: runs autoJoin on gateway resume", async () => {
const manager = createManager();
const autoJoinSpy = vi.spyOn(manager, "autoJoin").mockResolvedValue(undefined);
const { DiscordVoiceResumedListener } = managerModule;
const listener = new DiscordVoiceResumedListener(manager);
await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow();
expect(autoJoinSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { DiscordVoiceReadyListener } from "./manager.js";
import { GatewayDispatchEvents } from "../internal/discord.js";
import { DiscordVoiceReadyListener, DiscordVoiceResumedListener } from "./manager.js";
describe("DiscordVoiceReadyListener", () => {
it("starts auto-join without blocking the ready listener", async () => {
@@ -21,4 +22,16 @@ describe("DiscordVoiceReadyListener", () => {
resolveJoin?.();
});
it("starts auto-join after Discord gateway resumes", async () => {
const autoJoin = vi.fn(async () => {});
const listener = new DiscordVoiceResumedListener({
autoJoin,
} as unknown as ConstructorParameters<typeof DiscordVoiceResumedListener>[0]);
await expect(listener.handle({} as never, {} as never)).resolves.toBeUndefined();
expect(listener.type).toBe(GatewayDispatchEvents.Resumed);
expect(autoJoin).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,8 +1,11 @@
import {
DiscordVoiceManager as DiscordVoiceManagerImpl,
DiscordVoiceReadyListener as DiscordVoiceReadyListenerImpl,
DiscordVoiceResumedListener as DiscordVoiceResumedListenerImpl,
} from "./manager.js";
export class DiscordVoiceManager extends DiscordVoiceManagerImpl {}
export class DiscordVoiceReadyListener extends DiscordVoiceReadyListenerImpl {}
export class DiscordVoiceResumedListener extends DiscordVoiceResumedListenerImpl {}

View File

@@ -5,7 +5,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveDiscordAccountAllowFrom } from "../accounts.js";
import { type Client, ReadyListener } from "../internal/discord.js";
import { type Client, ReadyListener, ResumedListener } from "../internal/discord.js";
import type { VoicePlugin } from "../internal/voice.js";
import { formatMention } from "../mentions.js";
import { decodeOpusStream, writeVoiceWavFile } from "./audio.js";
@@ -46,6 +46,43 @@ import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
const logger = createSubsystemLogger("discord/voice");
type DiscordVoiceSdk = ReturnType<typeof loadDiscordVoiceSdk>;
type DiscordVoiceConnection = ReturnType<DiscordVoiceSdk["joinVoiceChannel"]>;
function isVoiceConnectionDestroyed(
connection: DiscordVoiceConnection,
voiceSdk: DiscordVoiceSdk,
): boolean {
return connection.state.status === voiceSdk.VoiceConnectionStatus.Destroyed;
}
function destroyVoiceConnectionSafely(params: {
connection: DiscordVoiceConnection;
voiceSdk: DiscordVoiceSdk;
reason: string;
}): void {
if (isVoiceConnectionDestroyed(params.connection, params.voiceSdk)) {
logVoiceVerbose(`destroy skipped: ${params.reason}; connection already destroyed`);
return;
}
try {
params.connection.destroy();
} catch (err) {
const message = formatErrorMessage(err);
if (message.includes("already been destroyed")) {
logVoiceVerbose(`destroy skipped: ${params.reason}; ${message}`);
return;
}
logger.warn(`discord voice: destroy failed: ${params.reason}: ${message}`);
}
}
function startAutoJoin(manager: Pick<DiscordVoiceManager, "autoJoin">) {
void manager
.autoJoin()
.catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`));
}
export class DiscordVoiceManager {
private sessions = new Map<string, VoiceSessionEntry>();
private botUserId?: string;
@@ -192,6 +229,19 @@ export class DiscordVoiceManager {
} connectTimeout=${connectReadyTimeoutMs}ms reconnectGrace=${reconnectGraceMs}ms`,
);
const voiceSdk = loadDiscordVoiceSdk();
const existingEntry = this.sessions.get(guildId);
if (existingEntry) {
existingEntry.stop();
this.sessions.delete(guildId);
}
const staleConnection = voiceSdk.getVoiceConnection(guildId);
if (staleConnection) {
destroyVoiceConnectionSafely({
connection: staleConnection,
voiceSdk,
reason: `stale connection before join guild ${guildId}`,
});
}
const connection = voiceSdk.joinVoiceChannel({
channelId,
guildId,
@@ -213,7 +263,11 @@ export class DiscordVoiceManager {
logger.warn(
`discord voice: join failed before ready: guild ${guildId} channel ${channelId} timeout=${connectReadyTimeoutMs}ms error=${formatErrorMessage(err)}`,
);
connection.destroy();
destroyVoiceConnectionSafely({
connection,
voiceSdk,
reason: `failed join cleanup guild ${guildId} channel ${channelId}`,
});
return { ok: false, message: `Failed to join voice channel: ${formatErrorMessage(err)}` };
}
@@ -288,7 +342,11 @@ export class DiscordVoiceManager {
player.off("error", playerErrorHandler);
}
player.stop();
connection.destroy();
destroyVoiceConnectionSafely({
connection,
voiceSdk,
reason: `stop guild ${guildId} channel ${channelId}`,
});
},
};
@@ -324,7 +382,11 @@ export class DiscordVoiceManager {
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
);
clearSessionIfCurrent();
connection.destroy();
destroyVoiceConnectionSafely({
connection,
voiceSdk,
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
});
}
};
destroyedHandler = () => {
@@ -613,8 +675,16 @@ export class DiscordVoiceReadyListener extends ReadyListener {
}
async handle(_data: unknown, _client: Client): Promise<void> {
void this.manager
.autoJoin()
.catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`));
startAutoJoin(this.manager);
}
}
export class DiscordVoiceResumedListener extends ResumedListener {
constructor(private manager: DiscordVoiceManager) {
super();
}
async handle(_data: unknown, _client: Client): Promise<void> {
startAutoJoin(this.manager);
}
}