Discord: add reusable component option

This commit is contained in:
Shadow
2026-02-16 14:22:31 -06:00
parent fc60336c18
commit 05a83b9e97
6 changed files with 112 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan.
- 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.
### Fixes

View File

@@ -97,6 +97,8 @@ Supported blocks:
- Action rows allow up to 5 buttons or a single select menu
- Select types: `string`, `user`, `role`, `mentionable`, `channel`
By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire.
File attachments:
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
@@ -118,6 +120,7 @@ Example:
to: "channel:123456789012345678",
message: "Optional fallback text",
components: {
reusable: true,
text: "Choose a path",
blocks: [
{

View File

@@ -129,17 +129,28 @@ const discordComponentModalSchema = Type.Object({
fields: Type.Array(discordComponentModalFieldSchema),
});
const discordComponentMessageSchema = Type.Object({
text: Type.Optional(Type.String()),
container: Type.Optional(
Type.Object({
accentColor: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
}),
),
blocks: Type.Optional(Type.Array(discordComponentBlockSchema)),
modal: Type.Optional(discordComponentModalSchema),
});
const discordComponentMessageSchema = Type.Object(
{
text: Type.Optional(Type.String()),
reusable: Type.Optional(
Type.Boolean({
description: "Allow components to be used multiple times until they expire.",
}),
),
container: Type.Optional(
Type.Object({
accentColor: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
}),
),
blocks: Type.Optional(Type.Array(discordComponentBlockSchema)),
modal: Type.Optional(discordComponentModalSchema),
},
{
description:
"Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.",
},
);
function buildSendSchema(options: {
includeButtons: boolean;

View File

@@ -141,6 +141,7 @@ export type DiscordModalSpec = {
export type DiscordComponentMessageSpec = {
text?: string;
reusable?: boolean;
container?: {
accentColor?: string | number;
spoiler?: boolean;
@@ -159,6 +160,7 @@ export type DiscordComponentEntry = {
sessionKey?: string;
agentId?: string;
accountId?: string;
reusable?: boolean;
messageId?: string;
createdAt?: number;
expiresAt?: number;
@@ -187,6 +189,7 @@ export type DiscordModalEntry = {
sessionKey?: string;
agentId?: string;
accountId?: string;
reusable?: boolean;
messageId?: string;
createdAt?: number;
expiresAt?: number;
@@ -542,6 +545,7 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
: undefined;
const modalRaw = obj.modal;
const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined;
let modal: DiscordModalSpec | undefined;
if (modalRaw !== undefined) {
const modalObj = requireObject(modalRaw, "components.modal");
@@ -564,6 +568,7 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
}
return {
text: readOptionalString(obj.text),
reusable,
container:
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
? {
@@ -926,6 +931,7 @@ export function buildDiscordComponentMessage(params: {
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
reusable: entry.reusable ?? params.spec.reusable,
});
};
@@ -1023,6 +1029,7 @@ export function buildDiscordComponentMessage(params: {
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
reusable: params.spec.reusable,
});
const triggerSpec: DiscordComponentButtonSpec = {

View File

@@ -935,7 +935,10 @@ async function handleDiscordComponentEvent(params: {
return;
}
const consumed = resolveDiscordComponentEntry({ id: parsed.componentId });
const consumed = resolveDiscordComponentEntry({
id: parsed.componentId,
consume: !entry.reusable,
});
if (!consumed) {
try {
await params.interaction.reply({
@@ -1069,7 +1072,10 @@ async function handleDiscordModalTrigger(params: {
return;
}
const consumed = resolveDiscordComponentEntry({ id: parsed.componentId });
const consumed = resolveDiscordComponentEntry({
id: parsed.componentId,
consume: !entry.reusable,
});
if (!consumed) {
try {
await params.interaction.reply({
@@ -1501,7 +1507,10 @@ class DiscordComponentModal extends Modal {
return;
}
const consumed = resolveDiscordModalEntry({ id: modalId });
const consumed = resolveDiscordModalEntry({
id: modalId,
consume: !modalEntry.reusable,
});
if (!consumed) {
try {
await interaction.reply({

View File

@@ -291,6 +291,36 @@ describe("discord component interactions", () => {
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
it("keeps reusable buttons active after use", async () => {
registerDiscordComponentEntries({
entries: [
{
id: "btn_1",
kind: "button",
label: "Approve",
messageId: "msg-1",
sessionKey: "session-1",
agentId: "agent-1",
accountId: "default",
reusable: true,
},
],
modals: [],
});
const button = createDiscordComponentButton(createComponentContext());
const { interaction } = createComponentButtonInteraction();
await button.run(interaction, { cid: "btn_1" } as ComponentData);
const { interaction: secondInteraction } = createComponentButtonInteraction({
rawData: { channel_id: "dm-channel", id: "interaction-2" },
});
await button.run(secondInteraction, { cid: "btn_1" } as ComponentData);
expect(dispatchReplyMock).toHaveBeenCalledTimes(2);
expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull();
});
it("routes modal submissions with field values", async () => {
registerDiscordComponentEntries({
entries: [],
@@ -331,6 +361,43 @@ describe("discord component interactions", () => {
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
});
it("keeps reusable modal entries active after submission", async () => {
registerDiscordComponentEntries({
entries: [],
modals: [
{
id: "mdl_1",
title: "Details",
messageId: "msg-2",
sessionKey: "session-2",
agentId: "agent-2",
accountId: "default",
reusable: true,
fields: [
{
id: "fld_1",
name: "name",
label: "Name",
type: "text",
},
],
},
],
});
const modal = createDiscordComponentModal(
createComponentContext({
discordConfig: createDiscordConfig({ replyToMode: "all" }),
}),
);
const { interaction, acknowledge } = createModalInteraction();
await modal.run(interaction, { mid: "mdl_1" } as ComponentData);
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull();
});
});
describe("resolveDiscordOwnerAllowFrom", () => {