From c593709d252a1efe70a8ce40d40627a35b818e46 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 16 Feb 2026 15:14:36 -0600 Subject: [PATCH] Discord: add per-button component allowlist --- CHANGELOG.md | 1 + docs/channels/discord.md | 8 ++- src/agents/tools/message-tool.ts | 7 +++ src/discord/components.ts | 18 +++++++ src/discord/monitor/agent-components.ts | 72 ++++++++++++++++++++++++- src/discord/monitor/monitor.test.ts | 23 ++++++++ 6 files changed, 126 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceab3c3bf43..8a5248b5eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan. - Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus. - Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow. +- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow. ### Fixes diff --git a/docs/channels/discord.md b/docs/channels/discord.md index f47291c7619..cfe020b4e11 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -99,6 +99,8 @@ Supported blocks: By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire. +To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial. + File attachments: - `file` blocks must point to an attachment reference (`attachment://`) @@ -126,7 +128,11 @@ Example: { type: "actions", buttons: [ - { label: "Approve", style: "success" }, + { + label: "Approve", + style: "success", + allowedUsers: ["123456789012345678"], + }, { label: "Decline", style: "danger" }, ], }, diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4e06d2547b0..324c8aa48be 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -69,6 +69,13 @@ const discordComponentButtonSchema = Type.Object({ url: Type.Optional(Type.String()), emoji: Type.Optional(discordComponentEmojiSchema), disabled: Type.Optional(Type.Boolean()), + allowedUsers: Type.Optional( + Type.Array( + Type.String({ + description: "Discord user ids or names allowed to interact with this button.", + }), + ), + ), }); const discordComponentSelectSchema = Type.Object({ diff --git a/src/discord/components.ts b/src/discord/components.ts index b78b4511ce4..0c789a00593 100644 --- a/src/discord/components.ts +++ b/src/discord/components.ts @@ -52,6 +52,8 @@ export type DiscordComponentButtonSpec = { animated?: boolean; }; disabled?: boolean; + /** Optional allowlist of users who can interact with this button (ids or names). */ + allowedUsers?: string[]; }; export type DiscordComponentSelectOption = { @@ -161,6 +163,7 @@ export type DiscordComponentEntry = { agentId?: string; accountId?: string; reusable?: boolean; + allowedUsers?: string[]; messageId?: string; createdAt?: number; expiresAt?: number; @@ -236,6 +239,19 @@ function readOptionalString(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } +function readOptionalStringArray(value: unknown, label: string): string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array`); + } + if (value.length === 0) { + return undefined; + } + return value.map((entry, index) => readString(entry, `${label}[${index}]`)); +} + function readOptionalNumber(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; @@ -360,6 +376,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe } : undefined, disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined, + allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`), }; } @@ -698,6 +715,7 @@ function createButtonComponent(params: { kind: params.modalId ? "modal-trigger" : "button", label: params.spec.label, modalId: params.modalId, + allowedUsers: params.spec.allowedUsers, }, }; } diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index d5be48dc7b5..02580c1bcb6 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -292,6 +292,48 @@ async function ensureGuildComponentMemberAllowed(params: { return false; } +async function ensureComponentUserAllowed(params: { + entry: DiscordComponentEntry; + interaction: AgentComponentInteraction; + user: DiscordUser; + replyOpts: { ephemeral?: boolean }; + componentLabel: string; + unauthorizedReply: string; +}): Promise { + const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [ + "discord:", + "user:", + "pk:", + ]); + if (!allowList) { + return true; + } + const match = resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: params.user.id, + name: params.user.username, + tag: formatDiscordUserTag(params.user), + }, + }); + if (match.allowed) { + return true; + } + + logVerbose( + `discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`, + ); + try { + await params.interaction.reply({ + content: params.unauthorizedReply, + ...params.replyOpts, + }); + } catch { + // Interaction may have expired + } + return false; +} + async function ensureAgentComponentInteractionAllowed(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; @@ -919,6 +961,7 @@ async function handleDiscordComponentEvent(params: { guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); + const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`; const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction: params.interaction, guildInfo, @@ -929,12 +972,24 @@ async function handleDiscordComponentEvent(params: { user, replyOpts, componentLabel: params.componentLabel, - unauthorizedReply: `You are not authorized to use this ${params.componentLabel}.`, + unauthorizedReply, }); if (!memberAllowed) { return; } + const componentAllowed = await ensureComponentUserAllowed({ + entry, + interaction: params.interaction, + user, + replyOpts, + componentLabel: params.componentLabel, + unauthorizedReply, + }); + if (!componentAllowed) { + return; + } + const consumed = resolveDiscordComponentEntry({ id: parsed.componentId, consume: !entry.reusable, @@ -1056,6 +1111,7 @@ async function handleDiscordModalTrigger(params: { guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); + const unauthorizedReply = "You are not authorized to use this form."; const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction: params.interaction, guildInfo, @@ -1066,12 +1122,24 @@ async function handleDiscordModalTrigger(params: { user, replyOpts, componentLabel: "form", - unauthorizedReply: "You are not authorized to use this form.", + unauthorizedReply, }); if (!memberAllowed) { return; } + const componentAllowed = await ensureComponentUserAllowed({ + entry, + interaction: params.interaction, + user, + replyOpts, + componentLabel: "form", + unauthorizedReply, + }); + if (!componentAllowed) { + return; + } + const consumed = resolveDiscordComponentEntry({ id: parsed.componentId, consume: !entry.reusable, diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index eb18d9d201b..01f56f5d118 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -321,6 +321,29 @@ describe("discord component interactions", () => { expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); }); + it("blocks buttons when allowedUsers does not match", async () => { + registerDiscordComponentEntries({ + entries: [ + { + id: "btn_1", + kind: "button", + label: "Approve", + allowedUsers: ["999"], + }, + ], + modals: [], + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction, reply } = createComponentButtonInteraction(); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(reply).toHaveBeenCalledWith({ content: "You are not authorized to use this button." }); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); + }); + it("routes modal submissions with field values", async () => { registerDiscordComponentEntries({ entries: [],