mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
fix: recover Discord voice auto-join after resume
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user