From 928698d3885b19d7413e0bf22696af43491dfec3 Mon Sep 17 00:00:00 2001 From: "openclaw-clownfish[bot]" <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:55:04 -0700 Subject: [PATCH] fix(discord): fail closed when bot identity is unavailable Fail Discord startup closed when the bot identity cannot be resolved, and keep mention gating active when configured mention patterns can still detect required mentions without a bot id.\n\nFixes #42219. Carries forward source PRs #46856 by @education-01 and #49218 by @BenediktSchackenberg. #46847 was already closed as a duplicate; #42675 was security-routed separately and left out of the replacement source. --- CHANGELOG.md | 1 + .../monitor/message-handler.preflight.test.ts | 49 +++++++++++++++++++ .../src/monitor/message-handler.preflight.ts | 4 +- .../discord/src/monitor/provider.startup.ts | 34 +++++++++---- .../discord/src/monitor/provider.test.ts | 36 ++++++++++++++ 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b368421b22..71d3161164e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/Discord: fail startup closed when Discord cannot resolve the bot's own identity and keep mention gating active when only configured mention patterns can detect mentions, so the provider no longer continues with a missing bot id. Fixes #42219; carries forward #46856 and #49218. Thanks @education-01 and @BenediktSchackenberg. - Browser/gateway: ignore Playwright dialog-close races from `Page.handleJavaScriptDialog` so browser automation no longer crashes the Gateway when a dialog disappears before Playwright accepts it. (#40067) Thanks @randyjtw. - Cron/Gateway: defer missed isolated agent-turn catch-up out of the channel startup window, so overdue cron work cannot starve Discord or Telegram while providers connect after a restart. Thanks @vincentkoc. - Plugins/runtime-deps: prune stale `openclaw-unknown-*` bundled runtime dependency roots during Gateway startup while keeping recent or locked roots, so old staging debris cannot keep growing across restarts. Thanks @vincentkoc. diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index e6510d71d69..1015bcb9db1 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -797,6 +797,55 @@ describe("preflightDiscordMessage", () => { expect(result).not.toBeNull(); }); + it("does not mask mention gating when bot id is missing but mention patterns can detect", async () => { + const channelId = "channel-missing-bot-id-mention-gate"; + const guildId = "guild-missing-bot-id-mention-gate"; + const message = createDiscordMessage({ + id: "m-missing-bot-id-mention-gate", + channelId, + content: "general update without the configured mention", + author: { + id: "user-1", + bot: false, + username: "Alice", + }, + }); + + const result = await preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: { + ...DEFAULT_PREFLIGHT_CFG, + messages: { + groupChat: { + mentionPatterns: ["openclaw"], + }, + }, + } as import("openclaw/plugin-sdk/config-types").OpenClawConfig, + discordConfig: {} as DiscordConfig, + data: createGuildEvent({ + channelId, + guildId, + author: message.author, + message, + }), + client: createGuildTextClient(channelId), + }), + botUserId: undefined, + guildEntries: { + [guildId]: { + channels: { + [channelId]: { + enabled: true, + requireMention: true, + }, + }, + }, + }, + }); + + expect(result).toBeNull(); + }); + it("treats @everyone as a mention when requireMention is true", async () => { const channelId = "channel-everyone-mention"; const guildId = "guild-everyone-mention"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index c1cce1995c2..3b5aec3c06f 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1009,9 +1009,9 @@ export async function preflightDiscordMessage( `[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionDecision.shouldSkip=${mentionDecision.shouldSkip} wasMentioned=${wasMentioned}`, ); if (isGuildMessage && shouldRequireMention) { - if (botId && mentionDecision.shouldSkip) { + if (mentionDecision.shouldSkip) { logDebug(`[discord-preflight] drop: no-mention`); - logVerbose(`discord: drop guild message (mention required, botId=${botId})`); + logVerbose(`discord: drop guild message (mention required, botId=${botId ?? ""})`); logger.info( { channelId: messageChannelId, diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index fade36e7b3f..af916adab4a 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -219,21 +219,35 @@ export async function fetchDiscordBotIdentity(params: { logStartupPhase: (phase: string, details?: string) => void; }) { params.logStartupPhase("fetch-bot-identity:start"); + let botUser: Awaited>; try { - const botUser = await params.client.fetchUser("@me"); - const botUserId = botUser?.id; - const botUserName = - normalizeOptionalString(botUser?.username) ?? normalizeOptionalString(botUser?.globalName); - params.logStartupPhase( - "fetch-bot-identity:done", - `botUserId=${botUserId ?? ""} botUserName=${botUserName ?? ""}`, - ); - return { botUserId, botUserName }; + botUser = await params.client.fetchUser("@me"); } catch (err) { params.runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`)); params.logStartupPhase("fetch-bot-identity:error", String(err)); - return { botUserId: undefined, botUserName: undefined }; + throw new Error("Failed to resolve Discord bot identity", { cause: err }); } + + const botUserRecord = botUser as + | { id?: unknown; username?: unknown; globalName?: unknown } + | null + | undefined; + const botUserId = normalizeOptionalString(botUserRecord?.id); + const botUserName = + normalizeOptionalString(botUserRecord?.username) ?? + normalizeOptionalString(botUserRecord?.globalName); + if (!botUserId) { + const details = 'fetchUser("@me") returned no usable id'; + params.runtime.error?.(danger(`discord: failed to fetch bot identity: ${details}`)); + params.logStartupPhase("fetch-bot-identity:error", details); + throw new Error("Failed to resolve Discord bot identity"); + } + + params.logStartupPhase( + "fetch-bot-identity:done", + `botUserId=${botUserId} botUserName=${botUserName ?? ""}`, + ); + return { botUserId, botUserName }; } export function registerDiscordMonitorListeners(params: { diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 9ccb67ee159..d6331eac15e 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -307,6 +307,42 @@ describe("monitorDiscordProvider", () => { ); }); + it("fails closed before lifecycle when Discord bot identity fetch rejects", async () => { + const runtime = baseRuntime(); + clientFetchUserMock.mockRejectedValueOnce(new Error("identity offline")); + + await expect( + monitorDiscordProvider({ + config: baseConfig(), + runtime, + }), + ).rejects.toThrow("Failed to resolve Discord bot identity"); + + expect(createDiscordMessageHandlerMock).not.toHaveBeenCalled(); + expect(monitorLifecycleMock).not.toHaveBeenCalled(); + expect(createdBindingManagers).toHaveLength(1); + expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("identity offline")); + }); + + it("fails closed before lifecycle when Discord bot identity has no usable id", async () => { + const runtime = baseRuntime(); + clientFetchUserMock.mockResolvedValueOnce({ username: "Molty" } as never); + + await expect( + monitorDiscordProvider({ + config: baseConfig(), + runtime, + }), + ).rejects.toThrow("Failed to resolve Discord bot identity"); + + expect(createDiscordMessageHandlerMock).not.toHaveBeenCalled(); + expect(monitorLifecycleMock).not.toHaveBeenCalled(); + expect(createdBindingManagers).toHaveLength(1); + expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("no usable id")); + }); + it("does not double-stop thread bindings when lifecycle performs cleanup", async () => { await monitorDiscordProvider({ config: baseConfig(),