Skip to content

Commit 4a4353e

Browse files
committed
fix: recover Discord voice auto-join after resume
1 parent 7719dd8 commit 4a4353e

8 files changed

Lines changed: 168 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919
### Fixes
2020

2121
- Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582)
22+
- 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.
2223
- 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.
2324
- 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.
2425
- 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.

extensions/discord/src/internal/listeners.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export abstract class ReadyListener extends BaseListener {
4545
readonly type = GatewayDispatchEvents.Ready;
4646
}
4747

48+
export abstract class ResumedListener extends BaseListener {
49+
readonly type = GatewayDispatchEvents.Resumed;
50+
}
51+
4852
export abstract class MessageCreateListener extends BaseListener {
4953
readonly type = GatewayDispatchEvents.MessageCreate;
5054
abstract override handle(data: DiscordMessageDispatchData, client: Client): Promise<void> | void;

extensions/discord/src/monitor/provider.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ vi.mock("../voice/manager.runtime.js", () => {
106106
return {
107107
DiscordVoiceManager: function DiscordVoiceManager() {},
108108
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
109+
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
109110
};
110111
});
111112
describe("monitorDiscordProvider", () => {
@@ -222,6 +223,7 @@ describe("monitorDiscordProvider", () => {
222223
return {
223224
DiscordVoiceManager: function DiscordVoiceManager() {},
224225
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
226+
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
225227
} as never;
226228
});
227229
providerTesting.setLoadDiscordProviderSessionRuntime(

extensions/discord/src/monitor/provider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
526526
}
527527

528528
if (voiceEnabled) {
529-
const { DiscordVoiceManager, DiscordVoiceReadyListener } = await loadDiscordVoiceRuntime();
529+
const { DiscordVoiceManager, DiscordVoiceReadyListener, DiscordVoiceResumedListener } =
530+
await loadDiscordVoiceRuntime();
530531
voiceManager = new DiscordVoiceManager({
531532
client,
532533
cfg,
@@ -537,6 +538,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
537538
});
538539
voiceManagerRef.current = voiceManager;
539540
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
541+
registerDiscordListener(client.listeners, new DiscordVoiceResumedListener(voiceManager));
540542
}
541543

542544
const messageHandler = discordProviderSessionRuntime.createDiscordMessageHandler({

extensions/discord/src/voice/manager.e2e.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createVoiceReceiveRecoveryState } from "./receive-recovery.js";
55

66
const {
77
createConnectionMock,
8+
getVoiceConnectionMock,
89
joinVoiceChannelMock,
910
entersStateMock,
1011
createAudioPlayerMock,
@@ -83,8 +84,11 @@ const {
8384
return connection;
8485
};
8586

87+
const getVoiceConnectionMock = vi.fn((): MockConnection | undefined => undefined);
88+
8689
return {
8790
createConnectionMock,
91+
getVoiceConnectionMock,
8892
joinVoiceChannelMock: vi.fn(() => createConnectionMock()),
8993
entersStateMock: vi.fn(async (_target?: unknown, _state?: string, _timeoutMs?: number) => {
9094
return undefined;
@@ -118,6 +122,7 @@ vi.mock("./sdk-runtime.js", () => ({
118122
createAudioPlayer: createAudioPlayerMock,
119123
createAudioResource: vi.fn(),
120124
entersState: entersStateMock,
125+
getVoiceConnection: getVoiceConnectionMock,
121126
joinVoiceChannel: joinVoiceChannelMock,
122127
}),
123128
}));
@@ -189,6 +194,8 @@ describe("DiscordVoiceManager", () => {
189194
});
190195

191196
beforeEach(() => {
197+
getVoiceConnectionMock.mockReset();
198+
getVoiceConnectionMock.mockReturnValue(undefined);
192199
joinVoiceChannelMock.mockReset();
193200
joinVoiceChannelMock.mockImplementation(() => createConnectionMock());
194201
entersStateMock.mockReset();
@@ -313,6 +320,52 @@ describe("DiscordVoiceManager", () => {
313320
expectConnectedStatus(manager, "1002");
314321
});
315322

323+
it("destroys stale tracked voice connections before joining", async () => {
324+
const staleConnection = createConnectionMock();
325+
const connection = createConnectionMock();
326+
getVoiceConnectionMock.mockReturnValueOnce(staleConnection);
327+
joinVoiceChannelMock.mockReturnValueOnce(connection);
328+
const manager = createManager();
329+
330+
await manager.join({ guildId: "g1", channelId: "1001" });
331+
332+
expect(getVoiceConnectionMock).toHaveBeenCalledWith("g1");
333+
expect(staleConnection.destroy).toHaveBeenCalledTimes(1);
334+
expectConnectedStatus(manager, "1001");
335+
});
336+
337+
it("does not throw when stale tracked voice connections are already destroyed", async () => {
338+
const staleConnection = createConnectionMock();
339+
staleConnection.state.status = "destroyed";
340+
staleConnection.destroy.mockImplementation(() => {
341+
throw new Error("Cannot destroy VoiceConnection - it has already been destroyed");
342+
});
343+
getVoiceConnectionMock.mockReturnValueOnce(staleConnection);
344+
joinVoiceChannelMock.mockReturnValueOnce(createConnectionMock());
345+
const manager = createManager();
346+
347+
await expect(manager.join({ guildId: "g1", channelId: "1001" })).resolves.toMatchObject({
348+
ok: true,
349+
});
350+
351+
expect(staleConnection.destroy).not.toHaveBeenCalled();
352+
});
353+
354+
it("does not throw when leaving an already destroyed voice connection", async () => {
355+
const connection = createConnectionMock();
356+
connection.destroy.mockImplementation(() => {
357+
throw new Error("Cannot destroy VoiceConnection - it has already been destroyed");
358+
});
359+
joinVoiceChannelMock.mockReturnValueOnce(connection);
360+
const manager = createManager();
361+
362+
await manager.join({ guildId: "g1", channelId: "1001" });
363+
connection.state.status = "destroyed";
364+
365+
await expect(manager.leave({ guildId: "g1" })).resolves.toMatchObject({ ok: true });
366+
expect(connection.destroy).not.toHaveBeenCalled();
367+
});
368+
316369
it("removes voice listeners on leave", async () => {
317370
const connection = createConnectionMock();
318371
joinVoiceChannelMock.mockReturnValueOnce(connection);
@@ -850,4 +903,15 @@ describe("DiscordVoiceManager", () => {
850903
await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow();
851904
expect(autoJoinSpy).toHaveBeenCalledTimes(1);
852905
});
906+
907+
it("DiscordVoiceResumedListener: runs autoJoin on gateway resume", async () => {
908+
const manager = createManager();
909+
const autoJoinSpy = vi.spyOn(manager, "autoJoin").mockResolvedValue(undefined);
910+
911+
const { DiscordVoiceResumedListener } = managerModule;
912+
const listener = new DiscordVoiceResumedListener(manager);
913+
914+
await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow();
915+
expect(autoJoinSpy).toHaveBeenCalledTimes(1);
916+
});
853917
});

extensions/discord/src/voice/manager.ready-listener.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { DiscordVoiceReadyListener } from "./manager.js";
2+
import { GatewayDispatchEvents } from "../internal/discord.js";
3+
import { DiscordVoiceReadyListener, DiscordVoiceResumedListener } from "./manager.js";
34

45
describe("DiscordVoiceReadyListener", () => {
56
it("starts auto-join without blocking the ready listener", async () => {
@@ -21,4 +22,16 @@ describe("DiscordVoiceReadyListener", () => {
2122

2223
resolveJoin?.();
2324
});
25+
26+
it("starts auto-join after Discord gateway resumes", async () => {
27+
const autoJoin = vi.fn(async () => {});
28+
const listener = new DiscordVoiceResumedListener({
29+
autoJoin,
30+
} as unknown as ConstructorParameters<typeof DiscordVoiceResumedListener>[0]);
31+
32+
await expect(listener.handle({} as never, {} as never)).resolves.toBeUndefined();
33+
34+
expect(listener.type).toBe(GatewayDispatchEvents.Resumed);
35+
expect(autoJoin).toHaveBeenCalledTimes(1);
36+
});
2437
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import {
22
DiscordVoiceManager as DiscordVoiceManagerImpl,
33
DiscordVoiceReadyListener as DiscordVoiceReadyListenerImpl,
4+
DiscordVoiceResumedListener as DiscordVoiceResumedListenerImpl,
45
} from "./manager.js";
56

67
export class DiscordVoiceManager extends DiscordVoiceManagerImpl {}
78

89
export class DiscordVoiceReadyListener extends DiscordVoiceReadyListenerImpl {}
10+
11+
export class DiscordVoiceResumedListener extends DiscordVoiceResumedListenerImpl {}

extensions/discord/src/voice/manager.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
55
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
66
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
77
import { resolveDiscordAccountAllowFrom } from "../accounts.js";
8-
import { type Client, ReadyListener } from "../internal/discord.js";
8+
import { type Client, ReadyListener, ResumedListener } from "../internal/discord.js";
99
import type { VoicePlugin } from "../internal/voice.js";
1010
import { formatMention } from "../mentions.js";
1111
import { decodeOpusStream, writeVoiceWavFile } from "./audio.js";
@@ -46,6 +46,43 @@ import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
4646

4747
const logger = createSubsystemLogger("discord/voice");
4848

49+
type DiscordVoiceSdk = ReturnType<typeof loadDiscordVoiceSdk>;
50+
type DiscordVoiceConnection = ReturnType<DiscordVoiceSdk["joinVoiceChannel"]>;
51+
52+
function isVoiceConnectionDestroyed(
53+
connection: DiscordVoiceConnection,
54+
voiceSdk: DiscordVoiceSdk,
55+
): boolean {
56+
return connection.state.status === voiceSdk.VoiceConnectionStatus.Destroyed;
57+
}
58+
59+
function destroyVoiceConnectionSafely(params: {
60+
connection: DiscordVoiceConnection;
61+
voiceSdk: DiscordVoiceSdk;
62+
reason: string;
63+
}): void {
64+
if (isVoiceConnectionDestroyed(params.connection, params.voiceSdk)) {
65+
logVoiceVerbose(`destroy skipped: ${params.reason}; connection already destroyed`);
66+
return;
67+
}
68+
try {
69+
params.connection.destroy();
70+
} catch (err) {
71+
const message = formatErrorMessage(err);
72+
if (message.includes("already been destroyed")) {
73+
logVoiceVerbose(`destroy skipped: ${params.reason}; ${message}`);
74+
return;
75+
}
76+
logger.warn(`discord voice: destroy failed: ${params.reason}: ${message}`);
77+
}
78+
}
79+
80+
function startAutoJoin(manager: Pick<DiscordVoiceManager, "autoJoin">) {
81+
void manager
82+
.autoJoin()
83+
.catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`));
84+
}
85+
4986
export class DiscordVoiceManager {
5087
private sessions = new Map<string, VoiceSessionEntry>();
5188
private botUserId?: string;
@@ -192,6 +229,19 @@ export class DiscordVoiceManager {
192229
} connectTimeout=${connectReadyTimeoutMs}ms reconnectGrace=${reconnectGraceMs}ms`,
193230
);
194231
const voiceSdk = loadDiscordVoiceSdk();
232+
const existingEntry = this.sessions.get(guildId);
233+
if (existingEntry) {
234+
existingEntry.stop();
235+
this.sessions.delete(guildId);
236+
}
237+
const staleConnection = voiceSdk.getVoiceConnection(guildId);
238+
if (staleConnection) {
239+
destroyVoiceConnectionSafely({
240+
connection: staleConnection,
241+
voiceSdk,
242+
reason: `stale connection before join guild ${guildId}`,
243+
});
244+
}
195245
const connection = voiceSdk.joinVoiceChannel({
196246
channelId,
197247
guildId,
@@ -213,7 +263,11 @@ export class DiscordVoiceManager {
213263
logger.warn(
214264
`discord voice: join failed before ready: guild ${guildId} channel ${channelId} timeout=${connectReadyTimeoutMs}ms error=${formatErrorMessage(err)}`,
215265
);
216-
connection.destroy();
266+
destroyVoiceConnectionSafely({
267+
connection,
268+
voiceSdk,
269+
reason: `failed join cleanup guild ${guildId} channel ${channelId}`,
270+
});
217271
return { ok: false, message: `Failed to join voice channel: ${formatErrorMessage(err)}` };
218272
}
219273

@@ -288,7 +342,11 @@ export class DiscordVoiceManager {
288342
player.off("error", playerErrorHandler);
289343
}
290344
player.stop();
291-
connection.destroy();
345+
destroyVoiceConnectionSafely({
346+
connection,
347+
voiceSdk,
348+
reason: `stop guild ${guildId} channel ${channelId}`,
349+
});
292350
},
293351
};
294352

@@ -324,7 +382,11 @@ export class DiscordVoiceManager {
324382
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
325383
);
326384
clearSessionIfCurrent();
327-
connection.destroy();
385+
destroyVoiceConnectionSafely({
386+
connection,
387+
voiceSdk,
388+
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
389+
});
328390
}
329391
};
330392
destroyedHandler = () => {
@@ -613,8 +675,16 @@ export class DiscordVoiceReadyListener extends ReadyListener {
613675
}
614676

615677
async handle(_data: unknown, _client: Client): Promise<void> {
616-
void this.manager
617-
.autoJoin()
618-
.catch((err) => logger.warn(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`));
678+
startAutoJoin(this.manager);
679+
}
680+
}
681+
682+
export class DiscordVoiceResumedListener extends ResumedListener {
683+
constructor(private manager: DiscordVoiceManager) {
684+
super();
685+
}
686+
687+
async handle(_data: unknown, _client: Client): Promise<void> {
688+
startAutoJoin(this.manager);
619689
}
620690
}

0 commit comments

Comments
 (0)