From 05a83b9e97090b79b02cd64310b2a027607b2168 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 16 Feb 2026 14:22:31 -0600 Subject: [PATCH] Discord: add reusable component option --- CHANGELOG.md | 1 + docs/channels/discord.md | 3 ++ src/agents/tools/message-tool.ts | 33 ++++++++---- src/discord/components.ts | 7 +++ src/discord/monitor/agent-components.ts | 15 ++++-- src/discord/monitor/monitor.test.ts | 67 +++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5ed56e37..ceab3c3bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e232851454..f47291c761 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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://`) @@ -118,6 +120,7 @@ Example: to: "channel:123456789012345678", message: "Optional fallback text", components: { + reusable: true, text: "Choose a path", blocks: [ { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index be0d4f10f3..4e06d2547b 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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; diff --git a/src/discord/components.ts b/src/discord/components.ts index b45c28b210..b78b4511ce 100644 --- a/src/discord/components.ts +++ b/src/discord/components.ts @@ -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 = { diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 5e8c797881..d5be48dc7b 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -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({ diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index 33dc0a38eb..eb18d9d201 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -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", () => {