diff --git a/CHANGELOG.md b/CHANGELOG.md index 429b0202c28..9e6b0836438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120. - Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury. - Config: log the "newer OpenClaw" version warning once per process instead of once per config snapshot read. (#75927) Thanks @romneyda. - Telegram/message actions: treat benign delete-message 400s as no-op warnings instead of runtime errors, so stale or already-removed messages do not create noisy delete failures. Fixes #73726. Thanks @Avicennasis. diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index e557196a0e4..13742286909 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -67,12 +67,24 @@ vi.mock("./gateway-supervisor.js", () => ({ })); vi.mock("./listeners.js", () => ({ - DiscordMessageListener: function DiscordMessageListener() {}, - DiscordInteractionListener: function DiscordInteractionListener() {}, - DiscordPresenceListener: function DiscordPresenceListener() {}, - DiscordReactionListener: function DiscordReactionListener() {}, - DiscordReactionRemoveListener: function DiscordReactionRemoveListener() {}, - DiscordThreadUpdateListener: function DiscordThreadUpdateListener() {}, + DiscordMessageListener: function DiscordMessageListener() { + return { type: "message" }; + }, + DiscordInteractionListener: function DiscordInteractionListener() { + return { type: "interaction" }; + }, + DiscordPresenceListener: function DiscordPresenceListener() { + return { type: "presence" }; + }, + DiscordReactionListener: function DiscordReactionListener() { + return { type: "reaction-add" }; + }, + DiscordReactionRemoveListener: function DiscordReactionRemoveListener() { + return { type: "reaction-remove" }; + }, + DiscordThreadUpdateListener: function DiscordThreadUpdateListener() { + return { type: "thread-update" }; + }, registerDiscordListener: vi.fn(), })); @@ -81,13 +93,19 @@ vi.mock("./presence.js", () => ({ })); import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; -import { createDiscordMonitorClient, fetchDiscordBotIdentity } from "./provider.startup.js"; +import { registerDiscordListener } from "./listeners.js"; +import { + createDiscordMonitorClient, + fetchDiscordBotIdentity, + registerDiscordMonitorListeners, +} from "./provider.startup.js"; describe("createDiscordMonitorClient", () => { beforeEach(() => { registerVoiceClientSpy.mockReset(); waitForDiscordGatewayPluginRegistrationMock.mockReset().mockReturnValue(undefined); vi.mocked(createDiscordRequestClient).mockClear(); + vi.mocked(registerDiscordListener).mockClear(); }); function createRuntime() { @@ -296,6 +314,92 @@ describe("createDiscordMonitorClient", () => { }); }); +describe("registerDiscordMonitorListeners", () => { + beforeEach(() => { + vi.mocked(registerDiscordListener).mockClear(); + }); + + function createRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + } + + function createListenerParams( + overrides: Partial[0]> = {}, + ): Parameters[0] { + return { + cfg: {}, + client: { listeners: [] }, + accountId: "default", + discordConfig: {}, + runtime: createRuntime(), + botUserId: "bot-1", + dmEnabled: false, + groupDmEnabled: false, + groupDmChannels: [], + dmPolicy: "disabled", + allowFrom: [], + groupPolicy: "allowlist", + guildEntries: { + "guild-1": { + id: "guild-1", + reactionNotifications: "off", + }, + }, + logger: {}, + messageHandler: {}, + ...overrides, + } as Parameters[0]; + } + + function registeredListenerTypes() { + return vi.mocked(registerDiscordListener).mock.calls.map((call) => { + const listener = call[1] as { type?: string }; + return listener.type; + }); + } + + it("skips reaction listeners when every configured guild disables reactions and DMs are off", () => { + registerDiscordMonitorListeners(createListenerParams()); + + expect(registeredListenerTypes()).toEqual(["interaction", "message", "thread-update"]); + }); + + it("keeps reaction listeners when direct messages can emit reaction notifications", () => { + registerDiscordMonitorListeners( + createListenerParams({ + dmEnabled: true, + }), + ); + + expect(registeredListenerTypes()).toContain("reaction-add"); + expect(registeredListenerTypes()).toContain("reaction-remove"); + }); + + it("keeps reaction listeners when a configured guild enables reaction notifications", () => { + registerDiscordMonitorListeners( + createListenerParams({ + guildEntries: { + "guild-1": { + id: "guild-1", + reactionNotifications: "off", + }, + "guild-2": { + id: "guild-2", + reactionNotifications: "own", + }, + }, + }), + ); + + expect(registeredListenerTypes()).toContain("reaction-add"); + expect(registeredListenerTypes()).toContain("reaction-remove"); + }); +}); + describe("fetchDiscordBotIdentity", () => { it("derives the bot id from a Discord bot token without calling /users/@me", async () => { const fetchUser = vi.fn(async () => { diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index 5f4d220b6d3..5d6db6c3a98 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -263,30 +263,32 @@ export function registerDiscordMonitorListeners(params: { new DiscordMessageListener(params.messageHandler, params.logger, params.trackInboundEvent), ); - const reactionListenerOptions: ConstructorParameters[0] = { - cfg: params.cfg, - accountId: params.accountId, - runtime: params.runtime, - botUserId: params.botUserId, - dmEnabled: params.dmEnabled, - groupDmEnabled: params.groupDmEnabled, - groupDmChannels: params.groupDmChannels ?? [], - dmPolicy: params.dmPolicy, - allowFrom: params.allowFrom ?? [], - groupPolicy: params.groupPolicy, - allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig), - guildEntries: params.guildEntries, - logger: params.logger, - onEvent: params.trackInboundEvent, - }; - registerDiscordListener( - params.client.listeners, - new DiscordReactionListener(reactionListenerOptions), - ); - registerDiscordListener( - params.client.listeners, - new DiscordReactionRemoveListener(reactionListenerOptions), - ); + if (shouldRegisterDiscordReactionListeners(params)) { + const reactionListenerOptions: ConstructorParameters[0] = { + cfg: params.cfg, + accountId: params.accountId, + runtime: params.runtime, + botUserId: params.botUserId, + dmEnabled: params.dmEnabled, + groupDmEnabled: params.groupDmEnabled, + groupDmChannels: params.groupDmChannels ?? [], + dmPolicy: params.dmPolicy, + allowFrom: params.allowFrom ?? [], + groupPolicy: params.groupPolicy, + allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig), + guildEntries: params.guildEntries, + logger: params.logger, + onEvent: params.trackInboundEvent, + }; + registerDiscordListener( + params.client.listeners, + new DiscordReactionListener(reactionListenerOptions), + ); + registerDiscordListener( + params.client.listeners, + new DiscordReactionRemoveListener(reactionListenerOptions), + ); + } registerDiscordListener( params.client.listeners, new DiscordThreadUpdateListener(params.cfg, params.accountId, params.logger), @@ -300,3 +302,22 @@ export function registerDiscordMonitorListeners(params: { params.runtime.log?.("discord: GuildPresences intent enabled — presence listener registered"); } } + +function shouldRegisterDiscordReactionListeners(params: { + dmEnabled: boolean; + groupDmEnabled: boolean; + groupPolicy: "open" | "allowlist" | "disabled"; + guildEntries?: Record; +}): boolean { + if (params.dmEnabled || params.groupDmEnabled) { + return true; + } + if (params.groupPolicy === "disabled") { + return false; + } + const guildEntries = Object.values(params.guildEntries ?? {}); + if (guildEntries.length === 0) { + return true; + } + return guildEntries.some((entry) => entry.reactionNotifications !== "off"); +}