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: 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.
|
- 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.
|
- 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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ Supported blocks:
|
|||||||
- Action rows allow up to 5 buttons or a single select menu
|
- Action rows allow up to 5 buttons or a single select menu
|
||||||
- Select types: `string`, `user`, `role`, `mentionable`, `channel`
|
- 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 attachments:
|
||||||
|
|
||||||
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
|
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
|
||||||
@@ -118,6 +120,7 @@ Example:
|
|||||||
to: "channel:123456789012345678",
|
to: "channel:123456789012345678",
|
||||||
message: "Optional fallback text",
|
message: "Optional fallback text",
|
||||||
components: {
|
components: {
|
||||||
|
reusable: true,
|
||||||
text: "Choose a path",
|
text: "Choose a path",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -129,17 +129,28 @@ const discordComponentModalSchema = Type.Object({
|
|||||||
fields: Type.Array(discordComponentModalFieldSchema),
|
fields: Type.Array(discordComponentModalFieldSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const discordComponentMessageSchema = Type.Object({
|
const discordComponentMessageSchema = Type.Object(
|
||||||
text: Type.Optional(Type.String()),
|
{
|
||||||
container: Type.Optional(
|
text: Type.Optional(Type.String()),
|
||||||
Type.Object({
|
reusable: Type.Optional(
|
||||||
accentColor: Type.Optional(Type.String()),
|
Type.Boolean({
|
||||||
spoiler: Type.Optional(Type.Boolean()),
|
description: "Allow components to be used multiple times until they expire.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
blocks: Type.Optional(Type.Array(discordComponentBlockSchema)),
|
container: Type.Optional(
|
||||||
modal: Type.Optional(discordComponentModalSchema),
|
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: {
|
function buildSendSchema(options: {
|
||||||
includeButtons: boolean;
|
includeButtons: boolean;
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export type DiscordModalSpec = {
|
|||||||
|
|
||||||
export type DiscordComponentMessageSpec = {
|
export type DiscordComponentMessageSpec = {
|
||||||
text?: string;
|
text?: string;
|
||||||
|
reusable?: boolean;
|
||||||
container?: {
|
container?: {
|
||||||
accentColor?: string | number;
|
accentColor?: string | number;
|
||||||
spoiler?: boolean;
|
spoiler?: boolean;
|
||||||
@@ -159,6 +160,7 @@ export type DiscordComponentEntry = {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
reusable?: boolean;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
@@ -187,6 +189,7 @@ export type DiscordModalEntry = {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
reusable?: boolean;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
@@ -542,6 +545,7 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
|
|||||||
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
|
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
|
||||||
: undefined;
|
: undefined;
|
||||||
const modalRaw = obj.modal;
|
const modalRaw = obj.modal;
|
||||||
|
const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined;
|
||||||
let modal: DiscordModalSpec | undefined;
|
let modal: DiscordModalSpec | undefined;
|
||||||
if (modalRaw !== undefined) {
|
if (modalRaw !== undefined) {
|
||||||
const modalObj = requireObject(modalRaw, "components.modal");
|
const modalObj = requireObject(modalRaw, "components.modal");
|
||||||
@@ -564,6 +568,7 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
text: readOptionalString(obj.text),
|
text: readOptionalString(obj.text),
|
||||||
|
reusable,
|
||||||
container:
|
container:
|
||||||
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
|
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
|
||||||
? {
|
? {
|
||||||
@@ -926,6 +931,7 @@ export function buildDiscordComponentMessage(params: {
|
|||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
reusable: entry.reusable ?? params.spec.reusable,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1023,6 +1029,7 @@ export function buildDiscordComponentMessage(params: {
|
|||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
reusable: params.spec.reusable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerSpec: DiscordComponentButtonSpec = {
|
const triggerSpec: DiscordComponentButtonSpec = {
|
||||||
|
|||||||
@@ -935,7 +935,10 @@ async function handleDiscordComponentEvent(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumed = resolveDiscordComponentEntry({ id: parsed.componentId });
|
const consumed = resolveDiscordComponentEntry({
|
||||||
|
id: parsed.componentId,
|
||||||
|
consume: !entry.reusable,
|
||||||
|
});
|
||||||
if (!consumed) {
|
if (!consumed) {
|
||||||
try {
|
try {
|
||||||
await params.interaction.reply({
|
await params.interaction.reply({
|
||||||
@@ -1069,7 +1072,10 @@ async function handleDiscordModalTrigger(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumed = resolveDiscordComponentEntry({ id: parsed.componentId });
|
const consumed = resolveDiscordComponentEntry({
|
||||||
|
id: parsed.componentId,
|
||||||
|
consume: !entry.reusable,
|
||||||
|
});
|
||||||
if (!consumed) {
|
if (!consumed) {
|
||||||
try {
|
try {
|
||||||
await params.interaction.reply({
|
await params.interaction.reply({
|
||||||
@@ -1501,7 +1507,10 @@ class DiscordComponentModal extends Modal {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumed = resolveDiscordModalEntry({ id: modalId });
|
const consumed = resolveDiscordModalEntry({
|
||||||
|
id: modalId,
|
||||||
|
consume: !modalEntry.reusable,
|
||||||
|
});
|
||||||
if (!consumed) {
|
if (!consumed) {
|
||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
|
|||||||
@@ -291,6 +291,36 @@ describe("discord component interactions", () => {
|
|||||||
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
|
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 () => {
|
it("routes modal submissions with field values", async () => {
|
||||||
registerDiscordComponentEntries({
|
registerDiscordComponentEntries({
|
||||||
entries: [],
|
entries: [],
|
||||||
@@ -331,6 +361,43 @@ describe("discord component interactions", () => {
|
|||||||
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
|
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
|
||||||
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
|
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", () => {
|
describe("resolveDiscordOwnerAllowFrom", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user