fix(discord): hand off interactions asynchronously

This commit is contained in:
Peter Steinberger
2026-04-28 04:33:50 +01:00
parent 6f13982212
commit 2860592302
8 changed files with 92 additions and 2 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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) {

View File

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

View File

@@ -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<typeof import("openclaw/plugin-sdk/runtime-env").createSubsystemLogger>;
export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0];
export type DiscordInteractionEvent = Parameters<InteractionCreateListener["handle"]>[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<Client["handleInteraction"]>[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();

View File

@@ -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() {},

View File

@@ -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, {

View File

@@ -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() {},