mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Discord: CV2! (#16364)
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Changes
|
### 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.
|
- 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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -482,6 +482,30 @@ Default gate behavior:
|
|||||||
| moderation | disabled |
|
| moderation | disabled |
|
||||||
| presence | 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.<id>.ui.components.accentColor`.
|
||||||
|
- `embeds` are ignored when components v2 are present.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
ui: {
|
||||||
|
components: {
|
||||||
|
accentColor: "#5865F2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Voice messages
|
## 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.
|
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`
|
- media/retry: `mediaMaxMb`, `retry`
|
||||||
- actions: `actions.*`
|
- actions: `actions.*`
|
||||||
- presence: `activity`, `status`, `activityType`, `activityUrl`
|
- presence: `activity`, `status`, `activityType`, `activityUrl`
|
||||||
|
- UI: `ui.components.accentColor`
|
||||||
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
|
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
|
||||||
|
|
||||||
## Safety and operations
|
## Safety and operations
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
|||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
chunkMode: "length", // length | newline
|
chunkMode: "length", // length | newline
|
||||||
maxLinesPerMessage: 17,
|
maxLinesPerMessage: 17,
|
||||||
|
ui: {
|
||||||
|
components: {
|
||||||
|
accentColor: "#5865F2",
|
||||||
|
},
|
||||||
|
},
|
||||||
retry: {
|
retry: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
minDelayMs: 500,
|
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.
|
- 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).
|
- 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.
|
- `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.<id>.users` on all messages).
|
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ Use the `message` tool. No provider-specific `discord` tool exposed to the agent
|
|||||||
- Prefer explicit ids: `guildId`, `channelId`, `messageId`, `userId`.
|
- Prefer explicit ids: `guildId`, `channelId`, `messageId`, `userId`.
|
||||||
- Multi-account: optional `accountId`.
|
- 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
|
## Targets
|
||||||
|
|
||||||
- Send-like actions: `to: "channel:<id>"` or `to: "user:<id>"`.
|
- Send-like actions: `to: "channel:<id>"` or `to: "user:<id>"`.
|
||||||
@@ -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:
|
React:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -157,4 +194,4 @@ Presence (often gated):
|
|||||||
|
|
||||||
- Short, conversational, low ceremony.
|
- Short, conversational, low ceremony.
|
||||||
- No markdown tables.
|
- No markdown tables.
|
||||||
- Prefer multiple small replies over one wall of text.
|
- Mention users as `<@USER_ID>`.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import type { DiscordActionConfig } from "../../config/config.js";
|
import type { DiscordActionConfig } from "../../config/config.js";
|
||||||
|
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js";
|
||||||
import {
|
import {
|
||||||
createThreadDiscord,
|
createThreadDiscord,
|
||||||
deleteMessageDiscord,
|
deleteMessageDiscord,
|
||||||
@@ -241,8 +242,15 @@ export async function handleDiscordMessagingAction(
|
|||||||
readStringParam(params, "path", { trim: false }) ??
|
readStringParam(params, "path", { trim: false }) ??
|
||||||
readStringParam(params, "filePath", { trim: false });
|
readStringParam(params, "filePath", { trim: false });
|
||||||
const replyTo = readStringParam(params, "replyTo");
|
const replyTo = readStringParam(params, "replyTo");
|
||||||
const embeds =
|
const rawComponents = params.components;
|
||||||
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
|
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
|
// Handle voice message sending
|
||||||
if (asVoice) {
|
if (asVoice) {
|
||||||
@@ -269,6 +277,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
...(accountId ? { accountId } : {}),
|
...(accountId ? { accountId } : {}),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyTo,
|
replyTo,
|
||||||
|
components,
|
||||||
embeds,
|
embeds,
|
||||||
silent,
|
silent,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
it("falls back to params accountId when context missing", async () => {
|
||||||
sendPollDiscord.mockClear();
|
sendPollDiscord.mockClear();
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ export async function handleDiscordMessageAction(
|
|||||||
readStringParam(params, "path", { trim: false }) ??
|
readStringParam(params, "path", { trim: false }) ??
|
||||||
readStringParam(params, "filePath", { trim: false });
|
readStringParam(params, "filePath", { trim: false });
|
||||||
const replyTo = readStringParam(params, "replyTo");
|
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 asVoice = params.asVoice === true;
|
||||||
const silent = params.silent === true;
|
const silent = params.silent === true;
|
||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
@@ -55,6 +61,7 @@ export async function handleDiscordMessageAction(
|
|||||||
content,
|
content,
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
replyTo: replyTo ?? undefined,
|
replyTo: replyTo ?? undefined,
|
||||||
|
components,
|
||||||
embeds,
|
embeds,
|
||||||
asVoice,
|
asVoice,
|
||||||
silent,
|
silent,
|
||||||
|
|||||||
@@ -373,6 +373,8 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.",
|
"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.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.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.<id>.ui.components.accentColor.",
|
||||||
"channels.discord.intents.presence":
|
"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.",
|
"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":
|
"channels.discord.intents.guildMembers":
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
|
"channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
|
||||||
"channels.discord.retry.jitter": "Discord Retry Jitter",
|
"channels.discord.retry.jitter": "Discord Retry Jitter",
|
||||||
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
|
"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.presence": "Discord Presence Intent",
|
||||||
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
|
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
|
||||||
"channels.discord.pluralkit.enabled": "Discord PluralKit Enabled",
|
"channels.discord.pluralkit.enabled": "Discord PluralKit Enabled",
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ export type DiscordAgentComponentsConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DiscordUiComponentsConfig = {
|
||||||
|
/** Accent color used by Discord component containers (hex). */
|
||||||
|
accentColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordUiConfig = {
|
||||||
|
components?: DiscordUiComponentsConfig;
|
||||||
|
};
|
||||||
|
|
||||||
export type DiscordAccountConfig = {
|
export type DiscordAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -183,6 +192,8 @@ export type DiscordAccountConfig = {
|
|||||||
execApprovals?: DiscordExecApprovalConfig;
|
execApprovals?: DiscordExecApprovalConfig;
|
||||||
/** Agent-controlled interactive components (buttons, select menus). */
|
/** Agent-controlled interactive components (buttons, select menus). */
|
||||||
agentComponents?: DiscordAgentComponentsConfig;
|
agentComponents?: DiscordAgentComponentsConfig;
|
||||||
|
/** Discord UI customization (components, modals, etc.). */
|
||||||
|
ui?: DiscordUiConfig;
|
||||||
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
|
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
|
||||||
intents?: DiscordIntentsConfig;
|
intents?: DiscordIntentsConfig;
|
||||||
/** PluralKit identity resolution for proxied messages. */
|
/** PluralKit identity resolution for proxied messages. */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
ExecutableTokenSchema,
|
ExecutableTokenSchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
|
HexColorSchema,
|
||||||
MarkdownConfigSchema,
|
MarkdownConfigSchema,
|
||||||
MSTeamsReplyStyleSchema,
|
MSTeamsReplyStyleSchema,
|
||||||
ProviderCommandsSchema,
|
ProviderCommandsSchema,
|
||||||
@@ -247,6 +248,18 @@ export const DiscordGuildSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const DiscordUiSchema = z
|
||||||
|
.object({
|
||||||
|
components: z
|
||||||
|
.object({
|
||||||
|
accentColor: HexColorSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const DiscordAccountSchema = z
|
export const DiscordAccountSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -312,6 +325,7 @@ export const DiscordAccountSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
ui: DiscordUiSchema,
|
||||||
intents: z
|
intents: z
|
||||||
.object({
|
.object({
|
||||||
presence: z.boolean().optional(),
|
presence: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
|
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
|
||||||
import { Routes } from "discord-api-types/v10";
|
import { Routes } from "discord-api-types/v10";
|
||||||
|
import fs from "node:fs";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,16 @@ import {
|
|||||||
type ExecApprovalButtonContext,
|
type ExecApprovalButtonContext,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
|
|
||||||
|
const STORE_PATH = "/tmp/openclaw-exec-approvals-test.json";
|
||||||
|
|
||||||
|
const writeStore = (store: Record<string, unknown>) => {
|
||||||
|
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
writeStore({});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockRestPost = vi.hoisted(() => vi.fn());
|
const mockRestPost = vi.hoisted(() => vi.fn());
|
||||||
@@ -50,12 +61,12 @@ vi.mock("../../logger.js", () => ({
|
|||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function createHandler(config: DiscordExecApprovalConfig) {
|
function createHandler(config: DiscordExecApprovalConfig, accountId = "default") {
|
||||||
return new DiscordExecApprovalHandler({
|
return new DiscordExecApprovalHandler({
|
||||||
token: "test-token",
|
token: "test-token",
|
||||||
accountId: "default",
|
accountId,
|
||||||
config,
|
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", () => {
|
it("combines agent and session filters", () => {
|
||||||
const handler = createHandler({
|
const handler = createHandler({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -618,7 +644,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
|
|||||||
Routes.channelMessages("dm-1"),
|
Routes.channelMessages("dm-1"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
body: expect.objectContaining({
|
body: expect.objectContaining({
|
||||||
embeds: expect.any(Array),
|
|
||||||
components: expect.any(Array),
|
components: expect.any(Array),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { ButtonStyle, Routes } from "discord-api-types/v10";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
||||||
@@ -9,11 +19,18 @@ import type {
|
|||||||
ExecApprovalResolved,
|
ExecApprovalResolved,
|
||||||
} from "../../infra/exec-approvals.js";
|
} from "../../infra/exec-approvals.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||||
import { buildGatewayConnectionDetails } from "../../gateway/call.js";
|
import { buildGatewayConnectionDetails } from "../../gateway/call.js";
|
||||||
import { GatewayClient } from "../../gateway/client.js";
|
import { GatewayClient } from "../../gateway/client.js";
|
||||||
import { logDebug, logError } from "../../logger.js";
|
import { logDebug, logError } from "../../logger.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||||
import { createDiscordClient } from "../send.shared.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";
|
const EXEC_APPROVAL_KEY = "execapproval";
|
||||||
|
|
||||||
@@ -79,105 +96,209 @@ export function parseExecApprovalData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExecApprovalEmbed(request: ExecApprovalRequest) {
|
type ExecApprovalContainerParams = {
|
||||||
const commandText = request.request.command;
|
cfg: OpenClawConfig;
|
||||||
const commandPreview =
|
accountId: string;
|
||||||
commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;
|
title: string;
|
||||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
|
description?: string;
|
||||||
|
commandPreview: string;
|
||||||
|
metadataLines?: string[];
|
||||||
|
actionRow?: Row<Button>;
|
||||||
|
footer?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const fields: Array<{ name: string; value: string; inline: boolean }> = [
|
class ExecApprovalContainer extends DiscordUiContainer {
|
||||||
{
|
constructor(params: ExecApprovalContainerParams) {
|
||||||
name: "Command",
|
const components: Array<TextDisplay | Separator | Row<Button>> = [
|
||||||
value: `\`\`\`\n${commandPreview}\n\`\`\``,
|
new TextDisplay(`## ${params.title}`),
|
||||||
inline: false,
|
];
|
||||||
},
|
if (params.description) {
|
||||||
];
|
components.push(new TextDisplay(params.description));
|
||||||
|
}
|
||||||
if (request.request.cwd) {
|
components.push(new Separator({ divider: true, spacing: "small" }));
|
||||||
fields.push({
|
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
|
||||||
name: "Working Directory",
|
if (params.metadataLines?.length) {
|
||||||
value: request.request.cwd,
|
components.push(new TextDisplay(params.metadataLines.join("\n")));
|
||||||
inline: true,
|
}
|
||||||
|
if (params.actionRow) {
|
||||||
|
components.push(params.actionRow);
|
||||||
|
}
|
||||||
|
if (params.footer) {
|
||||||
|
components.push(new Separator({ divider: false, spacing: "small" }));
|
||||||
|
components.push(new TextDisplay(`-# ${params.footer}`));
|
||||||
|
}
|
||||||
|
super({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
|
components,
|
||||||
|
accentColor: params.accentColor,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.request.host) {
|
|
||||||
fields.push({
|
|
||||||
name: "Host",
|
|
||||||
value: request.request.host,
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.request.agentId) {
|
|
||||||
fields.push({
|
|
||||||
name: "Agent",
|
|
||||||
value: request.request.agentId,
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: "Exec Approval Required",
|
|
||||||
description: "A command needs your approval.",
|
|
||||||
color: 0xffa500, // Orange
|
|
||||||
fields,
|
|
||||||
footer: { text: `Expires in ${expiresIn}s | ID: ${request.id}` },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatResolvedEmbed(
|
class ExecApprovalActionButton extends Button {
|
||||||
request: ExecApprovalRequest,
|
customId: string;
|
||||||
decision: ExecApprovalDecision,
|
label: string;
|
||||||
resolvedBy?: string | null,
|
style: ButtonStyle;
|
||||||
) {
|
|
||||||
const commandText = request.request.command;
|
constructor(params: {
|
||||||
|
approvalId: string;
|
||||||
|
action: ExecApprovalDecision;
|
||||||
|
label: string;
|
||||||
|
style: ButtonStyle;
|
||||||
|
}) {
|
||||||
|
super();
|
||||||
|
this.customId = buildExecApprovalCustomId(params.approvalId, params.action);
|
||||||
|
this.label = params.label;
|
||||||
|
this.style = params.style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExecApprovalActionRow extends Row<Button> {
|
||||||
|
constructor(approvalId: string) {
|
||||||
|
super([
|
||||||
|
new ExecApprovalActionButton({
|
||||||
|
approvalId,
|
||||||
|
action: "allow-once",
|
||||||
|
label: "Allow once",
|
||||||
|
style: ButtonStyle.Success,
|
||||||
|
}),
|
||||||
|
new ExecApprovalActionButton({
|
||||||
|
approvalId,
|
||||||
|
action: "allow-always",
|
||||||
|
label: "Always allow",
|
||||||
|
style: ButtonStyle.Primary,
|
||||||
|
}),
|
||||||
|
new ExecApprovalActionButton({
|
||||||
|
approvalId,
|
||||||
|
action: "deny",
|
||||||
|
label: "Deny",
|
||||||
|
style: ButtonStyle.Danger,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExecApprovalAccountId(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
request: ExecApprovalRequest;
|
||||||
|
}): string | null {
|
||||||
|
const sessionKey = params.request.request.sessionKey?.trim();
|
||||||
|
if (!sessionKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||||
|
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey];
|
||||||
|
const channel = normalizeMessageChannel(entry?.origin?.provider ?? entry?.lastChannel);
|
||||||
|
if (channel && channel !== "discord") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const accountId = entry?.origin?.accountId ?? entry?.lastAccountId;
|
||||||
|
return accountId?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (request.request.cwd) {
|
||||||
|
lines.push(`- Working Directory: ${request.request.cwd}`);
|
||||||
|
}
|
||||||
|
if (request.request.host) {
|
||||||
|
lines.push(`- Host: ${request.request.host}`);
|
||||||
|
}
|
||||||
|
if (request.request.agentId) {
|
||||||
|
lines.push(`- Agent: ${request.request.agentId}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
|
||||||
|
const components: TopLevelComponents[] = [container];
|
||||||
|
return { components };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecApprovalRequestContainer(params: {
|
||||||
|
request: ExecApprovalRequest;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId: string;
|
||||||
|
actionRow?: Row<Button>;
|
||||||
|
}): ExecApprovalContainer {
|
||||||
|
const commandText = params.request.request.command;
|
||||||
|
const commandPreview =
|
||||||
|
commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;
|
||||||
|
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
|
||||||
|
|
||||||
|
return new ExecApprovalContainer({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
|
title: "Exec Approval Required",
|
||||||
|
description: "A command needs your approval.",
|
||||||
|
commandPreview,
|
||||||
|
metadataLines: buildExecApprovalMetadataLines(params.request),
|
||||||
|
actionRow: params.actionRow,
|
||||||
|
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
|
||||||
|
accentColor: "#FFA500",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResolvedContainer(params: {
|
||||||
|
request: ExecApprovalRequest;
|
||||||
|
decision: ExecApprovalDecision;
|
||||||
|
resolvedBy?: string | null;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId: string;
|
||||||
|
}): ExecApprovalContainer {
|
||||||
|
const commandText = params.request.request.command;
|
||||||
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||||
|
|
||||||
const decisionLabel =
|
const decisionLabel =
|
||||||
decision === "allow-once"
|
params.decision === "allow-once"
|
||||||
? "Allowed (once)"
|
? "Allowed (once)"
|
||||||
: decision === "allow-always"
|
: params.decision === "allow-always"
|
||||||
? "Allowed (always)"
|
? "Allowed (always)"
|
||||||
: "Denied";
|
: "Denied";
|
||||||
|
|
||||||
const color = decision === "deny" ? 0xed4245 : decision === "allow-always" ? 0x5865f2 : 0x57f287;
|
const accentColor =
|
||||||
|
params.decision === "deny"
|
||||||
|
? "#ED4245"
|
||||||
|
: params.decision === "allow-always"
|
||||||
|
? "#5865F2"
|
||||||
|
: "#57F287";
|
||||||
|
|
||||||
return {
|
return new ExecApprovalContainer({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
title: `Exec Approval: ${decisionLabel}`,
|
title: `Exec Approval: ${decisionLabel}`,
|
||||||
description: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved",
|
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
|
||||||
color,
|
commandPreview,
|
||||||
fields: [
|
footer: `ID: ${params.request.id}`,
|
||||||
{
|
accentColor,
|
||||||
name: "Command",
|
});
|
||||||
value: `\`\`\`\n${commandPreview}\n\`\`\``,
|
|
||||||
inline: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
footer: { text: `ID: ${request.id}` },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExpiredEmbed(request: ExecApprovalRequest) {
|
function createExpiredContainer(params: {
|
||||||
const commandText = request.request.command;
|
request: ExecApprovalRequest;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId: string;
|
||||||
|
}): ExecApprovalContainer {
|
||||||
|
const commandText = params.request.request.command;
|
||||||
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||||
|
|
||||||
return {
|
return new ExecApprovalContainer({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
title: "Exec Approval: Expired",
|
title: "Exec Approval: Expired",
|
||||||
description: "This approval request has expired.",
|
description: "This approval request has expired.",
|
||||||
color: 0x99aab5, // Gray
|
commandPreview,
|
||||||
fields: [
|
footer: `ID: ${params.request.id}`,
|
||||||
{
|
accentColor: "#99AAB5",
|
||||||
name: "Command",
|
});
|
||||||
value: `\`\`\`\n${commandPreview}\n\`\`\``,
|
|
||||||
inline: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
footer: { text: `ID: ${request.id}` },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DiscordExecApprovalHandlerOpts = {
|
export type DiscordExecApprovalHandlerOpts = {
|
||||||
@@ -210,6 +331,17 @@ export class DiscordExecApprovalHandler {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestAccountId = resolveExecApprovalAccountId({
|
||||||
|
cfg: this.opts.cfg,
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
if (requestAccountId) {
|
||||||
|
const handlerAccountId = normalizeAccountId(this.opts.accountId);
|
||||||
|
if (normalizeAccountId(requestAccountId) !== handlerAccountId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check agent filter
|
// Check agent filter
|
||||||
if (config.agentFilter?.length) {
|
if (config.agentFilter?.length) {
|
||||||
if (!request.request.agentId) {
|
if (!request.request.agentId) {
|
||||||
@@ -329,34 +461,15 @@ export class DiscordExecApprovalHandler {
|
|||||||
this.opts.cfg,
|
this.opts.cfg,
|
||||||
);
|
);
|
||||||
|
|
||||||
const embed = formatExecApprovalEmbed(request);
|
const actionRow = new ExecApprovalActionRow(request.id);
|
||||||
|
const container = createExecApprovalRequestContainer({
|
||||||
// Build action rows with buttons
|
request,
|
||||||
const components = [
|
cfg: this.opts.cfg,
|
||||||
{
|
accountId: this.opts.accountId,
|
||||||
type: 1, // ACTION_ROW
|
actionRow,
|
||||||
components: [
|
});
|
||||||
{
|
const payload = buildExecApprovalPayload(container);
|
||||||
type: 2, // BUTTON
|
const body = stripUndefinedFields(serializePayload(payload));
|
||||||
style: ButtonStyle.Success,
|
|
||||||
label: "Allow once",
|
|
||||||
custom_id: buildExecApprovalCustomId(request.id, "allow-once"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 2, // BUTTON
|
|
||||||
style: ButtonStyle.Primary,
|
|
||||||
label: "Always allow",
|
|
||||||
custom_id: buildExecApprovalCustomId(request.id, "allow-always"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 2, // BUTTON
|
|
||||||
style: ButtonStyle.Danger,
|
|
||||||
label: "Deny",
|
|
||||||
custom_id: buildExecApprovalCustomId(request.id, "deny"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const target = this.opts.config.target ?? "dm";
|
const target = this.opts.config.target ?? "dm";
|
||||||
const sendToDm = target === "dm" || target === "both";
|
const sendToDm = target === "dm" || target === "both";
|
||||||
@@ -371,10 +484,7 @@ export class DiscordExecApprovalHandler {
|
|||||||
const message = (await discordRequest(
|
const message = (await discordRequest(
|
||||||
() =>
|
() =>
|
||||||
rest.post(Routes.channelMessages(channelId), {
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body,
|
||||||
embeds: [embed],
|
|
||||||
components,
|
|
||||||
},
|
|
||||||
}) as Promise<{ id: string; channel_id: string }>,
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
"send-approval-channel",
|
"send-approval-channel",
|
||||||
)) as { id: string; channel_id: string };
|
)) as { id: string; channel_id: string };
|
||||||
@@ -403,7 +513,7 @@ export class DiscordExecApprovalHandler {
|
|||||||
);
|
);
|
||||||
fallbackToDm = true;
|
fallbackToDm = true;
|
||||||
} else {
|
} else {
|
||||||
logDebug(`discord exec approvals: could not extract channel id from session key`);
|
logDebug("discord exec approvals: could not extract channel id from session key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,14 +539,11 @@ export class DiscordExecApprovalHandler {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send message with embed and buttons
|
// Send message with components v2 + buttons
|
||||||
const message = (await discordRequest(
|
const message = (await discordRequest(
|
||||||
() =>
|
() =>
|
||||||
rest.post(Routes.channelMessages(dmChannel.id), {
|
rest.post(Routes.channelMessages(dmChannel.id), {
|
||||||
body: {
|
body,
|
||||||
embeds: [embed],
|
|
||||||
components,
|
|
||||||
},
|
|
||||||
}) as Promise<{ id: string; channel_id: string }>,
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
"send-approval",
|
"send-approval",
|
||||||
)) as { id: string; channel_id: string };
|
)) as { id: string; channel_id: string };
|
||||||
@@ -483,7 +590,13 @@ export class DiscordExecApprovalHandler {
|
|||||||
|
|
||||||
logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
|
logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
|
||||||
|
|
||||||
const resolvedEmbed = formatResolvedEmbed(request, resolved.decision, resolved.resolvedBy);
|
const container = createResolvedContainer({
|
||||||
|
request,
|
||||||
|
decision: resolved.decision,
|
||||||
|
resolvedBy: resolved.resolvedBy,
|
||||||
|
cfg: this.opts.cfg,
|
||||||
|
accountId: this.opts.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
for (const suffix of [":channel", ":dm", ""]) {
|
for (const suffix of [":channel", ":dm", ""]) {
|
||||||
const key = `${resolved.id}${suffix}`;
|
const key = `${resolved.id}${suffix}`;
|
||||||
@@ -495,7 +608,7 @@ export class DiscordExecApprovalHandler {
|
|||||||
clearTimeout(pending.timeoutId);
|
clearTimeout(pending.timeoutId);
|
||||||
this.pending.delete(key);
|
this.pending.delete(key);
|
||||||
|
|
||||||
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, resolvedEmbed);
|
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,20 +641,21 @@ export class DiscordExecApprovalHandler {
|
|||||||
|
|
||||||
logDebug(`discord exec approvals: timeout for ${approvalId} (${source ?? "default"})`);
|
logDebug(`discord exec approvals: timeout for ${approvalId} (${source ?? "default"})`);
|
||||||
|
|
||||||
await this.finalizeMessage(
|
const container = createExpiredContainer({
|
||||||
pending.discordChannelId,
|
request,
|
||||||
pending.discordMessageId,
|
cfg: this.opts.cfg,
|
||||||
formatExpiredEmbed(request),
|
accountId: this.opts.accountId,
|
||||||
);
|
});
|
||||||
|
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finalizeMessage(
|
private async finalizeMessage(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
embed: ReturnType<typeof formatExpiredEmbed>,
|
container: DiscordUiContainer,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.opts.config.cleanupAfterResolve) {
|
if (!this.opts.config.cleanupAfterResolve) {
|
||||||
await this.updateMessage(channelId, messageId, embed);
|
await this.updateMessage(channelId, messageId, container);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,28 +671,26 @@ export class DiscordExecApprovalHandler {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`discord exec approvals: failed to delete message: ${String(err)}`);
|
logError(`discord exec approvals: failed to delete message: ${String(err)}`);
|
||||||
await this.updateMessage(channelId, messageId, embed);
|
await this.updateMessage(channelId, messageId, container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateMessage(
|
private async updateMessage(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
embed: ReturnType<typeof formatExpiredEmbed>,
|
container: DiscordUiContainer,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { rest, request: discordRequest } = createDiscordClient(
|
const { rest, request: discordRequest } = createDiscordClient(
|
||||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||||
this.opts.cfg,
|
this.opts.cfg,
|
||||||
);
|
);
|
||||||
|
const payload = buildExecApprovalPayload(container);
|
||||||
|
|
||||||
await discordRequest(
|
await discordRequest(
|
||||||
() =>
|
() =>
|
||||||
rest.patch(Routes.channelMessage(channelId, messageId), {
|
rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||||
body: {
|
body: stripUndefinedFields(serializePayload(payload)),
|
||||||
embeds: [embed],
|
|
||||||
components: [], // Remove buttons
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
"update-approval",
|
"update-approval",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RequestClient } from "@buape/carbon";
|
|
||||||
import type { APIChannel } from "discord-api-types/v10";
|
import type { APIChannel } from "discord-api-types/v10";
|
||||||
|
import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon";
|
||||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
@@ -18,6 +18,7 @@ import { extensionForMime } from "../media/mime.js";
|
|||||||
import { loadWebMediaRaw } from "../web/media.js";
|
import { loadWebMediaRaw } from "../web/media.js";
|
||||||
import { resolveDiscordAccount } from "./accounts.js";
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
|
buildDiscordMessagePayload,
|
||||||
buildDiscordSendError,
|
buildDiscordSendError,
|
||||||
buildDiscordTextChunks,
|
buildDiscordTextChunks,
|
||||||
createDiscordClient,
|
createDiscordClient,
|
||||||
@@ -25,9 +26,14 @@ import {
|
|||||||
normalizeStickerIds,
|
normalizeStickerIds,
|
||||||
parseAndResolveRecipient,
|
parseAndResolveRecipient,
|
||||||
resolveChannelId,
|
resolveChannelId,
|
||||||
|
resolveDiscordSendComponents,
|
||||||
|
resolveDiscordSendEmbeds,
|
||||||
sendDiscordMedia,
|
sendDiscordMedia,
|
||||||
sendDiscordText,
|
sendDiscordText,
|
||||||
|
stripUndefinedFields,
|
||||||
SUPPRESS_NOTIFICATIONS_FLAG,
|
SUPPRESS_NOTIFICATIONS_FLAG,
|
||||||
|
type DiscordSendComponents,
|
||||||
|
type DiscordSendEmbeds,
|
||||||
} from "./send.shared.js";
|
} from "./send.shared.js";
|
||||||
import {
|
import {
|
||||||
ensureOggOpus,
|
ensureOggOpus,
|
||||||
@@ -44,7 +50,8 @@ type DiscordSendOpts = {
|
|||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
retry?: RetryConfig;
|
retry?: RetryConfig;
|
||||||
embeds?: unknown[];
|
components?: DiscordSendComponents;
|
||||||
|
embeds?: DiscordSendEmbeds;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,7 +110,19 @@ export async function sendMessageDiscord(
|
|||||||
chunkMode,
|
chunkMode,
|
||||||
});
|
});
|
||||||
const starterContent = chunks[0]?.trim() ? chunks[0] : threadName;
|
const starterContent = chunks[0]?.trim() ? chunks[0] : threadName;
|
||||||
const starterEmbeds = opts.embeds?.length ? opts.embeds : undefined;
|
const starterComponents = resolveDiscordSendComponents({
|
||||||
|
components: opts.components,
|
||||||
|
text: starterContent,
|
||||||
|
isFirst: true,
|
||||||
|
});
|
||||||
|
const starterEmbeds = resolveDiscordSendEmbeds({ embeds: opts.embeds, isFirst: true });
|
||||||
|
const silentFlags = opts.silent ? 1 << 12 : undefined;
|
||||||
|
const starterPayload: MessagePayloadObject = buildDiscordMessagePayload({
|
||||||
|
text: starterContent,
|
||||||
|
components: starterComponents,
|
||||||
|
embeds: starterEmbeds,
|
||||||
|
flags: silentFlags,
|
||||||
|
});
|
||||||
let threadRes: { id: string; message?: { id: string; channel_id: string } };
|
let threadRes: { id: string; message?: { id: string; channel_id: string } };
|
||||||
try {
|
try {
|
||||||
threadRes = (await request(
|
threadRes = (await request(
|
||||||
@@ -111,10 +130,7 @@ export async function sendMessageDiscord(
|
|||||||
rest.post(Routes.threads(channelId), {
|
rest.post(Routes.threads(channelId), {
|
||||||
body: {
|
body: {
|
||||||
name: threadName,
|
name: threadName,
|
||||||
message: {
|
message: stripUndefinedFields(serializePayload(starterPayload)),
|
||||||
content: starterContent,
|
|
||||||
...(starterEmbeds ? { embeds: starterEmbeds } : {}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}) as Promise<{ id: string; message?: { id: string; channel_id: string } }>,
|
}) as Promise<{ id: string; message?: { id: string; channel_id: string } }>,
|
||||||
"forum-thread",
|
"forum-thread",
|
||||||
@@ -146,6 +162,7 @@ export async function sendMessageDiscord(
|
|||||||
request,
|
request,
|
||||||
accountInfo.config.maxLinesPerMessage,
|
accountInfo.config.maxLinesPerMessage,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
opts.silent,
|
opts.silent,
|
||||||
);
|
);
|
||||||
@@ -158,6 +175,7 @@ export async function sendMessageDiscord(
|
|||||||
request,
|
request,
|
||||||
accountInfo.config.maxLinesPerMessage,
|
accountInfo.config.maxLinesPerMessage,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
opts.silent,
|
opts.silent,
|
||||||
);
|
);
|
||||||
@@ -172,6 +190,7 @@ export async function sendMessageDiscord(
|
|||||||
request,
|
request,
|
||||||
accountInfo.config.maxLinesPerMessage,
|
accountInfo.config.maxLinesPerMessage,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
opts.silent,
|
opts.silent,
|
||||||
);
|
);
|
||||||
@@ -209,6 +228,7 @@ export async function sendMessageDiscord(
|
|||||||
opts.replyTo,
|
opts.replyTo,
|
||||||
request,
|
request,
|
||||||
accountInfo.config.maxLinesPerMessage,
|
accountInfo.config.maxLinesPerMessage,
|
||||||
|
opts.components,
|
||||||
opts.embeds,
|
opts.embeds,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
opts.silent,
|
opts.silent,
|
||||||
@@ -221,6 +241,7 @@ export async function sendMessageDiscord(
|
|||||||
opts.replyTo,
|
opts.replyTo,
|
||||||
request,
|
request,
|
||||||
accountInfo.config.maxLinesPerMessage,
|
accountInfo.config.maxLinesPerMessage,
|
||||||
|
opts.components,
|
||||||
opts.embeds,
|
opts.embeds,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
opts.silent,
|
opts.silent,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
||||||
import { RequestClient } from "@buape/carbon";
|
import {
|
||||||
|
Embed,
|
||||||
|
RequestClient,
|
||||||
|
serializePayload,
|
||||||
|
type MessagePayloadFile,
|
||||||
|
type MessagePayloadObject,
|
||||||
|
type TopLevelComponents,
|
||||||
|
} from "@buape/carbon";
|
||||||
import { PollLayoutType } from "discord-api-types/payloads/v10";
|
import { PollLayoutType } from "discord-api-types/payloads/v10";
|
||||||
import { Routes } from "discord-api-types/v10";
|
import { Routes, type APIEmbed } from "discord-api-types/v10";
|
||||||
import type { ChunkMode } from "../auto-reply/chunk.js";
|
import type { ChunkMode } from "../auto-reply/chunk.js";
|
||||||
import type { RetryRunner } from "../infra/retry-policy.js";
|
import type { RetryRunner } from "../infra/retry-policy.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
@@ -23,6 +30,10 @@ const DISCORD_CANNOT_DM = 50007;
|
|||||||
|
|
||||||
type DiscordRequest = RetryRunner;
|
type DiscordRequest = RetryRunner;
|
||||||
|
|
||||||
|
export type DiscordSendComponentFactory = (text: string) => TopLevelComponents[];
|
||||||
|
export type DiscordSendComponents = TopLevelComponents[] | DiscordSendComponentFactory;
|
||||||
|
export type DiscordSendEmbeds = Array<APIEmbed | Embed>;
|
||||||
|
|
||||||
type DiscordRecipient =
|
type DiscordRecipient =
|
||||||
| {
|
| {
|
||||||
kind: "user";
|
kind: "user";
|
||||||
@@ -252,6 +263,72 @@ export function buildDiscordTextChunks(
|
|||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasV2Components(components?: TopLevelComponents[]): boolean {
|
||||||
|
return Boolean(components?.some((component) => "isV2" in component && component.isV2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiscordSendComponents(params: {
|
||||||
|
components?: DiscordSendComponents;
|
||||||
|
text: string;
|
||||||
|
isFirst: boolean;
|
||||||
|
}): TopLevelComponents[] | undefined {
|
||||||
|
if (!params.components || !params.isFirst) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return typeof params.components === "function"
|
||||||
|
? params.components(params.text)
|
||||||
|
: params.components;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDiscordEmbeds(embeds?: DiscordSendEmbeds): Embed[] | undefined {
|
||||||
|
if (!embeds?.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return embeds.map((embed) => (embed instanceof Embed ? embed : new Embed(embed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiscordSendEmbeds(params: {
|
||||||
|
embeds?: DiscordSendEmbeds;
|
||||||
|
isFirst: boolean;
|
||||||
|
}): Embed[] | undefined {
|
||||||
|
if (!params.embeds || !params.isFirst) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return normalizeDiscordEmbeds(params.embeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDiscordMessagePayload(params: {
|
||||||
|
text: string;
|
||||||
|
components?: TopLevelComponents[];
|
||||||
|
embeds?: Embed[];
|
||||||
|
flags?: number;
|
||||||
|
files?: MessagePayloadFile[];
|
||||||
|
}): MessagePayloadObject {
|
||||||
|
const payload: MessagePayloadObject = {};
|
||||||
|
const hasV2 = hasV2Components(params.components);
|
||||||
|
const trimmed = params.text.trim();
|
||||||
|
if (!hasV2 && trimmed) {
|
||||||
|
payload.content = params.text;
|
||||||
|
}
|
||||||
|
if (params.components?.length) {
|
||||||
|
payload.components = params.components;
|
||||||
|
}
|
||||||
|
if (!hasV2 && params.embeds?.length) {
|
||||||
|
payload.embeds = params.embeds;
|
||||||
|
}
|
||||||
|
if (params.flags !== undefined) {
|
||||||
|
payload.flags = params.flags;
|
||||||
|
}
|
||||||
|
if (params.files?.length) {
|
||||||
|
payload.files = params.files;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripUndefinedFields<T extends object>(value: T): T {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendDiscordText(
|
async function sendDiscordText(
|
||||||
rest: RequestClient,
|
rest: RequestClient,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
@@ -259,7 +336,8 @@ async function sendDiscordText(
|
|||||||
replyTo: string | undefined,
|
replyTo: string | undefined,
|
||||||
request: DiscordRequest,
|
request: DiscordRequest,
|
||||||
maxLinesPerMessage?: number,
|
maxLinesPerMessage?: number,
|
||||||
embeds?: unknown[],
|
components?: DiscordSendComponents,
|
||||||
|
embeds?: DiscordSendEmbeds,
|
||||||
chunkMode?: ChunkMode,
|
chunkMode?: ChunkMode,
|
||||||
silent?: boolean,
|
silent?: boolean,
|
||||||
) {
|
) {
|
||||||
@@ -269,36 +347,38 @@ async function sendDiscordText(
|
|||||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||||
const flags = silent ? SUPPRESS_NOTIFICATIONS_FLAG : undefined;
|
const flags = silent ? SUPPRESS_NOTIFICATIONS_FLAG : undefined;
|
||||||
const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode });
|
const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode });
|
||||||
if (chunks.length === 1) {
|
const sendChunk = async (chunk: string, isFirst: boolean) => {
|
||||||
const res = (await request(
|
const chunkComponents = resolveDiscordSendComponents({
|
||||||
|
components,
|
||||||
|
text: chunk,
|
||||||
|
isFirst,
|
||||||
|
});
|
||||||
|
const chunkEmbeds = resolveDiscordSendEmbeds({ embeds, isFirst });
|
||||||
|
const payload = buildDiscordMessagePayload({
|
||||||
|
text: chunk,
|
||||||
|
components: chunkComponents,
|
||||||
|
embeds: chunkEmbeds,
|
||||||
|
flags,
|
||||||
|
});
|
||||||
|
const body = stripUndefinedFields({
|
||||||
|
...serializePayload(payload),
|
||||||
|
...(isFirst && messageReference ? { message_reference: messageReference } : {}),
|
||||||
|
});
|
||||||
|
return (await request(
|
||||||
() =>
|
() =>
|
||||||
rest.post(Routes.channelMessages(channelId), {
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body,
|
||||||
content: chunks[0],
|
|
||||||
message_reference: messageReference,
|
|
||||||
...(embeds?.length ? { embeds } : {}),
|
|
||||||
...(flags ? { flags } : {}),
|
|
||||||
},
|
|
||||||
}) as Promise<{ id: string; channel_id: string }>,
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
"text",
|
"text",
|
||||||
)) as { id: string; channel_id: string };
|
)) as { id: string; channel_id: string };
|
||||||
return res;
|
};
|
||||||
|
if (chunks.length === 1) {
|
||||||
|
return await sendChunk(chunks[0], true);
|
||||||
}
|
}
|
||||||
let last: { id: string; channel_id: string } | null = null;
|
let last: { id: string; channel_id: string } | null = null;
|
||||||
let isFirst = true;
|
let isFirst = true;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
last = (await request(
|
last = await sendChunk(chunk, isFirst);
|
||||||
() =>
|
|
||||||
rest.post(Routes.channelMessages(channelId), {
|
|
||||||
body: {
|
|
||||||
content: chunk,
|
|
||||||
message_reference: isFirst ? messageReference : undefined,
|
|
||||||
...(isFirst && embeds?.length ? { embeds } : {}),
|
|
||||||
...(flags ? { flags } : {}),
|
|
||||||
},
|
|
||||||
}) as Promise<{ id: string; channel_id: string }>,
|
|
||||||
"text",
|
|
||||||
)) as { id: string; channel_id: string };
|
|
||||||
isFirst = false;
|
isFirst = false;
|
||||||
}
|
}
|
||||||
if (!last) {
|
if (!last) {
|
||||||
@@ -316,34 +396,49 @@ async function sendDiscordMedia(
|
|||||||
replyTo: string | undefined,
|
replyTo: string | undefined,
|
||||||
request: DiscordRequest,
|
request: DiscordRequest,
|
||||||
maxLinesPerMessage?: number,
|
maxLinesPerMessage?: number,
|
||||||
embeds?: unknown[],
|
components?: DiscordSendComponents,
|
||||||
|
embeds?: DiscordSendEmbeds,
|
||||||
chunkMode?: ChunkMode,
|
chunkMode?: ChunkMode,
|
||||||
silent?: boolean,
|
silent?: boolean,
|
||||||
) {
|
) {
|
||||||
const media = await loadWebMedia(mediaUrl, { localRoots: mediaLocalRoots });
|
const media = await loadWebMedia(mediaUrl, { localRoots: mediaLocalRoots });
|
||||||
const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : [];
|
const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : [];
|
||||||
const caption = chunks[0] ?? "";
|
const caption = chunks[0] ?? "";
|
||||||
const hasCaption = caption.trim().length > 0;
|
|
||||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||||
const flags = silent ? SUPPRESS_NOTIFICATIONS_FLAG : undefined;
|
const flags = silent ? SUPPRESS_NOTIFICATIONS_FLAG : undefined;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
const captionComponents = resolveDiscordSendComponents({
|
||||||
|
components,
|
||||||
|
text: caption,
|
||||||
|
isFirst: true,
|
||||||
|
});
|
||||||
|
const captionEmbeds = resolveDiscordSendEmbeds({ embeds, isFirst: true });
|
||||||
|
const payload = buildDiscordMessagePayload({
|
||||||
|
text: caption,
|
||||||
|
components: captionComponents,
|
||||||
|
embeds: captionEmbeds,
|
||||||
|
flags,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
data: fileData,
|
||||||
|
name: media.fileName ?? "upload",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
const res = (await request(
|
const res = (await request(
|
||||||
() =>
|
() =>
|
||||||
rest.post(Routes.channelMessages(channelId), {
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body: stripUndefinedFields({
|
||||||
// Only include content when there is actual text; Discord rejects
|
...serializePayload(payload),
|
||||||
// media-only messages that carry an empty or undefined content field
|
|
||||||
// when sent as multipart/form-data. Preserve whitespace in captions.
|
|
||||||
...(hasCaption ? { content: caption } : {}),
|
|
||||||
...(messageReference ? { message_reference: messageReference } : {}),
|
...(messageReference ? { message_reference: messageReference } : {}),
|
||||||
...(embeds?.length ? { embeds } : {}),
|
}),
|
||||||
...(flags ? { flags } : {}),
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
data: media.buffer,
|
|
||||||
name: media.fileName ?? "upload",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}) as Promise<{ id: string; channel_id: string }>,
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
"media",
|
"media",
|
||||||
)) as { id: string; channel_id: string };
|
)) as { id: string; channel_id: string };
|
||||||
@@ -359,6 +454,7 @@ async function sendDiscordMedia(
|
|||||||
request,
|
request,
|
||||||
maxLinesPerMessage,
|
maxLinesPerMessage,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
silent,
|
silent,
|
||||||
);
|
);
|
||||||
|
|||||||
45
src/discord/ui.ts
Normal file
45
src/discord/ui.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Container } from "@buape/carbon";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
|
|
||||||
|
const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2";
|
||||||
|
|
||||||
|
type DiscordContainerComponents = ConstructorParameters<typeof Container>[0];
|
||||||
|
|
||||||
|
type ResolveDiscordAccentColorParams = {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeDiscordAccentColor(raw?: string | null): string | null {
|
||||||
|
const trimmed = (raw ?? "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
||||||
|
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiscordAccentColor(params: ResolveDiscordAccentColorParams): string {
|
||||||
|
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const configured = normalizeDiscordAccentColor(account.config.ui?.components?.accentColor);
|
||||||
|
return configured ?? DEFAULT_DISCORD_ACCENT_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiscordUiContainer extends Container {
|
||||||
|
constructor(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
components?: DiscordContainerComponents;
|
||||||
|
accentColor?: string;
|
||||||
|
spoiler?: boolean;
|
||||||
|
}) {
|
||||||
|
const accentOverride = normalizeDiscordAccentColor(params.accentColor);
|
||||||
|
const accentColor =
|
||||||
|
accentOverride ?? resolveDiscordAccentColor({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
super(params.components, { accentColor, spoiler: params.spoiler });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,34 @@ describe("exec approval forwarder", () => {
|
|||||||
expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```");
|
expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips discord forwarding when discord exec approvals target channel", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const deliver = vi.fn().mockResolvedValue([]);
|
||||||
|
const cfg = {
|
||||||
|
approvals: { exec: { enabled: true, mode: "session" } },
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
execApprovals: {
|
||||||
|
enabled: true,
|
||||||
|
target: "channel",
|
||||||
|
approvers: ["123"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const forwarder = createExecApprovalForwarder({
|
||||||
|
getConfig: () => cfg,
|
||||||
|
deliver,
|
||||||
|
nowMs: () => 1000,
|
||||||
|
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await forwarder.handleRequested(baseRequest);
|
||||||
|
|
||||||
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses a longer fence when command already contains triple backticks", async () => {
|
it("uses a longer fence when command already contains triple backticks", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const deliver = vi.fn().mockResolvedValue([]);
|
const deliver = vi.fn().mockResolvedValue([]);
|
||||||
|
|||||||
@@ -98,6 +98,15 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string {
|
|||||||
return [channel, target.to, accountId, threadId].join(":");
|
return [channel, target.to, accountId, threadId].join(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldSkipDiscordForwarding(cfg: OpenClawConfig): boolean {
|
||||||
|
const discordConfig = cfg.channels?.discord?.execApprovals;
|
||||||
|
if (!discordConfig?.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const target = discordConfig.target ?? "dm";
|
||||||
|
return target === "channel" || target === "both";
|
||||||
|
}
|
||||||
|
|
||||||
function formatApprovalCommand(command: string): { inline: boolean; text: string } {
|
function formatApprovalCommand(command: string): { inline: boolean; text: string } {
|
||||||
if (!command.includes("\n") && !command.includes("`")) {
|
if (!command.includes("\n") && !command.includes("`")) {
|
||||||
return { inline: true, text: `\`${command}\`` };
|
return { inline: true, text: `\`${command}\`` };
|
||||||
@@ -265,7 +274,11 @@ export function createExecApprovalForwarder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targets.length === 0) {
|
const filteredTargets = shouldSkipDiscordForwarding(cfg)
|
||||||
|
? targets.filter((target) => normalizeMessageChannel(target.channel) !== "discord")
|
||||||
|
: targets;
|
||||||
|
|
||||||
|
if (filteredTargets.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +296,7 @@ export function createExecApprovalForwarder(
|
|||||||
}, expiresInMs);
|
}, expiresInMs);
|
||||||
timeoutId.unref?.();
|
timeoutId.unref?.();
|
||||||
|
|
||||||
const pendingEntry: PendingApproval = { request, targets, timeoutId };
|
const pendingEntry: PendingApproval = { request, targets: filteredTargets, timeoutId };
|
||||||
pending.set(request.id, pendingEntry);
|
pending.set(request.id, pendingEntry);
|
||||||
|
|
||||||
if (pending.get(request.id) !== pendingEntry) {
|
if (pending.get(request.id) !== pendingEntry) {
|
||||||
@@ -293,7 +306,7 @@ export function createExecApprovalForwarder(
|
|||||||
const text = buildRequestMessage(request, nowMs());
|
const text = buildRequestMessage(request, nowMs());
|
||||||
await deliverToTargets({
|
await deliverToTargets({
|
||||||
cfg,
|
cfg,
|
||||||
targets,
|
targets: filteredTargets,
|
||||||
text,
|
text,
|
||||||
deliver,
|
deliver,
|
||||||
shouldSend: () => pending.get(request.id) === pendingEntry,
|
shouldSend: () => pending.get(request.id) === pendingEntry,
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
|
import { Separator, TextDisplay, type TopLevelComponents } from "@buape/carbon";
|
||||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import { DiscordUiContainer } from "../../discord/ui.js";
|
||||||
|
|
||||||
|
export type CrossContextComponentsBuilder = (message: string) => TopLevelComponents[];
|
||||||
|
|
||||||
|
export type CrossContextComponentsFactory = (params: {
|
||||||
|
originLabel: string;
|
||||||
|
message: string;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
}) => TopLevelComponents[];
|
||||||
|
|
||||||
export type ChannelMessageAdapter = {
|
export type ChannelMessageAdapter = {
|
||||||
supportsEmbeds: boolean;
|
supportsComponentsV2: boolean;
|
||||||
buildCrossContextEmbeds?: (originLabel: string) => unknown[];
|
buildCrossContextComponents?: CrossContextComponentsFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CrossContextContainerParams = {
|
||||||
|
originLabel: string;
|
||||||
|
message: string;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CrossContextContainer extends DiscordUiContainer {
|
||||||
|
constructor({ originLabel, message, cfg, accountId }: CrossContextContainerParams) {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
const components = [] as Array<TextDisplay | Separator>;
|
||||||
|
if (trimmed) {
|
||||||
|
components.push(new TextDisplay(message));
|
||||||
|
components.push(new Separator({ divider: true, spacing: "small" }));
|
||||||
|
}
|
||||||
|
components.push(new TextDisplay(`*From ${originLabel}*`));
|
||||||
|
super({ cfg, accountId, components });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_ADAPTER: ChannelMessageAdapter = {
|
const DEFAULT_ADAPTER: ChannelMessageAdapter = {
|
||||||
supportsEmbeds: false,
|
supportsComponentsV2: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DISCORD_ADAPTER: ChannelMessageAdapter = {
|
const DISCORD_ADAPTER: ChannelMessageAdapter = {
|
||||||
supportsEmbeds: true,
|
supportsComponentsV2: true,
|
||||||
buildCrossContextEmbeds: (originLabel: string) => [
|
buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => [
|
||||||
{
|
new CrossContextContainer({ originLabel, message, cfg, accountId }),
|
||||||
description: `From ${originLabel}`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -161,21 +161,21 @@ function applyCrossContextMessageDecoration({
|
|||||||
params,
|
params,
|
||||||
message,
|
message,
|
||||||
decoration,
|
decoration,
|
||||||
preferEmbeds,
|
preferComponents,
|
||||||
}: {
|
}: {
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
message: string;
|
message: string;
|
||||||
decoration: CrossContextDecoration;
|
decoration: CrossContextDecoration;
|
||||||
preferEmbeds: boolean;
|
preferComponents: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const applied = applyCrossContextDecoration({
|
const applied = applyCrossContextDecoration({
|
||||||
message,
|
message,
|
||||||
decoration,
|
decoration,
|
||||||
preferEmbeds,
|
preferComponents,
|
||||||
});
|
});
|
||||||
params.message = applied.message;
|
params.message = applied.message;
|
||||||
if (applied.embeds?.length) {
|
if (applied.componentsBuilder) {
|
||||||
params.embeds = applied.embeds;
|
params.components = applied.componentsBuilder;
|
||||||
}
|
}
|
||||||
return applied.message;
|
return applied.message;
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ async function maybeApplyCrossContextMarker(params: {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
message: string;
|
message: string;
|
||||||
preferEmbeds: boolean;
|
preferComponents: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
if (!shouldApplyCrossContextMarker(params.action) || !params.toolContext) {
|
if (!shouldApplyCrossContextMarker(params.action) || !params.toolContext) {
|
||||||
return params.message;
|
return params.message;
|
||||||
@@ -208,7 +208,7 @@ async function maybeApplyCrossContextMarker(params: {
|
|||||||
params: params.args,
|
params: params.args,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
decoration,
|
decoration,
|
||||||
preferEmbeds: params.preferEmbeds,
|
preferComponents: params.preferComponents,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
accountId,
|
accountId,
|
||||||
args: params,
|
args: params,
|
||||||
message,
|
message,
|
||||||
preferEmbeds: true,
|
preferComponents: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||||
@@ -601,7 +601,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
accountId,
|
accountId,
|
||||||
args: params,
|
args: params,
|
||||||
message: base,
|
message: base,
|
||||||
preferEmbeds: true,
|
preferComponents: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const poll = await executePollAction({
|
const poll = await executePollAction({
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ describe("outbound policy", () => {
|
|||||||
).toThrow(/Cross-context messaging denied/);
|
).toThrow(/Cross-context messaging denied/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses embeds when available and preferred", async () => {
|
it("uses components when available and preferred", async () => {
|
||||||
const decoration = await buildCrossContextDecoration({
|
const decoration = await buildCrossContextDecoration({
|
||||||
cfg: discordConfig,
|
cfg: discordConfig,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
@@ -82,11 +82,12 @@ describe("outbound policy", () => {
|
|||||||
const applied = applyCrossContextDecoration({
|
const applied = applyCrossContextDecoration({
|
||||||
message: "hello",
|
message: "hello",
|
||||||
decoration: decoration!,
|
decoration: decoration!,
|
||||||
preferEmbeds: true,
|
preferComponents: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(applied.usedEmbeds).toBe(true);
|
expect(applied.usedComponents).toBe(true);
|
||||||
expect(applied.embeds?.length).toBeGreaterThan(0);
|
expect(applied.componentsBuilder).toBeDefined();
|
||||||
|
expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0);
|
||||||
expect(applied.message).toBe("hello");
|
expect(applied.message).toBe("hello");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import type {
|
|||||||
ChannelThreadingToolContext,
|
ChannelThreadingToolContext,
|
||||||
} from "../../channels/plugins/types.js";
|
} from "../../channels/plugins/types.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { getChannelMessageAdapter } from "./channel-adapters.js";
|
import {
|
||||||
|
getChannelMessageAdapter,
|
||||||
|
type CrossContextComponentsBuilder,
|
||||||
|
} from "./channel-adapters.js";
|
||||||
import { normalizeTargetForProvider } from "./target-normalization.js";
|
import { normalizeTargetForProvider } from "./target-normalization.js";
|
||||||
import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
|
import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
|
||||||
|
|
||||||
export type CrossContextDecoration = {
|
export type CrossContextDecoration = {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
suffix: string;
|
suffix: string;
|
||||||
embeds?: unknown[];
|
componentsBuilder?: CrossContextComponentsBuilder;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTEXT_GUARDED_ACTIONS = new Set<ChannelMessageActionName>([
|
const CONTEXT_GUARDED_ACTIONS = new Set<ChannelMessageActionName>([
|
||||||
@@ -177,11 +180,19 @@ export async function buildCrossContextDecoration(params: {
|
|||||||
const suffix = suffixTemplate.replaceAll("{channel}", originLabel);
|
const suffix = suffixTemplate.replaceAll("{channel}", originLabel);
|
||||||
|
|
||||||
const adapter = getChannelMessageAdapter(params.channel);
|
const adapter = getChannelMessageAdapter(params.channel);
|
||||||
const embeds = adapter.supportsEmbeds
|
const componentsBuilder = adapter.supportsComponentsV2
|
||||||
? (adapter.buildCrossContextEmbeds?.(originLabel) ?? undefined)
|
? adapter.buildCrossContextComponents
|
||||||
|
? (message: string) =>
|
||||||
|
adapter.buildCrossContextComponents!({
|
||||||
|
originLabel,
|
||||||
|
message,
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId ?? undefined,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return { prefix, suffix, embeds };
|
return { prefix, suffix, componentsBuilder };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean {
|
export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean {
|
||||||
@@ -191,12 +202,20 @@ export function shouldApplyCrossContextMarker(action: ChannelMessageActionName):
|
|||||||
export function applyCrossContextDecoration(params: {
|
export function applyCrossContextDecoration(params: {
|
||||||
message: string;
|
message: string;
|
||||||
decoration: CrossContextDecoration;
|
decoration: CrossContextDecoration;
|
||||||
preferEmbeds: boolean;
|
preferComponents: boolean;
|
||||||
}): { message: string; embeds?: unknown[]; usedEmbeds: boolean } {
|
}): {
|
||||||
const useEmbeds = params.preferEmbeds && params.decoration.embeds?.length;
|
message: string;
|
||||||
if (useEmbeds) {
|
componentsBuilder?: CrossContextComponentsBuilder;
|
||||||
return { message: params.message, embeds: params.decoration.embeds, usedEmbeds: true };
|
usedComponents: boolean;
|
||||||
|
} {
|
||||||
|
const useComponents = params.preferComponents && params.decoration.componentsBuilder;
|
||||||
|
if (useComponents) {
|
||||||
|
return {
|
||||||
|
message: params.message,
|
||||||
|
componentsBuilder: params.decoration.componentsBuilder,
|
||||||
|
usedComponents: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const message = `${params.decoration.prefix}${params.message}${params.decoration.suffix}`;
|
const message = `${params.decoration.prefix}${params.message}${params.decoration.suffix}`;
|
||||||
return { message, usedEmbeds: false };
|
return { message, usedComponents: false };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user