mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
Discord: add per-button component allowlist
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user