From 5a1cf20aeea75eb92088c0efd392a1ba2b37ada1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 09:57:05 +0100 Subject: [PATCH] fix(discord): add voice listener compat shim --- .../src/monitor/provider.startup.test.ts | 125 ++++++++++++++++++ .../discord/src/monitor/provider.startup.ts | 47 ++++++- .../discord/src/monitor/provider.test.ts | 7 +- 3 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 extensions/discord/src/monitor/provider.startup.test.ts diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts new file mode 100644 index 00000000000..9b3094a360c --- /dev/null +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -0,0 +1,125 @@ +import type { Client, Plugin } from "@buape/carbon"; +import { describe, expect, it, vi } from "vitest"; + +const { registerVoiceClientSpy } = vi.hoisted(() => ({ + registerVoiceClientSpy: vi.fn(), +})); + +vi.mock("@buape/carbon/voice", () => ({ + VoicePlugin: class VoicePlugin { + id = "voice"; + + registerClient(client: { + getPlugin: (id: string) => unknown; + registerListener: (listener: object) => object; + unregisterListener: (listener: object) => boolean; + }) { + registerVoiceClientSpy(client); + if (!client.getPlugin("gateway")) { + throw new Error("gateway plugin missing"); + } + client.registerListener({ type: "legacy-voice-listener" }); + } + }, +})); + +vi.mock("openclaw/plugin-sdk/config-runtime", () => ({ + isDangerousNameMatchingEnabled: () => false, +})); + +vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ + danger: (value: string) => value, +})); + +vi.mock("openclaw/plugin-sdk/text-runtime", () => ({ + normalizeOptionalString: (value: string | null | undefined) => { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; + }, +})); + +vi.mock("../proxy-request-client.js", () => ({ + createDiscordRequestClient: vi.fn(), +})); + +vi.mock("./auto-presence.js", () => ({ + createDiscordAutoPresenceController: vi.fn(), +})); + +vi.mock("./gateway-plugin.js", () => ({ + createDiscordGatewayPlugin: vi.fn(), +})); + +vi.mock("./gateway-supervisor.js", () => ({ + createDiscordGatewaySupervisor: vi.fn(), +})); + +vi.mock("./listeners.js", () => ({ + DiscordMessageListener: class DiscordMessageListener {}, + DiscordPresenceListener: class DiscordPresenceListener {}, + DiscordReactionListener: class DiscordReactionListener {}, + DiscordReactionRemoveListener: class DiscordReactionRemoveListener {}, + DiscordThreadUpdateListener: class DiscordThreadUpdateListener {}, + registerDiscordListener: vi.fn(), +})); + +vi.mock("./presence.js", () => ({ + resolveDiscordPresenceUpdate: vi.fn(() => undefined), +})); + +import { createDiscordMonitorClient } from "./provider.startup.js"; + +describe("createDiscordMonitorClient", () => { + it("adds listener compat for legacy voice plugins", () => { + registerVoiceClientSpy.mockReset(); + + const gatewayPlugin = { + id: "gateway", + registerClient: vi.fn(), + registerRoutes: vi.fn(), + } as Plugin; + + const result = createDiscordMonitorClient({ + accountId: "default", + applicationId: "app-1", + token: "token-1", + commands: [], + components: [], + modals: [], + voiceEnabled: true, + discordConfig: {}, + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + createClient: (_options, handlers, plugins = []) => { + const pluginRegistry = plugins.map((plugin) => ({ id: plugin.id, plugin })); + return { + listeners: [...(handlers.listeners ?? [])], + plugins: pluginRegistry, + getPlugin: (id: string) => pluginRegistry.find((entry) => entry.id === id)?.plugin, + } as Client; + }, + createGatewayPlugin: () => gatewayPlugin as never, + createGatewaySupervisor: () => ({ shutdown: vi.fn(), handleError: vi.fn() }) as never, + createAutoPresenceController: () => + ({ + enabled: false, + start: vi.fn(), + stop: vi.fn(), + refresh: vi.fn(), + runNow: vi.fn(), + }) as never, + isDisallowedIntentsError: () => false, + }); + + expect(registerVoiceClientSpy).toHaveBeenCalledTimes(1); + expect(result.client.listeners).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "legacy-voice-listener" })]), + ); + }); +}); diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index d31d3522a58..ad098f06ec8 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -41,6 +41,44 @@ type CreateClientFn = ( plugins: ConstructorParameters[2], ) => Client; +type ListenerCompatClient = Client & { + plugins?: Array<{ id: string; plugin: Plugin }>; + registerListener?: (listener: object) => object; + unregisterListener?: (listener: object) => boolean; +}; + +function withLegacyListenerCompat(client: Client): ListenerCompatClient { + const compatClient = client as ListenerCompatClient; + if (!compatClient.registerListener) { + compatClient.registerListener = (listener: object) => { + if (!compatClient.listeners.includes(listener as never)) { + compatClient.listeners.push(listener as never); + } + return listener; + }; + } + if (!compatClient.unregisterListener) { + compatClient.unregisterListener = (listener: object) => { + const index = compatClient.listeners.indexOf(listener as never); + if (index < 0) { + return false; + } + compatClient.listeners.splice(index, 1); + return true; + }; + } + return compatClient; +} + +function registerLatePlugin(client: Client, plugin: Plugin) { + const compatClient = withLegacyListenerCompat(client); + void plugin.registerClient?.(compatClient); + void plugin.registerRoutes?.(compatClient); + if (!compatClient.plugins?.some((entry) => entry.id === plugin.id)) { + compatClient.plugins?.push({ id: plugin.id, plugin }); + } +} + export function createDiscordStatusReadyListener(params: { discordConfig: Parameters[0]; getAutoPresenceController: () => DiscordAutoPresenceController | null; @@ -97,6 +135,10 @@ export function createDiscordMonitorClient(params: { if (params.voiceEnabled) { clientPlugins.push(new VoicePlugin()); } + const voicePlugin = clientPlugins.find((plugin) => plugin.id === "voice"); + const constructorPlugins = voicePlugin + ? clientPlugins.filter((plugin) => plugin !== voicePlugin) + : clientPlugins; // Pass eventQueue config to Carbon so the gateway listener budget can be tuned. // Default listenerTimeout is 120s (Carbon defaults to 30s, which is too short for some @@ -125,8 +167,11 @@ export function createDiscordMonitorClient(params: { components: params.components, modals: params.modals, }, - clientPlugins, + constructorPlugins, ); + if (voicePlugin) { + registerLatePlugin(client, voicePlugin); + } if (params.proxyFetch) { client.rest = createDiscordRequestClient(params.token, { fetch: params.proxyFetch, diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 24fdfa8ad07..c295246a425 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -194,15 +194,18 @@ describe("monitorDiscordProvider", () => { Parameters[0] >, ); - providerTesting.setCreateClient((options, handlers) => { + providerTesting.setCreateClient((options, handlers, plugins = []) => { clientConstructorOptionsMock(options); + const pluginRegistry = plugins.map((plugin) => ({ id: plugin.id, plugin })); return { options, listeners: handlers.listeners ?? [], + plugins: pluginRegistry, rest: { put: vi.fn(async () => undefined) }, handleDeployRequest: async () => await clientHandleDeployRequestMock(), fetchUser: async (target: string) => await clientFetchUserMock(target), - getPlugin: (name: string) => clientGetPluginMock(name), + getPlugin: (name: string) => + clientGetPluginMock(name) ?? pluginRegistry.find((entry) => entry.id === name)?.plugin, } as never; }); providerTesting.setGetPluginCommandSpecs((provider?: string) =>