From 10afde99c144d50f571a78023a5c6aec6d8482af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 02:08:57 +0000 Subject: [PATCH] fix: harden discord guild allowlist resolution --- CHANGELOG.md | 1 + src/discord/monitor.test.ts | 12 +++ src/discord/monitor/agent-components.ts | 4 + src/discord/monitor/allow-list.ts | 15 ++-- src/discord/monitor/listeners.ts | 1 + .../message-handler.preflight.test-helpers.ts | 13 ++- .../monitor/message-handler.preflight.test.ts | 87 +++++++++++++++++++ .../monitor/message-handler.preflight.ts | 1 + src/discord/monitor/native-command.ts | 1 + src/discord/voice/command.ts | 1 + 10 files changed, 127 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466dbdc09d8..349f47cb41a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. - Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. - macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance. +- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks. ## 2026.3.12 diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 9471a3fe6bc..d3289155699 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -247,6 +247,18 @@ describe("discord guild/channel resolution", () => { expect(resolved?.slug).toBe("friends-of-openclaw"); }); + it("resolves guild entry by raw guild id when guild object is missing", () => { + const guildEntries = makeEntries({ + "123": { slug: "friends-of-openclaw" }, + }); + const resolved = resolveDiscordGuildEntry({ + guildId: "123", + guildEntries, + }); + expect(resolved?.id).toBe("123"); + expect(resolved?.slug).toBe("friends-of-openclaw"); + }); + it("resolves guild entry by slug key", () => { const guildEntries = makeEntries({ "friends-of-openclaw": { slug: "friends-of-openclaw" }, diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 56e7dfe3240..80239ea51d7 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -360,6 +360,7 @@ async function ensureAgentComponentInteractionAllowed(params: { }): Promise<{ parentId: string | undefined } | null> { const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: params.rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1094,6 +1095,7 @@ async function handleDiscordComponentEvent(params: { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1246,6 +1248,7 @@ async function handleDiscordModalTrigger(params: { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1696,6 +1699,7 @@ class DiscordComponentModal extends Modal { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: this.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(interaction); diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index ef29f1fc706..353ab8635be 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -321,25 +321,30 @@ export function resolveDiscordCommandAuthorized(params: { export function resolveDiscordGuildEntry(params: { guild?: Guild | Guild | null; + guildId?: string | null; guildEntries?: Record; }): DiscordGuildEntryResolved | null { const guild = params.guild; const entries = params.guildEntries; - if (!guild || !entries) { + const guildId = params.guildId?.trim() || guild?.id; + if (!entries) { return null; } - const byId = entries[guild.id]; + const byId = guildId ? entries[guildId] : undefined; if (byId) { - return { ...byId, id: guild.id }; + return { ...byId, id: guildId }; + } + if (!guild) { + return null; } const slug = normalizeDiscordSlug(guild.name ?? ""); const bySlug = entries[slug]; if (bySlug) { - return { ...bySlug, id: guild.id, slug: slug || bySlug.slug }; + return { ...bySlug, id: guildId ?? guild.id, slug: slug || bySlug.slug }; } const wildcard = entries["*"]; if (wildcard) { - return { ...wildcard, id: guild.id, slug: slug || wildcard.slug }; + return { ...wildcard, id: guildId ?? guild.id, slug: slug || wildcard.slug }; } return null; } diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 824cb5fb19a..ea6f7b3c628 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -430,6 +430,7 @@ async function handleDiscordReactionEvent( const guildInfo = isGuildMessage ? resolveDiscordGuildEntry({ guild: data.guild ?? undefined, + guildId: data.guild_id ?? undefined, guildEntries, }) : null; diff --git a/src/discord/monitor/message-handler.preflight.test-helpers.ts b/src/discord/monitor/message-handler.preflight.test-helpers.ts index 712aec7e187..147483171b0 100644 --- a/src/discord/monitor/message-handler.preflight.test-helpers.ts +++ b/src/discord/monitor/message-handler.preflight.test-helpers.ts @@ -34,14 +34,19 @@ export function createGuildEvent(params: { guildId: string; author: import("@buape/carbon").Message["author"]; message: import("@buape/carbon").Message; + includeGuildObject?: boolean; }): DiscordMessageEvent { return { channel_id: params.channelId, guild_id: params.guildId, - guild: { - id: params.guildId, - name: "Guild One", - }, + ...(params.includeGuildObject === false + ? {} + : { + guild: { + id: params.guildId, + name: "Guild One", + }, + }), author: params.author, message: params.message, } as unknown as DiscordMessageEvent; diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index c90c608e93b..e5ddfe158ef 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -138,6 +138,7 @@ async function runGuildPreflight(params: { discordConfig: DiscordConfig; cfg?: import("../../config/config.js").OpenClawConfig; guildEntries?: Parameters[0]["guildEntries"]; + includeGuildObject?: boolean; }) { return preflightDiscordMessage({ ...createPreflightArgs({ @@ -148,6 +149,7 @@ async function runGuildPreflight(params: { guildId: params.guildId, author: params.message.author, message: params.message, + includeGuildObject: params.includeGuildObject, }), client: createGuildTextClient(params.channelId), }), @@ -374,6 +376,91 @@ describe("preflightDiscordMessage", () => { expect(result).not.toBeNull(); }); + it("accepts allowlisted guild messages when guild object is missing", async () => { + const message = createDiscordMessage({ + id: "m-guild-id-only", + channelId: "ch-1", + content: "hello from maintainers", + author: { + id: "user-1", + bot: false, + username: "Peter", + }, + }); + + const result = await runGuildPreflight({ + channelId: "ch-1", + guildId: "guild-1", + message, + discordConfig: {} as DiscordConfig, + guildEntries: { + "guild-1": { + channels: { + "ch-1": { + allow: true, + requireMention: false, + }, + }, + }, + }, + includeGuildObject: false, + }); + + expect(result).not.toBeNull(); + expect(result?.guildInfo?.id).toBe("guild-1"); + expect(result?.channelConfig?.allowed).toBe(true); + expect(result?.shouldRequireMention).toBe(false); + }); + + it("inherits parent thread allowlist when guild object is missing", async () => { + const threadId = "thread-1"; + const parentId = "parent-1"; + const message = createDiscordMessage({ + id: "m-thread-id-only", + channelId: threadId, + content: "thread hello", + author: { + id: "user-1", + bot: false, + username: "Peter", + }, + }); + + const result = await preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: {} as DiscordConfig, + data: createGuildEvent({ + channelId: threadId, + guildId: "guild-1", + author: message.author, + message, + includeGuildObject: false, + }), + client: createThreadClient({ + threadId, + parentId, + }), + }), + guildEntries: { + "guild-1": { + channels: { + [parentId]: { + allow: true, + requireMention: false, + }, + }, + }, + }, + }); + + expect(result).not.toBeNull(); + expect(result?.guildInfo?.id).toBe("guild-1"); + expect(result?.threadParentId).toBe(parentId); + expect(result?.channelConfig?.allowed).toBe(true); + expect(result?.shouldRequireMention).toBe(false); + }); + it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index ddd79e42064..65bf6d85c46 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -430,6 +430,7 @@ export async function preflightDiscordMessage( const guildInfo = isGuildMessage ? resolveDiscordGuildEntry({ guild: params.data.guild ?? undefined, + guildId: params.data.guild_id ?? undefined, guildEntries: params.guildEntries, }) : null; diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 4af7d5ef6d3..51f3e3e6973 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -1355,6 +1355,7 @@ async function dispatchDiscordCommandInteraction(params: { }); const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, + guildId: interaction.guild?.id ?? undefined, guildEntries: discordConfig?.guilds, }); let threadParentId: string | undefined; diff --git a/src/discord/voice/command.ts b/src/discord/voice/command.ts index 835ba4d82f3..754a0f3622a 100644 --- a/src/discord/voice/command.ts +++ b/src/discord/voice/command.ts @@ -107,6 +107,7 @@ async function authorizeVoiceCommand( const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, + guildId: interaction.guild?.id ?? interaction.rawData.guild_id ?? undefined, guildEntries: params.discordConfig.guilds, });