From 2860592302d84869c4fbe88389d9681537885f09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 04:33:50 +0100 Subject: [PATCH] fix(discord): hand off interactions asynchronously --- CHANGELOG.md | 1 + .../src/monitor/gateway-plugin.test.ts | 8 +++ .../discord/src/monitor/gateway-plugin.ts | 4 +- .../discord/src/monitor/listeners.test.ts | 50 ++++++++++++++++++- extensions/discord/src/monitor/listeners.ts | 24 +++++++++ .../src/monitor/provider.startup.test.ts | 1 + .../discord/src/monitor/provider.startup.ts | 5 ++ .../src/test-support/provider.test-support.ts | 1 + 8 files changed, 92 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeab4aef004..6b221876148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: own the Carbon interaction listener and hand off Discord slash/component handling asynchronously, so compaction or long session locks no longer trip `InteractionEventListener` listener timeouts. Fixes #73204. Thanks @slideshow-dingo. - Gateway/startup: keep value-option foreground starts on the gateway fast path and skip proxy bootstrap unless proxy env is configured, reducing normal gateway startup RSS and avoiding full CLI graph loading. Thanks @vincentkoc. - Heartbeat/models: show heartbeat model bleed guidance on context-overflow resets when the last runtime model matches configured `heartbeat.model`, so smaller local heartbeat models point users to `isolatedSession` or `lightContext` instead of only compaction-buffer tuning. Fixes #67314. Thanks @Knightmare6890. - Subagents/models: persist `sessions_spawn.model` and configured subagent models as child-session model overrides before the first turn, so spawned subagents actually run on the requested provider/model instead of reverting to the target agent default. Fixes #73180. Thanks @danielzinhu99. diff --git a/extensions/discord/src/monitor/gateway-plugin.test.ts b/extensions/discord/src/monitor/gateway-plugin.test.ts index c6a53437e18..eaf8c1d5680 100644 --- a/extensions/discord/src/monitor/gateway-plugin.test.ts +++ b/extensions/discord/src/monitor/gateway-plugin.test.ts @@ -117,6 +117,14 @@ describe("SafeGatewayPlugin.connect()", () => { } }); + it("leaves Carbon autoInteractions disabled so OpenClaw owns interaction handoff", () => { + const plugin = createPlugin(); + + expect((plugin as unknown as { options?: { autoInteractions?: boolean } }).options).toEqual( + expect.objectContaining({ autoInteractions: false }), + ); + }); + it("clears stale firstHeartbeatTimeout before delegating to super when isConnecting=true", () => { const plugin = createPlugin(); diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 995641751f1..fd3d2e7b07d 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -485,7 +485,9 @@ export function createDiscordGatewayPlugin(params: { const options = { reconnect: { maxAttempts: 50 }, intents, - autoInteractions: true, + // OpenClaw registers its own async interaction listener. Carbon's default + // InteractionEventListener awaits the full handler on the critical event lane. + autoInteractions: false, }; if (!proxy) { diff --git a/extensions/discord/src/monitor/listeners.test.ts b/extensions/discord/src/monitor/listeners.test.ts index 92894e66937..69f9e4e58c8 100644 --- a/extensions/discord/src/monitor/listeners.test.ts +++ b/extensions/discord/src/monitor/listeners.test.ts @@ -1,9 +1,10 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; let DiscordMessageListener: typeof import("./listeners.js").DiscordMessageListener; +let DiscordInteractionListener: typeof import("./listeners.js").DiscordInteractionListener; beforeAll(async () => { - ({ DiscordMessageListener } = await import("./listeners.js")); + ({ DiscordMessageListener, DiscordInteractionListener } = await import("./listeners.js")); }); function createLogger() { @@ -150,3 +151,50 @@ describe("DiscordMessageListener", () => { expect(onEvent).toHaveBeenCalledTimes(2); }); }); + +describe("DiscordInteractionListener", () => { + it("returns immediately without awaiting Carbon interaction handling", async () => { + const handlerDone = createDeferred(); + const handleInteraction = vi.fn(async () => { + await handlerDone.promise; + }); + const logger = createLogger(); + const listener = new DiscordInteractionListener(logger as never); + + await expect( + listener.handle({ id: "interaction-1" } as never, { handleInteraction } as never), + ).resolves.toBeUndefined(); + await flushAsyncWork(); + expect(handleInteraction).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); + + handlerDone.resolve?.(); + await flushAsyncWork(); + }); + + it("logs async interaction failures", async () => { + const handleInteraction = vi.fn(async () => { + throw new Error("interaction boom"); + }); + const logger = createLogger(); + const listener = new DiscordInteractionListener(logger as never); + + await listener.handle({ id: "interaction-1" } as never, { handleInteraction } as never); + await flushAsyncWork(); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("discord interaction handler failed: Error: interaction boom"), + ); + }); + + it("calls onEvent callback for each interaction", async () => { + const handleInteraction = vi.fn(async () => {}); + const onEvent = vi.fn(); + const listener = new DiscordInteractionListener(undefined, onEvent); + + await listener.handle({ id: "interaction-1" } as never, { handleInteraction } as never); + await listener.handle({ id: "interaction-2" } as never, { handleInteraction } as never); + + expect(onEvent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/extensions/discord/src/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts index 3d533d67f5c..b26b7e53526 100644 --- a/extensions/discord/src/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -1,6 +1,7 @@ import { ChannelType, type Client, + InteractionCreateListener, MessageCreateListener, MessageReactionAddListener, MessageReactionRemoveListener, @@ -44,6 +45,7 @@ type RuntimeEnv = import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; type Logger = ReturnType; export type DiscordMessageEvent = Parameters[0]; +export type DiscordInteractionEvent = Parameters[0]; export type DiscordMessageHandler = ( data: DiscordMessageEvent, @@ -231,6 +233,28 @@ export class DiscordMessageListener extends MessageCreateListener { } } +export class DiscordInteractionListener extends InteractionCreateListener { + constructor( + private logger?: Logger, + private onEvent?: () => void, + ) { + super(); + } + + async handle(data: DiscordInteractionEvent, client: Client) { + this.onEvent?.(); + // Carbon awaits interaction listeners on its critical gateway lane. Hand off + // immediately so slash/component handling can wait on session locks or compaction + // without tripping Carbon's listener timeout and dropping later gateway events. + void Promise.resolve() + .then(() => client.handleInteraction(data as Parameters[0], {})) + .catch((err) => { + const logger = this.logger ?? discordEventQueueLog; + logger.error(danger(`discord interaction handler failed: ${String(err)}`)); + }); + } +} + export class DiscordReactionListener extends MessageReactionAddListener { constructor(private params: DiscordReactionListenerParams) { super(); diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index e5e440ba23a..24b64e44f79 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -61,6 +61,7 @@ vi.mock("./gateway-supervisor.js", () => ({ vi.mock("./listeners.js", () => ({ DiscordMessageListener: function DiscordMessageListener() {}, + DiscordInteractionListener: function DiscordInteractionListener() {}, DiscordPresenceListener: function DiscordPresenceListener() {}, DiscordReactionListener: function DiscordReactionListener() {}, DiscordReactionRemoveListener: function DiscordReactionRemoveListener() {}, diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index 7aa33dee54d..cb4cc81e720 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -25,6 +25,7 @@ import { import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js"; import { DiscordMessageListener, + DiscordInteractionListener, DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, @@ -254,6 +255,10 @@ export function registerDiscordMonitorListeners(params: { trackInboundEvent?: () => void; eventQueueListenerTimeoutMs?: number; }) { + registerDiscordListener( + params.client.listeners, + new DiscordInteractionListener(params.logger, params.trackInboundEvent), + ); registerDiscordListener( params.client.listeners, new DiscordMessageListener(params.messageHandler, params.logger, params.trackInboundEvent, { diff --git a/extensions/discord/src/test-support/provider.test-support.ts b/extensions/discord/src/test-support/provider.test-support.ts index b23b3644f74..f273aa1a43d 100644 --- a/extensions/discord/src/test-support/provider.test-support.ts +++ b/extensions/discord/src/test-support/provider.test-support.ts @@ -483,6 +483,7 @@ vi.mock(buildDiscordSourceModuleId("monitor/gateway-plugin.js"), () => ({ })); vi.mock(buildDiscordSourceModuleId("monitor/listeners.js"), () => ({ + DiscordInteractionListener: function DiscordInteractionListener() {}, DiscordMessageListener: function DiscordMessageListener() {}, DiscordPresenceListener: function DiscordPresenceListener() {}, DiscordReactionListener: function DiscordReactionListener() {},