mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Discord: add reusable component option
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user