Discord: add per-button component allowlist

This commit is contained in:
Shadow
2026-02-16 15:14:36 -06:00
parent fc8290af42
commit c593709d25
6 changed files with 126 additions and 3 deletions

View File

@@ -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

View File

@@ -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://<filename>`)
@@ -126,7 +128,11 @@ Example:
{
type: "actions",
buttons: [
{ label: "Approve", style: "success" },
{
label: "Approve",
style: "success",
allowedUsers: ["123456789012345678"],
},
{ label: "Decline", style: "danger" },
],
},

View File

@@ -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({

View File

@@ -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,
},
};
}

View File

@@ -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<boolean> {
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,

View File

@@ -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: [],