mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Discord: add component v2 UI tool support (#17419)
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
|
||||
- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
|
||||
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
|
||||
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
|
||||
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
|
||||
|
||||
@@ -87,6 +87,77 @@ Token resolution is account-aware. Config token values win over env fallback. `D
|
||||
- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`).
|
||||
- Native slash commands run in isolated command sessions (`agent:<agentId>:discord:slash:<userId>`), while still carrying `CommandTargetSessionKey` to the routed conversation session.
|
||||
|
||||
## Interactive components
|
||||
|
||||
OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings.
|
||||
|
||||
Supported blocks:
|
||||
|
||||
- `text`, `section`, `separator`, `actions`, `media-gallery`, `file`
|
||||
- Action rows allow up to 5 buttons or a single select menu
|
||||
- Select types: `string`, `user`, `role`, `mentionable`, `channel`
|
||||
|
||||
File attachments:
|
||||
|
||||
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
|
||||
- Provide the attachment via `media`/`path`/`filePath` (single file); use `media-gallery` for multiple files
|
||||
- Use `filename` to override the upload name when it should match the attachment reference
|
||||
|
||||
Modal forms:
|
||||
|
||||
- Add `components.modal` with up to 5 fields
|
||||
- Field types: `text`, `checkbox`, `radio`, `select`, `role-select`, `user-select`
|
||||
- OpenClaw adds a trigger button automatically
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channel: "discord",
|
||||
action: "send",
|
||||
to: "channel:123456789012345678",
|
||||
message: "Optional fallback text",
|
||||
components: {
|
||||
text: "Choose a path",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [
|
||||
{ label: "Approve", style: "success" },
|
||||
{ label: "Decline", style: "danger" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
select: {
|
||||
type: "string",
|
||||
placeholder: "Pick an option",
|
||||
options: [
|
||||
{ label: "Option A", value: "a" },
|
||||
{ label: "Option B", value: "b" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
modal: {
|
||||
title: "Details",
|
||||
triggerLabel: "Open form",
|
||||
fields: [
|
||||
{ type: "text", label: "Requester" },
|
||||
{
|
||||
type: "select",
|
||||
label: "Priority",
|
||||
options: [
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "High", value: "high" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Access control and routing
|
||||
|
||||
<Tabs>
|
||||
|
||||
@@ -158,6 +158,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.",
|
||||
"- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.",
|
||||
],
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeDiscordMessagingTarget,
|
||||
targetResolver: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js";
|
||||
import { readDiscordComponentSpec } from "../../discord/components.js";
|
||||
import {
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
removeOwnReactionsDiscord,
|
||||
removeReactionDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendDiscordComponentMessage,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
@@ -233,24 +235,54 @@ export async function handleDiscordMessagingAction(
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const asVoice = params.asVoice === true;
|
||||
const silent = params.silent === true;
|
||||
const rawComponents = params.components;
|
||||
const componentSpec =
|
||||
rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents)
|
||||
? readDiscordComponentSpec(rawComponents)
|
||||
: null;
|
||||
const components: DiscordSendComponents | undefined =
|
||||
Array.isArray(rawComponents) || typeof rawComponents === "function"
|
||||
? (rawComponents as DiscordSendComponents)
|
||||
: undefined;
|
||||
const content = readStringParam(params, "content", {
|
||||
required: !asVoice,
|
||||
required: !asVoice && !componentSpec && !components,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl =
|
||||
readStringParam(params, "mediaUrl", { trim: false }) ??
|
||||
readStringParam(params, "path", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false });
|
||||
const filename = readStringParam(params, "filename");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const rawComponents = params.components;
|
||||
const components: DiscordSendComponents | undefined =
|
||||
Array.isArray(rawComponents) || typeof rawComponents === "function"
|
||||
? (rawComponents as DiscordSendComponents)
|
||||
: undefined;
|
||||
const rawEmbeds = params.embeds;
|
||||
const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds)
|
||||
? (rawEmbeds as DiscordSendEmbeds)
|
||||
: undefined;
|
||||
const sessionKey = readStringParam(params, "__sessionKey");
|
||||
const agentId = readStringParam(params, "__agentId");
|
||||
|
||||
if (componentSpec) {
|
||||
if (asVoice) {
|
||||
throw new Error("Discord components cannot be sent as voice messages.");
|
||||
}
|
||||
if (embeds?.length) {
|
||||
throw new Error("Discord components cannot include embeds.");
|
||||
}
|
||||
const normalizedContent = content?.trim() ? content : undefined;
|
||||
const payload = componentSpec.text
|
||||
? componentSpec
|
||||
: { ...componentSpec, text: normalizedContent };
|
||||
const result = await sendDiscordComponentMessage(to, payload, {
|
||||
...(accountId ? { accountId } : {}),
|
||||
silent,
|
||||
replyTo: replyTo ?? undefined,
|
||||
sessionKey: sessionKey ?? undefined,
|
||||
agentId: agentId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
filename: filename ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, result, components: true });
|
||||
}
|
||||
|
||||
// Handle voice message sending
|
||||
if (asVoice) {
|
||||
|
||||
@@ -47,6 +47,98 @@ function buildRoutingSchema() {
|
||||
};
|
||||
}
|
||||
|
||||
const discordComponentEmojiSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
id: Type.Optional(Type.String()),
|
||||
animated: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const discordComponentOptionSchema = Type.Object({
|
||||
label: Type.String(),
|
||||
value: Type.String(),
|
||||
description: Type.Optional(Type.String()),
|
||||
emoji: Type.Optional(discordComponentEmojiSchema),
|
||||
default: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const discordComponentButtonSchema = Type.Object({
|
||||
label: Type.String(),
|
||||
style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])),
|
||||
url: Type.Optional(Type.String()),
|
||||
emoji: Type.Optional(discordComponentEmojiSchema),
|
||||
disabled: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const discordComponentSelectSchema = Type.Object({
|
||||
type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])),
|
||||
placeholder: Type.Optional(Type.String()),
|
||||
minValues: Type.Optional(Type.Number()),
|
||||
maxValues: Type.Optional(Type.Number()),
|
||||
options: Type.Optional(Type.Array(discordComponentOptionSchema)),
|
||||
});
|
||||
|
||||
const discordComponentBlockSchema = Type.Object({
|
||||
type: Type.String(),
|
||||
text: Type.Optional(Type.String()),
|
||||
texts: Type.Optional(Type.Array(Type.String())),
|
||||
accessory: Type.Optional(
|
||||
Type.Object({
|
||||
type: Type.String(),
|
||||
url: Type.Optional(Type.String()),
|
||||
button: Type.Optional(discordComponentButtonSchema),
|
||||
}),
|
||||
),
|
||||
spacing: Type.Optional(stringEnum(["small", "large"])),
|
||||
divider: Type.Optional(Type.Boolean()),
|
||||
buttons: Type.Optional(Type.Array(discordComponentButtonSchema)),
|
||||
select: Type.Optional(discordComponentSelectSchema),
|
||||
items: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
url: Type.String(),
|
||||
description: Type.Optional(Type.String()),
|
||||
spoiler: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
file: Type.Optional(Type.String()),
|
||||
spoiler: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const discordComponentModalFieldSchema = Type.Object({
|
||||
type: Type.String(),
|
||||
name: Type.Optional(Type.String()),
|
||||
label: Type.String(),
|
||||
description: Type.Optional(Type.String()),
|
||||
placeholder: Type.Optional(Type.String()),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
options: Type.Optional(Type.Array(discordComponentOptionSchema)),
|
||||
minValues: Type.Optional(Type.Number()),
|
||||
maxValues: Type.Optional(Type.Number()),
|
||||
minLength: Type.Optional(Type.Number()),
|
||||
maxLength: Type.Optional(Type.Number()),
|
||||
style: Type.Optional(stringEnum(["short", "paragraph"])),
|
||||
});
|
||||
|
||||
const discordComponentModalSchema = Type.Object({
|
||||
title: Type.String(),
|
||||
triggerLabel: Type.Optional(Type.String()),
|
||||
triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])),
|
||||
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),
|
||||
});
|
||||
|
||||
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
|
||||
const props: Record<string, unknown> = {
|
||||
message: Type.Optional(Type.String()),
|
||||
@@ -105,6 +197,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
|
||||
},
|
||||
),
|
||||
),
|
||||
components: Type.Optional(discordComponentMessageSchema),
|
||||
};
|
||||
if (!options.includeButtons) {
|
||||
delete props.buttons;
|
||||
@@ -481,6 +574,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
defaultAccountId: accountId ?? undefined,
|
||||
gateway,
|
||||
toolContext,
|
||||
sessionKey: options?.agentSessionKey,
|
||||
agentId: options?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||
: undefined,
|
||||
|
||||
@@ -36,12 +36,12 @@ export async function handleDiscordMessageAction(
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const asVoice = params.asVoice === true;
|
||||
const rawComponents = params.components;
|
||||
const components =
|
||||
rawComponents && (Array.isArray(rawComponents) || typeof rawComponents === "function")
|
||||
? rawComponents
|
||||
: undefined;
|
||||
const hasComponents =
|
||||
Boolean(rawComponents) &&
|
||||
(typeof rawComponents === "function" || typeof rawComponents === "object");
|
||||
const components = hasComponents ? rawComponents : undefined;
|
||||
const content = readStringParam(params, "message", {
|
||||
required: !asVoice,
|
||||
required: !asVoice && !hasComponents,
|
||||
allowEmpty: true,
|
||||
});
|
||||
// Support media, path, and filePath for media URL
|
||||
@@ -49,10 +49,13 @@ export async function handleDiscordMessageAction(
|
||||
readStringParam(params, "media", { trim: false }) ??
|
||||
readStringParam(params, "path", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false });
|
||||
const filename = readStringParam(params, "filename");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const rawEmbeds = params.embeds;
|
||||
const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined;
|
||||
const silent = params.silent === true;
|
||||
const sessionKey = readStringParam(params, "__sessionKey");
|
||||
const agentId = readStringParam(params, "__agentId");
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
@@ -60,11 +63,14 @@ export async function handleDiscordMessageAction(
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
filename: filename ?? undefined,
|
||||
replyTo: replyTo ?? undefined,
|
||||
components,
|
||||
embeds,
|
||||
asVoice,
|
||||
silent,
|
||||
__sessionKey: sessionKey ?? undefined,
|
||||
__agentId: agentId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
89
src/discord/components-registry.ts
Normal file
89
src/discord/components-registry.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
|
||||
|
||||
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
const componentEntries = new Map<string, DiscordComponentEntry>();
|
||||
const modalEntries = new Map<string, DiscordModalEntry>();
|
||||
|
||||
function isExpired(entry: { expiresAt?: number }, now: number) {
|
||||
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
|
||||
}
|
||||
|
||||
function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: number }>(
|
||||
entry: T,
|
||||
now: number,
|
||||
ttlMs: number,
|
||||
): T {
|
||||
const createdAt = entry.createdAt ?? now;
|
||||
const expiresAt = entry.expiresAt ?? createdAt + ttlMs;
|
||||
return { ...entry, createdAt, expiresAt };
|
||||
}
|
||||
|
||||
export function registerDiscordComponentEntries(params: {
|
||||
entries: DiscordComponentEntry[];
|
||||
modals: DiscordModalEntry[];
|
||||
ttlMs?: number;
|
||||
messageId?: string;
|
||||
}): void {
|
||||
const now = Date.now();
|
||||
const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS;
|
||||
for (const entry of params.entries) {
|
||||
const normalized = normalizeEntryTimestamps(
|
||||
{ ...entry, messageId: params.messageId ?? entry.messageId },
|
||||
now,
|
||||
ttlMs,
|
||||
);
|
||||
componentEntries.set(entry.id, normalized);
|
||||
}
|
||||
for (const modal of params.modals) {
|
||||
const normalized = normalizeEntryTimestamps(
|
||||
{ ...modal, messageId: params.messageId ?? modal.messageId },
|
||||
now,
|
||||
ttlMs,
|
||||
);
|
||||
modalEntries.set(modal.id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDiscordComponentEntry(params: {
|
||||
id: string;
|
||||
consume?: boolean;
|
||||
}): DiscordComponentEntry | null {
|
||||
const entry = componentEntries.get(params.id);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (isExpired(entry, now)) {
|
||||
componentEntries.delete(params.id);
|
||||
return null;
|
||||
}
|
||||
if (params.consume !== false) {
|
||||
componentEntries.delete(params.id);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function resolveDiscordModalEntry(params: {
|
||||
id: string;
|
||||
consume?: boolean;
|
||||
}): DiscordModalEntry | null {
|
||||
const entry = modalEntries.get(params.id);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (isExpired(entry, now)) {
|
||||
modalEntries.delete(params.id);
|
||||
return null;
|
||||
}
|
||||
if (params.consume !== false) {
|
||||
modalEntries.delete(params.id);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function clearDiscordComponentEntries(): void {
|
||||
componentEntries.clear();
|
||||
modalEntries.clear();
|
||||
}
|
||||
98
src/discord/components.test.ts
Normal file
98
src/discord/components.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { MessageFlags } from "discord-api-types/v10";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
clearDiscordComponentEntries,
|
||||
registerDiscordComponentEntries,
|
||||
resolveDiscordComponentEntry,
|
||||
resolveDiscordModalEntry,
|
||||
} from "./components-registry.js";
|
||||
import {
|
||||
buildDiscordComponentMessage,
|
||||
buildDiscordComponentMessageFlags,
|
||||
readDiscordComponentSpec,
|
||||
} from "./components.js";
|
||||
|
||||
describe("discord components", () => {
|
||||
it("builds v2 containers with modal trigger", () => {
|
||||
const spec = readDiscordComponentSpec({
|
||||
text: "Choose a path",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Approve", style: "success" }],
|
||||
},
|
||||
],
|
||||
modal: {
|
||||
title: "Details",
|
||||
fields: [{ type: "text", label: "Requester" }],
|
||||
},
|
||||
});
|
||||
if (!spec) {
|
||||
throw new Error("Expected component spec to be parsed");
|
||||
}
|
||||
|
||||
const result = buildDiscordComponentMessage({ spec });
|
||||
expect(result.components).toHaveLength(1);
|
||||
expect(result.components[0]?.isV2).toBe(true);
|
||||
expect(buildDiscordComponentMessageFlags(result.components)).toBe(MessageFlags.IsComponentsV2);
|
||||
expect(result.modals).toHaveLength(1);
|
||||
|
||||
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
|
||||
expect(trigger?.modalId).toBe(result.modals[0]?.id);
|
||||
});
|
||||
|
||||
it("requires options for modal select fields", () => {
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
modal: {
|
||||
title: "Details",
|
||||
fields: [{ type: "select", label: "Priority" }],
|
||||
},
|
||||
}),
|
||||
).toThrow("options");
|
||||
});
|
||||
|
||||
it("requires attachment references for file blocks", () => {
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
blocks: [{ type: "file", file: "https://example.com/report.pdf" }],
|
||||
}),
|
||||
).toThrow("attachment://");
|
||||
expect(() =>
|
||||
readDiscordComponentSpec({
|
||||
blocks: [{ type: "file", file: "attachment://" }],
|
||||
}),
|
||||
).toThrow("filename");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discord component registry", () => {
|
||||
beforeEach(() => {
|
||||
clearDiscordComponentEntries();
|
||||
});
|
||||
|
||||
it("registers and consumes component entries", () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [{ id: "btn_1", kind: "button", label: "Confirm" }],
|
||||
modals: [
|
||||
{
|
||||
id: "mdl_1",
|
||||
title: "Details",
|
||||
fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }],
|
||||
},
|
||||
],
|
||||
messageId: "msg_1",
|
||||
ttlMs: 1000,
|
||||
});
|
||||
|
||||
const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false });
|
||||
expect(entry?.messageId).toBe("msg_1");
|
||||
|
||||
const modal = resolveDiscordModalEntry({ id: "mdl_1", consume: false });
|
||||
expect(modal?.messageId).toBe("msg_1");
|
||||
|
||||
const consumed = resolveDiscordComponentEntry({ id: "btn_1" });
|
||||
expect(consumed?.id).toBe("btn_1");
|
||||
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
1120
src/discord/components.ts
Normal file
1120
src/discord/components.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,28 @@
|
||||
import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon";
|
||||
import type {
|
||||
ButtonInteraction,
|
||||
ComponentData,
|
||||
ModalInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { Client } from "@buape/carbon";
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { DiscordAccountConfig } from "../../config/types.discord.js";
|
||||
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
|
||||
import {
|
||||
clearDiscordComponentEntries,
|
||||
registerDiscordComponentEntries,
|
||||
resolveDiscordComponentEntry,
|
||||
resolveDiscordModalEntry,
|
||||
} from "../components-registry.js";
|
||||
import {
|
||||
createAgentComponentButton,
|
||||
createAgentSelectMenu,
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentModal,
|
||||
} from "./agent-components.js";
|
||||
import {
|
||||
resolveDiscordMemberAllowed,
|
||||
resolveDiscordOwnerAllowFrom,
|
||||
@@ -29,6 +46,12 @@ import {
|
||||
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchReplyMock = vi.hoisted(() => vi.fn());
|
||||
const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
||||
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
let lastDispatchCtx: Record<string, unknown> | undefined;
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
@@ -43,6 +66,27 @@ vi.mock("../../infra/system-events.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-delivery.js", () => ({
|
||||
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/session.js", () => ({
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
|
||||
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("agent components", () => {
|
||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||
|
||||
@@ -128,6 +172,167 @@ describe("agent components", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("discord component interactions", () => {
|
||||
const createCfg = (): OpenClawConfig =>
|
||||
({
|
||||
channels: {
|
||||
discord: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
|
||||
const createDiscordConfig = (overrides?: Partial<DiscordAccountConfig>): DiscordAccountConfig =>
|
||||
({
|
||||
replyToMode: "first",
|
||||
...overrides,
|
||||
}) as DiscordAccountConfig;
|
||||
|
||||
type DispatchParams = {
|
||||
ctx: Record<string, unknown>;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text?: string }) => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
|
||||
const createComponentContext = (
|
||||
overrides?: Partial<Parameters<typeof createDiscordComponentButton>[0]>,
|
||||
) =>
|
||||
({
|
||||
cfg: createCfg(),
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123456789"],
|
||||
discordConfig: createDiscordConfig(),
|
||||
token: "token",
|
||||
...overrides,
|
||||
}) as Parameters<typeof createDiscordComponentButton>[0];
|
||||
|
||||
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const defer = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
rawData: { channel_id: "dm-channel", id: "interaction-1" },
|
||||
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
|
||||
customId: "occomp:cid=btn_1",
|
||||
message: { id: "msg-1" },
|
||||
client: { rest: {} },
|
||||
defer,
|
||||
reply,
|
||||
...overrides,
|
||||
} as unknown as ButtonInteraction;
|
||||
return { interaction, defer, reply };
|
||||
};
|
||||
|
||||
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const acknowledge = vi.fn().mockResolvedValue(undefined);
|
||||
const fields = {
|
||||
getText: (key: string) => (key === "fld_1" ? "Casey" : undefined),
|
||||
getStringSelect: (_key: string) => undefined,
|
||||
getRoleSelect: (_key: string) => [],
|
||||
getUserSelect: (_key: string) => [],
|
||||
};
|
||||
const interaction = {
|
||||
rawData: { channel_id: "dm-channel", id: "interaction-2" },
|
||||
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
|
||||
customId: "ocmodal:mid=mdl_1",
|
||||
fields,
|
||||
acknowledge,
|
||||
reply,
|
||||
client: { rest: {} },
|
||||
...overrides,
|
||||
} as unknown as ModalInteraction;
|
||||
return { interaction, acknowledge, reply };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearDiscordComponentEntries();
|
||||
lastDispatchCtx = undefined;
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
enqueueSystemEventMock.mockReset();
|
||||
dispatchReplyMock.mockReset().mockImplementation(async (params: DispatchParams) => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" });
|
||||
});
|
||||
deliverDiscordReplyMock.mockReset();
|
||||
recordInboundSessionMock.mockReset().mockResolvedValue(undefined);
|
||||
readSessionUpdatedAtMock.mockReset().mockReturnValue(undefined);
|
||||
resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
||||
});
|
||||
|
||||
it("routes button clicks with reply references", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "btn_1",
|
||||
kind: "button",
|
||||
label: "Approve",
|
||||
messageId: "msg-1",
|
||||
sessionKey: "session-1",
|
||||
agentId: "agent-1",
|
||||
accountId: "default",
|
||||
},
|
||||
],
|
||||
modals: [],
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction, reply } = createComponentButtonInteraction();
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓" });
|
||||
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1");
|
||||
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("routes modal submissions with field values", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [],
|
||||
modals: [
|
||||
{
|
||||
id: "mdl_1",
|
||||
title: "Details",
|
||||
messageId: "msg-2",
|
||||
sessionKey: "session-2",
|
||||
agentId: "agent-2",
|
||||
accountId: "default",
|
||||
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(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
|
||||
expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey");
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
|
||||
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordOwnerAllowFrom", () => {
|
||||
it("returns undefined when no allowlist is configured", () => {
|
||||
const result = resolveDiscordOwnerAllowFrom({
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { Client, ReadyListener, type BaseMessageInteractiveComponent } from "@buape/carbon";
|
||||
import {
|
||||
Client,
|
||||
ReadyListener,
|
||||
type BaseMessageInteractiveComponent,
|
||||
type Modal,
|
||||
} from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { inspect } from "node:util";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
@@ -33,7 +38,17 @@ import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
|
||||
import {
|
||||
createAgentComponentButton,
|
||||
createAgentSelectMenu,
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentChannelSelect,
|
||||
createDiscordComponentMentionableSelect,
|
||||
createDiscordComponentModal,
|
||||
createDiscordComponentRoleSelect,
|
||||
createDiscordComponentStringSelect,
|
||||
createDiscordComponentUserSelect,
|
||||
} from "./agent-components.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
@@ -432,30 +447,32 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
if (agentComponentsEnabled) {
|
||||
components.push(
|
||||
createAgentComponentButton({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
}),
|
||||
);
|
||||
components.push(
|
||||
createAgentSelectMenu({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
}),
|
||||
);
|
||||
const componentContext = {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
runtime,
|
||||
token,
|
||||
};
|
||||
components.push(createAgentComponentButton(componentContext));
|
||||
components.push(createAgentSelectMenu(componentContext));
|
||||
components.push(createDiscordComponentButton(componentContext));
|
||||
components.push(createDiscordComponentStringSelect(componentContext));
|
||||
components.push(createDiscordComponentUserSelect(componentContext));
|
||||
components.push(createDiscordComponentRoleSelect(componentContext));
|
||||
components.push(createDiscordComponentMentionableSelect(componentContext));
|
||||
components.push(createDiscordComponentChannelSelect(componentContext));
|
||||
modals.push(createDiscordComponentModal(componentContext));
|
||||
}
|
||||
|
||||
class DiscordStatusReadyListener extends ReadyListener {
|
||||
@@ -487,6 +504,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
commands,
|
||||
listeners: [new DiscordStatusReadyListener()],
|
||||
components,
|
||||
modals,
|
||||
},
|
||||
[createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime })],
|
||||
);
|
||||
|
||||
169
src/discord/send.components.ts
Normal file
169
src/discord/send.components.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { APIChannel } from "discord-api-types/v10";
|
||||
import {
|
||||
serializePayload,
|
||||
type MessagePayloadFile,
|
||||
type MessagePayloadObject,
|
||||
type RequestClient,
|
||||
} from "@buape/carbon";
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import type { DiscordSendResult } from "./send.types.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { registerDiscordComponentEntries } from "./components-registry.js";
|
||||
import {
|
||||
buildDiscordComponentMessage,
|
||||
buildDiscordComponentMessageFlags,
|
||||
resolveDiscordComponentAttachmentName,
|
||||
type DiscordComponentMessageSpec,
|
||||
} from "./components.js";
|
||||
import {
|
||||
buildDiscordSendError,
|
||||
createDiscordClient,
|
||||
parseAndResolveRecipient,
|
||||
resolveChannelId,
|
||||
stripUndefinedFields,
|
||||
SUPPRESS_NOTIFICATIONS_FLAG,
|
||||
} from "./send.shared.js";
|
||||
|
||||
const DISCORD_FORUM_LIKE_TYPES = new Set<number>([ChannelType.GuildForum, ChannelType.GuildMedia]);
|
||||
|
||||
function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] {
|
||||
const names: string[] = [];
|
||||
for (const block of spec.blocks ?? []) {
|
||||
if (block.type === "file") {
|
||||
names.push(resolveDiscordComponentAttachmentName(block.file));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
type DiscordComponentSendOpts = {
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
silent?: boolean;
|
||||
replyTo?: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export async function sendDiscordComponentMessage(
|
||||
to: string,
|
||||
spec: DiscordComponentMessageSpec,
|
||||
opts: DiscordComponentSendOpts = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
|
||||
let channelType: number | undefined;
|
||||
try {
|
||||
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
|
||||
channelType = channel?.type;
|
||||
} catch {
|
||||
channelType = undefined;
|
||||
}
|
||||
|
||||
if (channelType && DISCORD_FORUM_LIKE_TYPES.has(channelType)) {
|
||||
throw new Error("Discord components are not supported in forum-style channels");
|
||||
}
|
||||
|
||||
const buildResult = buildDiscordComponentMessage({
|
||||
spec,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const flags = buildDiscordComponentMessageFlags(buildResult.components);
|
||||
const finalFlags = opts.silent
|
||||
? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG
|
||||
: (flags ?? undefined);
|
||||
const messageReference = opts.replyTo
|
||||
? { message_id: opts.replyTo, fail_if_not_exists: false }
|
||||
: undefined;
|
||||
|
||||
const attachmentNames = extractComponentAttachmentNames(spec);
|
||||
const uniqueAttachmentNames = [...new Set(attachmentNames)];
|
||||
if (uniqueAttachmentNames.length > 1) {
|
||||
throw new Error(
|
||||
"Discord component attachments currently support a single file. Use media-gallery for multiple files.",
|
||||
);
|
||||
}
|
||||
const expectedAttachmentName = uniqueAttachmentNames[0];
|
||||
let files: MessagePayloadFile[] | undefined;
|
||||
if (opts.mediaUrl) {
|
||||
const media = await loadWebMedia(opts.mediaUrl, { localRoots: opts.mediaLocalRoots });
|
||||
const filenameOverride = opts.filename?.trim();
|
||||
const fileName = filenameOverride || media.fileName || "upload";
|
||||
if (expectedAttachmentName && expectedAttachmentName !== fileName) {
|
||||
throw new Error(
|
||||
`Component file block expects attachment "${expectedAttachmentName}", but the uploaded file is "${fileName}". Update components.blocks[].file or provide a matching filename.`,
|
||||
);
|
||||
}
|
||||
let fileData: Blob;
|
||||
if (media.buffer instanceof Blob) {
|
||||
fileData = media.buffer;
|
||||
} else {
|
||||
const arrayBuffer = new ArrayBuffer(media.buffer.byteLength);
|
||||
new Uint8Array(arrayBuffer).set(media.buffer);
|
||||
fileData = new Blob([arrayBuffer]);
|
||||
}
|
||||
files = [{ data: fileData, name: fileName }];
|
||||
} else if (expectedAttachmentName) {
|
||||
throw new Error(
|
||||
"Discord component file blocks require a media attachment (media/path/filePath).",
|
||||
);
|
||||
}
|
||||
|
||||
const payload: MessagePayloadObject = {
|
||||
components: buildResult.components,
|
||||
...(finalFlags ? { flags: finalFlags } : {}),
|
||||
...(files ? { files } : {}),
|
||||
};
|
||||
const body = stripUndefinedFields({
|
||||
...serializePayload(payload),
|
||||
...(messageReference ? { message_reference: messageReference } : {}),
|
||||
});
|
||||
|
||||
let result: { id: string; channel_id: string };
|
||||
try {
|
||||
result = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body,
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"components",
|
||||
)) as { id: string; channel_id: string };
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(files?.length),
|
||||
});
|
||||
}
|
||||
|
||||
registerDiscordComponentEntries({
|
||||
entries: buildResult.entries,
|
||||
modals: buildResult.modals,
|
||||
messageId: result.id,
|
||||
});
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: result.id ?? "unknown",
|
||||
channelId: result.channel_id ?? channelId,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export {
|
||||
sendStickerDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
} from "./send.outbound.js";
|
||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||
export {
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchReactionsDiscord,
|
||||
|
||||
@@ -395,10 +395,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
readStringParam(params, "path", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false });
|
||||
const hasCard = params.card != null && typeof params.card === "object";
|
||||
const hasComponents = params.components != null && typeof params.components === "object";
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
||||
let message =
|
||||
readStringParam(params, "message", {
|
||||
required: !mediaHint && !hasCard,
|
||||
required: !mediaHint && !hasCard && !hasComponents,
|
||||
allowEmpty: true,
|
||||
}) ?? "";
|
||||
if (message.includes("\\n")) {
|
||||
@@ -464,7 +465,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
message = "";
|
||||
}
|
||||
}
|
||||
if (!message.trim() && !mediaUrl && mergedMediaUrls.length === 0 && !hasCard) {
|
||||
if (!message.trim() && !mediaUrl && mergedMediaUrls.length === 0 && !hasCard && !hasComponents) {
|
||||
throw new Error("send requires text or media");
|
||||
}
|
||||
params.message = message;
|
||||
@@ -501,6 +502,12 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
route: outboundRoute,
|
||||
});
|
||||
}
|
||||
if (outboundRoute && !dryRun) {
|
||||
params.__sessionKey = outboundRoute.sessionKey;
|
||||
}
|
||||
if (agentId) {
|
||||
params.__agentId = agentId;
|
||||
}
|
||||
const mirrorMediaUrls =
|
||||
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
|
||||
throwIfAborted(abortSignal);
|
||||
|
||||
Reference in New Issue
Block a user