fix(discord): add voice listener compat shim

This commit is contained in:
Peter Steinberger
2026-04-07 09:57:05 +01:00
parent 124cd5e307
commit 5a1cf20aee
3 changed files with 176 additions and 3 deletions

View File

@@ -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" })]),
);
});
});

View File

@@ -41,6 +41,44 @@ type CreateClientFn = (
plugins: ConstructorParameters<typeof Client>[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<typeof resolveDiscordPresenceUpdate>[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,

View File

@@ -194,15 +194,18 @@ describe("monitorDiscordProvider", () => {
Parameters<typeof providerTesting.setLoadDiscordProviderSessionRuntime>[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) =>