diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 55822830cd5..f5e975e5893 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -791,7 +791,7 @@ export async function preflightDiscordMessage( `[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`, ); if (isGuildMessage && shouldRequireMention) { - if (botId && mentionGate.shouldSkip) { + if (mentionGate.shouldSkip) { logDebug(`[discord-preflight] drop: no-mention`); logVerbose(`discord: drop guild message (mention required, botId=${botId})`); logger.info( diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index ff6fb310464..08c626da886 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -627,4 +627,36 @@ describe("monitorDiscordProvider", () => { const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false); }); + + it("falls back to applicationId when fetchUser('@me') fails (#42219)", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const runtime = baseRuntime(); + + clientFetchUserMock.mockRejectedValueOnce(new Error("network timeout")); + + // Should NOT throw — the code now falls back to applicationId as botUserId + // instead of aborting. We race with a short timeout to confirm no immediate rejection. + const result = await Promise.race([ + monitorDiscordProvider({ + config: baseConfig(), + runtime, + }).then(() => "resolved", (err: Error) => `rejected: ${err.message}`), + new Promise((r) => setTimeout(() => r("still-running"), 200)), + ]); + expect(result).toBe("still-running"); + }); + + it("throws when fetchUser('@me') returns no user id", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const runtime = baseRuntime(); + + clientFetchUserMock.mockResolvedValueOnce({ id: undefined as unknown as string, username: "NoId" }); + + await expect( + monitorDiscordProvider({ + config: baseConfig(), + runtime, + }), + ).rejects.toThrow("discord: fetchUser('@me') returned no user id"); + }); }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 523f7c54c36..f49be31e337 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -882,6 +882,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { gateway: lifecycleGateway, details: String(err), }); + // Transient REST/proxy failures should not take down the whole channel. + // applicationId is the same snowflake as the bot user id and was already + // resolved above (with a token-decoding fallback), so we can use it for + // mention gating and self-message filtering. botUserName is only used for + // logging and is non-critical. + botUserId = applicationId; + runtime.warn?.( + `discord: using applicationId as botUserId fallback (fetchUser failed: ${String(err)})`, + ); + } + if (!botUserId) { + // fetchUser succeeded but returned no id and applicationId is also missing — + // this should not happen in practice. + throw new Error("discord: fetchUser('@me') returned no user id"); } if (voiceEnabled) {