From 4a4353e33fdcf44b21981aac448207ac05280a30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 12:28:29 +0100 Subject: [PATCH] fix: recover Discord voice auto-join after resume --- CHANGELOG.md | 1 + extensions/discord/src/internal/listeners.ts | 4 + .../discord/src/monitor/provider.test.ts | 2 + extensions/discord/src/monitor/provider.ts | 4 +- .../discord/src/voice/manager.e2e.test.ts | 64 ++++++++++++++ .../src/voice/manager.ready-listener.test.ts | 15 +++- .../discord/src/voice/manager.runtime.ts | 3 + extensions/discord/src/voice/manager.ts | 84 +++++++++++++++++-- 8 files changed, 168 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05847e99499..b6f71c204a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/internal/listeners.ts b/extensions/discord/src/internal/listeners.ts index babc222d6a9..4c4c576178b 100644 --- a/extensions/discord/src/internal/listeners.ts +++ b/extensions/discord/src/internal/listeners.ts @@ -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; diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index f2f94b4df7c..95c0f04594f 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -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( diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 573aed2177f..1905f36fee4 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -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({ diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index b886cb6c441..8a95bb786f6 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -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); + }); }); diff --git a/extensions/discord/src/voice/manager.ready-listener.test.ts b/extensions/discord/src/voice/manager.ready-listener.test.ts index 841cda70bb9..344ca85e802 100644 --- a/extensions/discord/src/voice/manager.ready-listener.test.ts +++ b/extensions/discord/src/voice/manager.ready-listener.test.ts @@ -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[0]); + + await expect(listener.handle({} as never, {} as never)).resolves.toBeUndefined(); + + expect(listener.type).toBe(GatewayDispatchEvents.Resumed); + expect(autoJoin).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/discord/src/voice/manager.runtime.ts b/extensions/discord/src/voice/manager.runtime.ts index 1619d63a27c..84d73726160 100644 --- a/extensions/discord/src/voice/manager.runtime.ts +++ b/extensions/discord/src/voice/manager.runtime.ts @@ -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 {} diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 7c9fa679a51..480d918ea0e 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -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; +type DiscordVoiceConnection = ReturnType; + +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) { + void manager + .autoJoin() + .catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`)); +} + export class DiscordVoiceManager { private sessions = new Map(); 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 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 { + startAutoJoin(this.manager); } }