diff --git a/CHANGELOG.md b/CHANGELOG.md index b59b0bf8150..4c416fda801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. ### Fixes diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 37023da6407..8b96ad17ddd 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -482,6 +482,30 @@ Default gate behavior: | moderation | disabled | | presence | disabled | +## Components v2 UI + +OpenClaw uses Discord components v2 for exec approvals and cross-context markers. Discord message actions can also accept `components` for custom UI (advanced; requires Carbon component instances), while legacy `embeds` remain available but are not recommended. + +- `channels.discord.ui.components.accentColor` sets the accent color used by Discord component containers (hex). +- Set per account with `channels.discord.accounts..ui.components.accentColor`. +- `embeds` are ignored when components v2 are present. + +Example: + +```json5 +{ + channels: { + discord: { + ui: { + components: { + accentColor: "#5865F2", + }, + }, + }, + }, +} +``` + ## Voice messages Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files. @@ -574,6 +598,7 @@ High-signal Discord fields: - media/retry: `mediaMaxMb`, `retry` - actions: `actions.*` - presence: `activity`, `status`, `activityType`, `activityUrl` +- UI: `ui.components.accentColor` - features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix` ## Safety and operations diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 66124db9b84..4a02e4ff44f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -211,6 +211,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat textChunkLimit: 2000, chunkMode: "length", // length | newline maxLinesPerMessage: 17, + ui: { + components: { + accentColor: "#5865F2", + }, + }, retry: { attempts: 3, minDelayMs: 500, @@ -227,6 +232,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. - Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered). - `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars. +- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. **Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds..users` on all messages). diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md index 18411486488..dfedea1d88b 100644 --- a/skills/discord/SKILL.md +++ b/skills/discord/SKILL.md @@ -16,6 +16,12 @@ Use the `message` tool. No provider-specific `discord` tool exposed to the agent - Prefer explicit ids: `guildId`, `channelId`, `messageId`, `userId`. - Multi-account: optional `accountId`. +## Guidelines + +- Avoid Markdown tables in outbound Discord messages. +- Mention users as `<@USER_ID>`. +- Prefer Discord components v2 (`components`) for rich UI; use legacy `embeds` only when you must. + ## Targets - Send-like actions: `to: "channel:"` or `to: "user:"`. @@ -47,6 +53,37 @@ Send with media: } ``` +- Optional `silent: true` to suppress Discord notifications. + +Send with components v2 (recommended for rich UI): + +```json +{ + "action": "send", + "channel": "discord", + "to": "channel:123", + "message": "Status update", + "components": "[Carbon v2 components]" +} +``` + +- `components` expects Carbon component instances (Container, TextDisplay, etc.) from JS/TS integrations. +- Do not combine `components` with `embeds` (Discord rejects v2 + embeds). + +Legacy embeds (not recommended): + +```json +{ + "action": "send", + "channel": "discord", + "to": "channel:123", + "message": "Status update", + "embeds": [{ "title": "Legacy", "description": "Embeds are legacy." }] +} +``` + +- `embeds` are ignored when components v2 are present. + React: ```json @@ -157,4 +194,4 @@ Presence (often gated): - Short, conversational, low ceremony. - No markdown tables. -- Prefer multiple small replies over one wall of text. +- Mention users as `<@USER_ID>`. diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 54f5e970bf1..1097d48a00c 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,5 +1,6 @@ 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 { createThreadDiscord, deleteMessageDiscord, @@ -241,8 +242,15 @@ export async function handleDiscordMessagingAction( readStringParam(params, "path", { trim: false }) ?? readStringParam(params, "filePath", { trim: false }); const replyTo = readStringParam(params, "replyTo"); - const embeds = - Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined; + 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; // Handle voice message sending if (asVoice) { @@ -269,6 +277,7 @@ export async function handleDiscordMessagingAction( ...(accountId ? { accountId } : {}), mediaUrl, replyTo, + components, embeds, silent, }); diff --git a/src/channels/plugins/actions/discord.test.ts b/src/channels/plugins/actions/discord.test.ts index fc30a0a7566..640df817a1e 100644 --- a/src/channels/plugins/actions/discord.test.ts +++ b/src/channels/plugins/actions/discord.test.ts @@ -67,6 +67,31 @@ describe("handleDiscordMessageAction", () => { ); }); + it("forwards legacy embeds for send", async () => { + sendMessageDiscord.mockClear(); + const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); + + const embeds = [{ title: "Legacy", description: "Use components v2." }]; + + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + embeds, + }, + cfg: {} as OpenClawConfig, + }); + + expect(sendMessageDiscord).toHaveBeenCalledWith( + "channel:123", + "hi", + expect.objectContaining({ + embeds, + }), + ); + }); + it("falls back to params accountId when context missing", async () => { sendPollDiscord.mockClear(); diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index a5797440af9..87c5cb9abff 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -44,7 +44,13 @@ export async function handleDiscordMessageAction( readStringParam(params, "path", { trim: false }) ?? readStringParam(params, "filePath", { trim: false }); const replyTo = readStringParam(params, "replyTo"); - const embeds = Array.isArray(params.embeds) ? params.embeds : undefined; + const rawComponents = params.components; + const components = + Array.isArray(rawComponents) || typeof rawComponents === "function" + ? rawComponents + : undefined; + const rawEmbeds = params.embeds; + const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; const asVoice = params.asVoice === true; const silent = params.silent === true; return await handleDiscordAction( @@ -55,6 +61,7 @@ export async function handleDiscordMessageAction( content, mediaUrl: mediaUrl ?? undefined, replyTo: replyTo ?? undefined, + components, embeds, asVoice, silent, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 62a6d05e95b..2305da6f9e2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -373,6 +373,8 @@ export const FIELD_HELP: Record = { "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.ui.components.accentColor": + "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", "channels.discord.intents.presence": "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", "channels.discord.intents.guildMembers": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index c27ca6d5311..ed966d28c22 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -261,6 +261,7 @@ export const FIELD_LABELS: Record = { "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", "channels.discord.retry.jitter": "Discord Retry Jitter", "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.ui.components.accentColor": "Discord Component Accent Color", "channels.discord.intents.presence": "Discord Presence Intent", "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index b2f248459f4..c9ba2ed6f3d 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -113,6 +113,15 @@ export type DiscordAgentComponentsConfig = { enabled?: boolean; }; +export type DiscordUiComponentsConfig = { + /** Accent color used by Discord component containers (hex). */ + accentColor?: string; +}; + +export type DiscordUiConfig = { + components?: DiscordUiComponentsConfig; +}; + export type DiscordAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -183,6 +192,8 @@ export type DiscordAccountConfig = { execApprovals?: DiscordExecApprovalConfig; /** Agent-controlled interactive components (buttons, select menus). */ agentComponents?: DiscordAgentComponentsConfig; + /** Discord UI customization (components, modals, etc.). */ + ui?: DiscordUiConfig; /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */ intents?: DiscordIntentsConfig; /** PluralKit identity resolution for proxied messages. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f5866eadf0..eaca32ffa52 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -13,6 +13,7 @@ import { DmPolicySchema, ExecutableTokenSchema, GroupPolicySchema, + HexColorSchema, MarkdownConfigSchema, MSTeamsReplyStyleSchema, ProviderCommandsSchema, @@ -247,6 +248,18 @@ export const DiscordGuildSchema = z }) .strict(); +const DiscordUiSchema = z + .object({ + components: z + .object({ + accentColor: HexColorSchema.optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(); + export const DiscordAccountSchema = z .object({ name: z.string().optional(), @@ -312,6 +325,7 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + ui: DiscordUiSchema, intents: z .object({ presence: z.boolean().optional(), diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index 4ac2dc06e17..896e4a8c48a 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -1,5 +1,6 @@ import type { ButtonInteraction, ComponentData } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; +import fs from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; import { @@ -12,6 +13,16 @@ import { type ExecApprovalButtonContext, } from "./exec-approvals.js"; +const STORE_PATH = "/tmp/openclaw-exec-approvals-test.json"; + +const writeStore = (store: Record) => { + fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8"); +}; + +beforeEach(() => { + writeStore({}); +}); + // ─── Mocks ──────────────────────────────────────────────────────────────────── const mockRestPost = vi.hoisted(() => vi.fn()); @@ -50,12 +61,12 @@ vi.mock("../../logger.js", () => ({ // ─── Helpers ────────────────────────────────────────────────────────────────── -function createHandler(config: DiscordExecApprovalConfig) { +function createHandler(config: DiscordExecApprovalConfig, accountId = "default") { return new DiscordExecApprovalHandler({ token: "test-token", - accountId: "default", + accountId, config, - cfg: {}, + cfg: { session: { store: STORE_PATH } }, }); } @@ -281,6 +292,21 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => { ); }); + it("filters by discord account when session store includes account", () => { + writeStore({ + "agent:test-agent:discord:channel:999888777": { + sessionId: "sess", + updatedAt: Date.now(), + origin: { provider: "discord", accountId: "secondary" }, + lastAccountId: "secondary", + }, + }); + const handler = createHandler({ enabled: true, approvers: ["123"] }, "default"); + expect(handler.shouldHandle(createRequest())).toBe(false); + const matching = createHandler({ enabled: true, approvers: ["123"] }, "secondary"); + expect(matching.shouldHandle(createRequest())).toBe(true); + }); + it("combines agent and session filters", () => { const handler = createHandler({ enabled: true, @@ -618,7 +644,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => { Routes.channelMessages("dm-1"), expect.objectContaining({ body: expect.objectContaining({ - embeds: expect.any(Array), components: expect.any(Array), }), }), diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index f6fc1c24d9d..2cefb30e6be 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -1,4 +1,14 @@ -import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon"; +import { + Button, + Row, + Separator, + TextDisplay, + serializePayload, + type ButtonInteraction, + type ComponentData, + type MessagePayloadObject, + type TopLevelComponents, +} from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; import type { OpenClawConfig } from "../../config/config.js"; import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; @@ -9,11 +19,18 @@ import type { ExecApprovalResolved, } from "../../infra/exec-approvals.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { buildGatewayConnectionDetails } from "../../gateway/call.js"; import { GatewayClient } from "../../gateway/client.js"; import { logDebug, logError } from "../../logger.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; -import { createDiscordClient } from "../send.shared.js"; +import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + normalizeMessageChannel, +} from "../../utils/message-channel.js"; +import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; +import { DiscordUiContainer } from "../ui.js"; const EXEC_APPROVAL_KEY = "execapproval"; @@ -79,105 +96,209 @@ export function parseExecApprovalData( }; } -function formatExecApprovalEmbed(request: ExecApprovalRequest) { - const commandText = request.request.command; - const commandPreview = - commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText; - const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000)); +type ExecApprovalContainerParams = { + cfg: OpenClawConfig; + accountId: string; + title: string; + description?: string; + commandPreview: string; + metadataLines?: string[]; + actionRow?: Row