refactor(channels): decouple presentation rendering

This commit is contained in:
Peter Steinberger
2026-04-21 21:20:26 +01:00
parent d7a173e60e
commit fd0970c077
76 changed files with 2290 additions and 1181 deletions

View File

@@ -611,7 +611,7 @@ Teams markdown is more limited than Slack or Discord:
- Basic formatting works: **bold**, _italic_, `code`, links
- Complex markdown (tables, nested lists) may not render correctly
- Adaptive Cards are supported for polls and arbitrary card sends (see below)
- Adaptive Cards are supported for polls and semantic presentation sends (see below)
## Configuration
@@ -783,11 +783,11 @@ OpenClaw sends Teams polls as Adaptive Cards (there is no native Teams poll API)
- The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed).
## Adaptive Cards (arbitrary)
## Presentation Cards
Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI.
Send semantic presentation payloads to Teams users or conversations using the `message` tool or CLI. OpenClaw renders them as Teams Adaptive Cards from the generic presentation contract.
The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional.
The `presentation` parameter accepts semantic blocks. When `presentation` is provided, the message text is optional.
**Agent tool:**
@@ -796,10 +796,9 @@ The `card` parameter accepts an Adaptive Card JSON object. When `card` is provid
action: "send",
channel: "msteams",
target: "user:<id>",
card: {
type: "AdaptiveCard",
version: "1.5",
body: [{ type: "TextBlock", text: "Hello!" }],
presentation: {
title: "Hello",
blocks: [{ type: "text", text: "Hello!" }],
},
}
```
@@ -809,10 +808,10 @@ The `card` parameter accepts an Adaptive Card JSON object. When `card` is provid
```bash
openclaw message send --channel msteams \
--target "conversation:19:abc...@thread.tacv2" \
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}'
--presentation '{"title":"Hello","blocks":[{"type":"text","text":"Hello!"}]}'
```
See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below.
For target format details, see [Target formats](#target-formats) below.
## Target formats
@@ -837,9 +836,9 @@ openclaw message send --channel msteams --target "user:John Smith" --message "He
# Send to a group chat or channel
openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello"
# Send an Adaptive Card to a conversation
# Send a presentation card to a conversation
openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}'
--presentation '{"title":"Hello","blocks":[{"type":"text","text":"Hello"}]}'
```
**Agent tool examples:**
@@ -858,10 +857,9 @@ openclaw message send --channel msteams --target "conversation:19:abc...@thread.
action: "send",
channel: "msteams",
target: "conversation:19:abc...@thread.tacv2",
card: {
type: "AdaptiveCard",
version: "1.5",
body: [{ type: "TextBlock", text: "Hello" }],
presentation: {
title: "Hello",
blocks: [{ type: "text", text: "Hello" }],
},
}
```

View File

@@ -803,7 +803,8 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
Telegram send also supports:
- `--buttons` for inline keyboards when `channels.telegram.capabilities.inlineButtons` allows it
- `--presentation` with `buttons` blocks for inline keyboards when `channels.telegram.capabilities.inlineButtons` allows it
- `--pin` or `--delivery '{"pin":true}'` to request pinned delivery when the bot can pin in that chat
- `--force-document` to send outbound images and GIFs as documents instead of compressed photo or animated-media uploads
Action gating:

View File

@@ -67,15 +67,13 @@ Name lookup:
- `send`
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/Matrix/Microsoft Teams
- Required: `--target`, plus `--message` or `--media`
- Optional: `--media`, `--interactive`, `--buttons`, `--components`, `--card`, `--reply-to`, `--thread-id`, `--gif-playback`, `--force-document`, `--silent`
- Shared interactive payloads: `--interactive` sends a channel-native interactive JSON payload when supported
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Required: `--target`, plus `--message`, `--media`, or `--presentation`
- Optional: `--media`, `--presentation`, `--delivery`, `--pin`, `--reply-to`, `--thread-id`, `--gif-playback`, `--force-document`, `--silent`
- Shared presentation payloads: `--presentation` sends semantic blocks (`text`, `context`, `divider`, `buttons`, `select`) that core renders through the selected channel's declared capabilities.
- Generic delivery preferences: `--delivery` accepts delivery hints such as `{ "pin": true }`; `--pin` is shorthand for pinned delivery when the channel supports it.
- Telegram only: `--force-document` (send images and GIFs as documents to avoid Telegram compression)
- Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- Discord only: `--components` JSON payload
- Adaptive-card channels: `--card` JSON payload when supported
- Telegram + Discord: `--silent`
- WhatsApp only: `--gif-playback`
@@ -208,22 +206,22 @@ openclaw message send --channel discord \
--target channel:123 --message "hi" --reply-to 456
```
Send a Discord message with components:
Send a message with semantic buttons:
```
openclaw message send --channel discord \
--target channel:123 --message "Choose:" \
--components '{"text":"Choose a path","blocks":[{"type":"actions","buttons":[{"label":"Approve","style":"success"},{"label":"Decline","style":"danger"}]}]}'
--presentation '{"blocks":[{"type":"buttons","buttons":[{"label":"Approve","value":"approve","style":"success"},{"label":"Decline","value":"decline","style":"danger"}]}]}'
```
See [Discord components](/channels/discord#interactive-components) for the full schema.
Core renders the same `presentation` payload into Discord components, Slack blocks, Telegram inline buttons, Mattermost props, or Teams/Feishu cards depending on channel capability.
Send a shared interactive payload:
Send a richer presentation payload:
```bash
openclaw message send --channel googlechat --target spaces/AAA... \
--message "Choose:" \
--interactive '{"text":"Choose a path","blocks":[{"type":"actions","buttons":[{"label":"Approve"},{"label":"Decline"}]}]}'
--presentation '{"title":"Deploy approval","tone":"warning","blocks":[{"type":"text","text":"Choose a path"},{"type":"buttons","buttons":[{"label":"Approve","value":"approve"},{"label":"Decline","value":"decline"}]}]}'
```
Create a Discord poll:
@@ -277,19 +275,19 @@ openclaw message react --channel signal \
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
```
Send Telegram inline buttons:
Send Telegram inline buttons through generic presentation:
```
openclaw message send --channel telegram --target @mychat --message "Choose:" \
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
--presentation '{"blocks":[{"type":"buttons","buttons":[{"label":"Yes","value":"cmd:yes"},{"label":"No","value":"cmd:no"}]}]}'
```
Send a Teams Adaptive Card:
Send a Teams card through generic presentation:
```bash
openclaw message send --channel msteams \
--target conversation:19:abc@thread.tacv2 \
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Status update"}]}'
--presentation '{"title":"Status update","blocks":[{"type":"text","text":"Build completed"}]}'
```
Send a Telegram image as a document to avoid compression:

250
docs/plan/ui-channels.md Normal file
View File

@@ -0,0 +1,250 @@
---
title: Channel Presentation Refactor Plan
summary: Decouple semantic message presentation from channel native UI renderers.
read_when:
- Refactoring channel message UI, interactive payloads, or native channel renderers
- Changing message tool capabilities, delivery hints, or cross-context markers
- Debugging Discord Carbon import fanout or channel plugin runtime laziness
---
# Channel Presentation Refactor Plan
## Status
Implemented for the shared agent, CLI, plugin capability, and outbound delivery surfaces:
- `ReplyPayload.presentation` carries semantic message UI.
- `ReplyPayload.delivery.pin` carries sent-message pin requests.
- Shared message actions expose `presentation`, `delivery`, and `pin` instead of provider-native `components`, `blocks`, `buttons`, or `card`.
- Core renders or auto-degrades presentation through plugin-declared outbound capabilities.
- Discord, Slack, Telegram, Mattermost, MS Teams, and Feishu renderers consume the generic contract.
- Discord channel control-plane code no longer imports Carbon-backed UI containers.
## Problem
Channel UI is currently split across several incompatible surfaces:
- Core owns a Discord-shaped cross-context renderer hook through `buildCrossContextComponents`.
- Discord `channel.ts` can import native Carbon UI through `DiscordUiContainer`, which pulls runtime UI dependencies into the channel plugin control plane.
- The agent and CLI expose native payload escape hatches such as Discord `components`, Slack `blocks`, Telegram or Mattermost `buttons`, and Teams or Feishu `card`.
- `ReplyPayload.channelData` carries both transport hints and native UI envelopes.
- The generic `interactive` model exists, but it is narrower than the richer layouts already used by Discord, Slack, Teams, Feishu, LINE, Telegram, and Mattermost.
This makes core aware of native UI shapes, weakens plugin runtime laziness, and gives agents too many provider-specific ways to express the same message intent.
## Goals
- Core decides the best semantic presentation for a message from declared capabilities.
- Extensions declare capabilities and render semantic presentation into native transport payloads.
- Web Control UI remains separate from chat native UI.
- Native channel payloads are not exposed through the shared agent or CLI message surface.
- Unsupported presentation features auto-degrade to the best text representation.
- Delivery behavior such as pinning a sent message is generic delivery metadata, not presentation.
## Non Goals
- No backwards compatibility shim for `buildCrossContextComponents`.
- No public native escape hatches for `components`, `blocks`, `buttons`, or `card`.
- No core imports of channel-native UI libraries.
- No provider-specific SDK seams for bundled channels.
## Target Model
Add a core-owned `presentation` field to `ReplyPayload`.
```ts
type MessagePresentationTone = "neutral" | "info" | "success" | "warning" | "danger";
type MessagePresentation = {
tone?: MessagePresentationTone;
title?: string;
blocks: MessagePresentationBlock[];
};
type MessagePresentationBlock =
| { type: "text"; text: string }
| { type: "context"; text: string }
| { type: "divider" }
| { type: "buttons"; buttons: MessagePresentationButton[] }
| { type: "select"; placeholder?: string; options: MessagePresentationOption[] };
type MessagePresentationButton = {
label: string;
value?: string;
url?: string;
style?: "primary" | "secondary" | "success" | "danger";
};
type MessagePresentationOption = {
label: string;
value: string;
};
```
`interactive` becomes a subset of `presentation` during migration:
- `interactive` text block maps to `presentation.blocks[].type = "text"`.
- `interactive` buttons block maps to `presentation.blocks[].type = "buttons"`.
- `interactive` select block maps to `presentation.blocks[].type = "select"`.
The external agent and CLI schemas now use `presentation`; `interactive` remains an internal legacy parser/rendering helper for existing reply producers.
## Delivery Metadata
Add a core-owned `delivery` field for send behavior that is not UI.
```ts
type ReplyPayloadDelivery = {
pin?:
| boolean
| {
enabled: boolean;
notify?: boolean;
required?: boolean;
};
};
```
Semantics:
- `delivery.pin = true` means pin the first successfully delivered message.
- `notify` defaults to `false`.
- `required` defaults to `false`; unsupported channels or failed pinning auto-degrade by continuing delivery.
- Manual `pin`, `unpin`, and `list-pins` message actions remain for existing messages.
Current Telegram ACP topic binding should move from `channelData.telegram.pin = true` to `delivery.pin = true`.
## Runtime Capability Contract
Add presentation and delivery render hooks to the runtime outbound adapter, not the control-plane channel plugin.
```ts
type ChannelPresentationCapabilities = {
supported: boolean;
buttons?: boolean;
selects?: boolean;
context?: boolean;
divider?: boolean;
tones?: MessagePresentationTone[];
};
type ChannelDeliveryCapabilities = {
pinSentMessage?: boolean;
};
type ChannelOutboundAdapter = {
presentationCapabilities?: ChannelPresentationCapabilities;
renderPresentation?: (params: {
payload: ReplyPayload;
presentation: MessagePresentation;
ctx: ChannelOutboundSendContext;
}) => ReplyPayload | null;
deliveryCapabilities?: ChannelDeliveryCapabilities;
pinDeliveredMessage?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
threadId?: string | number | null;
messageId: string;
notify: boolean;
}) => Promise<void>;
};
```
Core behavior:
- Resolve target channel and runtime adapter.
- Ask for presentation capabilities.
- Degrade unsupported blocks before rendering.
- Call `renderPresentation`.
- If no renderer exists, convert presentation to text fallback.
- After successful send, call `pinDeliveredMessage` when `delivery.pin` is requested and supported.
## Channel Mapping
Discord:
- Render `presentation` to components v2 and Carbon containers in runtime-only modules.
- Keep accent color helpers in light modules.
- Remove `DiscordUiContainer` imports from channel plugin control-plane code.
Slack:
- Render `presentation` to Block Kit.
- Remove agent and CLI `blocks` input.
Telegram:
- Render text, context, and dividers as text.
- Render actions and select as inline keyboards when configured and allowed for the target surface.
- Use text fallback when inline buttons are disabled.
- Move ACP topic pinning to `delivery.pin`.
Mattermost:
- Render actions as interactive buttons where configured.
- Render other blocks as text fallback.
MS Teams:
- Render `presentation` to Adaptive Cards.
- Keep manual pin/unpin/list-pins actions.
- Optionally implement `pinDeliveredMessage` if Graph support is reliable for the target conversation.
Feishu:
- Render `presentation` to interactive cards.
- Keep manual pin/unpin/list-pins actions.
- Optionally implement `pinDeliveredMessage` for sent-message pinning if API behavior is reliable.
LINE:
- Render `presentation` to Flex or template messages where possible.
- Fall back to text for unsupported blocks.
- Remove LINE UI payloads from `channelData`.
Plain or limited channels:
- Convert presentation to text with conservative formatting.
## Refactor Steps
1. Reapply the Discord release fix that splits `ui-colors.ts` from Carbon-backed UI and removes `DiscordUiContainer` from `extensions/discord/src/channel.ts`.
2. Add `presentation` and `delivery` to `ReplyPayload`, outbound payload normalization, delivery summaries, and hook payloads.
3. Add `MessagePresentation` schema and parser helpers in a narrow SDK/runtime subpath.
4. Replace message capabilities `buttons`, `cards`, `components`, and `blocks` with semantic presentation capabilities.
5. Add runtime outbound adapter hooks for presentation render and delivery pinning.
6. Replace cross-context component construction with `buildCrossContextPresentation`.
7. Delete `src/infra/outbound/channel-adapters.ts` and remove `buildCrossContextComponents` from channel plugin types.
8. Change `maybeApplyCrossContextMarker` to attach `presentation` instead of native params.
9. Update plugin-dispatch send paths to consume only semantic presentation and delivery metadata.
10. Remove agent and CLI native payload params: `components`, `blocks`, `buttons`, and `card`.
11. Remove SDK helpers that create native message-tool schemas, replacing them with presentation schema helpers.
12. Remove UI/native envelopes from `channelData`; keep only transport metadata until each remaining field is reviewed.
13. Migrate Discord, Slack, Telegram, Mattermost, MS Teams, Feishu, and LINE renderers.
14. Update docs for message CLI, channel pages, plugin SDK, and capability cookbook.
15. Run import fanout profiling for Discord and affected channel entrypoints.
Steps 1-11 and 13-14 are implemented in this refactor for the shared agent, CLI, plugin capability, and outbound adapter contracts. Step 12 remains a deeper internal cleanup pass for provider-private `channelData` transport envelopes. Step 15 remains follow-up validation if we want quantified import-fanout numbers beyond the type/test gate.
## Tests
Add or update:
- Presentation normalization tests.
- Presentation auto-degrade tests for unsupported blocks.
- Cross-context marker tests for plugin dispatch and core delivery paths.
- Channel render matrix tests for Discord, Slack, Telegram, Mattermost, MS Teams, Feishu, LINE, and text fallback.
- Message tool schema tests proving native fields are gone.
- CLI tests proving native flags are gone.
- Discord entrypoint import-laziness regression covering Carbon.
- Delivery pin tests covering Telegram and generic fallback.
## Open Questions
- Should `delivery.pin` be implemented for Discord, Slack, MS Teams, and Feishu in the first pass, or only Telegram first?
- Should `delivery` eventually absorb existing fields such as `replyToId`, `replyToCurrent`, `silent`, and `audioAsVoice`, or stay focused on post-send behaviors?
- Should presentation support images or file references directly, or should media remain separate from UI layout for now?

View File

@@ -7,10 +7,16 @@ import {
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import {
normalizeInteractiveReply,
normalizeMessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
import { handleDiscordAction } from "../../action-runtime-api.js";
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
import {
buildDiscordInteractiveComponents,
buildDiscordPresentationComponents,
} from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
import { readDiscordParentIdParam } from "./runtime.shared.js";
@@ -48,7 +54,7 @@ export async function handleDiscordMessageAction(
const to = readStringParam(params, "to", { required: true });
const asVoice = readBooleanParam(params, "asVoice") === true;
const rawComponents =
params.components ??
buildDiscordPresentationComponents(normalizeMessagePresentation(params.presentation)) ??
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive));
const hasComponents =
Boolean(rawComponents) &&

View File

@@ -38,7 +38,7 @@ describe("discord actions contract", () => {
},
} as OpenClawConfig,
expectedActions: ["send", "poll", "react", "reactions", "emoji-list"],
expectedCapabilities: ["interactive", "components"],
expectedCapabilities: ["presentation"],
},
],
});

View File

@@ -1,4 +1,3 @@
import { Type } from "@sinclair/typebox";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { withEnv } from "openclaw/plugin-sdk/testing";
@@ -53,8 +52,8 @@ describe("discordMessageActions", () => {
} as OpenClawConfig,
});
expect(discovery?.capabilities).toEqual(["interactive", "components"]);
expect(discovery?.schema).not.toBeNull();
expect(discovery?.capabilities).toEqual(["presentation"]);
expect(discovery?.schema).toBeUndefined();
expect(discovery?.actions).toEqual(
expect.arrayContaining(["send", "poll", "react", "reactions", "emoji-list", "permissions"]),
);
@@ -101,7 +100,7 @@ describe("discordMessageActions", () => {
expect(workDiscovery?.actions).not.toContain("poll");
});
it("keeps components optional in the message tool schema", () => {
it("does not expose Discord-native message tool schema", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
@@ -111,12 +110,7 @@ describe("discordMessageActions", () => {
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected discord message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
expect(discovery?.schema).toBeUndefined();
});
it("extracts send targets for message and thread reply actions", () => {

View File

@@ -1,4 +1,3 @@
import { Type } from "@sinclair/typebox";
import {
createUnionActionGate,
listTokenSourcedAccounts,
@@ -16,7 +15,6 @@ import {
listEnabledDiscordAccounts,
resolveDiscordAccount,
} from "./accounts.js";
import { createDiscordMessageToolComponentsSchema } from "./message-tool-schema.js";
let discordChannelActionsRuntimePromise:
| Promise<typeof import("./channel-actions.runtime.js")>
@@ -157,12 +155,7 @@ function describeDiscordMessageTool({
}
return {
actions: Array.from(actions),
capabilities: ["interactive", "components"],
schema: {
properties: {
components: Type.Optional(createDiscordMessageToolComponentsSchema()),
},
},
capabilities: ["presentation"],
};
}

View File

@@ -1,4 +1,3 @@
import { createRequire } from "node:module";
import {
buildLegacyDmAccountAllowlistAdapter,
createAccountScopedAllowlistNameResolver,
@@ -69,12 +68,8 @@ import { discordSetupAdapter } from "./setup-adapter.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./target-parsing.js";
import { normalizeDiscordAccentColor, resolveDiscordAccentColor } from "./ui-colors.js";
type DiscordSendFn = typeof import("./send.js").sendMessageDiscord;
type DiscordCarbonModule = typeof import("@buape/carbon");
type DiscordTextDisplay = InstanceType<DiscordCarbonModule["TextDisplay"]>;
type DiscordSeparator = InstanceType<DiscordCarbonModule["Separator"]>;
let discordProviderRuntimePromise:
| Promise<typeof import("./monitor/provider.runtime.js")>
@@ -83,7 +78,6 @@ let discordProbeRuntimePromise: Promise<typeof import("./probe.runtime.js")> | u
let discordAuditModulePromise: Promise<typeof import("./audit.js")> | undefined;
let discordSendModulePromise: Promise<typeof import("./send.js")> | undefined;
let discordDirectoryLiveModulePromise: Promise<typeof import("./directory-live.js")> | undefined;
let discordCarbonModuleCache: DiscordCarbonModule | null = null;
const loadDiscordDirectoryConfigModule = createLazyRuntimeModule(
() => import("./directory-config.js"),
@@ -96,8 +90,6 @@ const loadDiscordThreadBindingsManagerModule = createLazyRuntimeModule(
() => import("./monitor/thread-bindings.manager.js"),
);
const require = createRequire(import.meta.url);
async function loadDiscordProviderRuntime() {
discordProviderRuntimePromise ??= import("./monitor/provider.runtime.js");
return await discordProviderRuntimePromise;
@@ -123,11 +115,6 @@ async function loadDiscordDirectoryLiveModule() {
return await discordDirectoryLiveModulePromise;
}
function loadDiscordCarbonModule() {
discordCarbonModuleCache ??= require("@buape/carbon") as DiscordCarbonModule;
return discordCarbonModuleCache;
}
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
function resolveDiscordAttachedOutboundTarget(params: {
@@ -229,29 +216,17 @@ function formatDiscordIntents(intents?: {
].join(" ");
}
function buildDiscordCrossContextComponents(params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) {
const { Container, Separator, TextDisplay } = loadDiscordCarbonModule();
function buildDiscordCrossContextPresentation(params: { originLabel: string; message: string }) {
const trimmed = params.message.trim();
const components: Array<DiscordTextDisplay | DiscordSeparator> = [];
if (trimmed) {
components.push(new TextDisplay(params.message));
components.push(new Separator({ divider: true, spacing: "small" }));
}
components.push(new TextDisplay(`*From ${params.originLabel}*`));
const configuredAccent = resolveDiscordAccentColor({
cfg: params.cfg,
accountId: params.accountId,
});
return [
new Container(components, {
accentColor: normalizeDiscordAccentColor(configuredAccent) ?? configuredAccent,
}),
];
return {
tone: "neutral" as const,
blocks: [
...(trimmed
? ([{ type: "text" as const, text: params.message }, { type: "divider" as const }] as const)
: []),
{ type: "context" as const, text: `From ${params.originLabel}` },
],
};
}
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
@@ -449,7 +424,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
buildCrossContextComponents: buildDiscordCrossContextComponents,
buildCrossContextPresentation: buildDiscordCrossContextPresentation,
resolveOutboundSessionRoute: (params) => resolveDiscordOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeDiscordTargetId,

View File

@@ -199,19 +199,29 @@ describe("discordOutbound", () => {
channelId: "ch-1",
});
const payload = await discordOutbound.renderPresentation?.({
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
},
presentation: {
blocks: [{ type: "buttons", buttons: [{ label: "Open", value: "open" }] }],
},
ctx: {
cfg: {},
to: "channel:123456",
},
} as never);
if (!payload) {
throw new Error("expected Discord presentation payload");
}
const result = await discordOutbound.sendPayload?.({
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
discord: {
components: { text: "hello", components: [] },
},
},
},
payload,
accountId: "default",
mediaLocalRoots: ["/tmp/media"],
});
@@ -241,6 +251,35 @@ describe("discordOutbound", () => {
});
});
it("renders channelData Discord components on payload sends", async () => {
await discordOutbound.sendPayload?.({
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "native component text",
channelData: {
discord: {
components: {
blocks: [{ type: "text", text: "Native component body" }],
},
},
},
},
accountId: "default",
});
expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith(
"channel:123456",
expect.objectContaining({
text: "native component text",
blocks: [{ type: "text", text: "Native component body" }],
}),
expect.objectContaining({ accountId: "default" }),
);
expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled();
});
it("neutralizes approval mentions only for approval payloads", async () => {
await discordOutbound.sendPayload?.({
cfg: {},

View File

@@ -17,7 +17,7 @@ import {
normalizeOptionalString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import type { DiscordComponentMessageSpec } from "./components.js";
import { readDiscordComponentSpec, type DiscordComponentMessageSpec } from "./components.js";
import type { ThreadBindingRecord } from "./monitor/thread-bindings.js";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
@@ -160,6 +160,31 @@ export const discordOutbound: ChannelOutboundAdapter = {
textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT,
pollMaxOptions: 10,
normalizePayload: ({ payload }) => normalizeDiscordApprovalPayload(payload),
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
},
renderPresentation: async ({ payload, presentation }) => {
const componentSpec = (await loadDiscordSharedInteractive()).buildDiscordPresentationComponents(
presentation,
);
if (!componentSpec) {
return null;
}
return {
...payload,
channelData: {
...payload.channelData,
discord: {
...(payload.channelData?.discord as Record<string, unknown> | undefined),
presentationComponents: componentSpec,
},
},
};
},
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendPayload: async (ctx) => {
const payload = normalizeDiscordApprovalPayload({
@@ -167,10 +192,11 @@ export const discordOutbound: ChannelOutboundAdapter = {
text: ctx.payload.text ?? "",
});
const discordData = payload.channelData?.discord as
| { components?: DiscordComponentMessageSpec }
| { components?: unknown; presentationComponents?: DiscordComponentMessageSpec }
| undefined;
const rawComponentSpec =
discordData?.components ??
discordData?.presentationComponents ??
readDiscordComponentSpec(discordData?.components) ??
(payload.interactive
? (await loadDiscordSharedInteractive()).buildDiscordInteractiveComponents(
payload.interactive,

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
import {
buildDiscordInteractiveComponents,
buildDiscordPresentationComponents,
} from "./shared-interactive.js";
describe("buildDiscordInteractiveComponents", () => {
it("maps shared buttons and selects into Discord component blocks", () => {
@@ -65,6 +68,26 @@ describe("buildDiscordInteractiveComponents", () => {
});
});
it("preserves URL-only buttons as Discord link buttons", () => {
expect(
buildDiscordInteractiveComponents({
blocks: [
{
type: "buttons",
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
}),
).toEqual({
blocks: [
{
type: "actions",
buttons: [{ label: "Docs", style: "link", url: "https://example.com/docs" }],
},
],
});
});
it("splits long shared button rows to stay within Discord action limits", () => {
expect(
buildDiscordInteractiveComponents({
@@ -101,4 +124,30 @@ describe("buildDiscordInteractiveComponents", () => {
],
});
});
it("does not duplicate presentation text when appending controls", () => {
expect(
buildDiscordPresentationComponents({
title: "Status",
blocks: [
{ type: "text", text: "Build completed" },
{ type: "context", text: "main branch" },
{
type: "buttons",
buttons: [{ label: "Open", value: "open" }],
},
],
}),
).toEqual({
blocks: [
{ type: "text", text: "Status" },
{ type: "text", text: "Build completed" },
{ type: "text", text: "-# main branch" },
{
type: "actions",
buttons: [{ label: "Open", style: "secondary", callbackData: "open" }],
},
],
});
});
});

View File

@@ -1,9 +1,14 @@
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import {
presentationToInteractiveReply,
reduceInteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveButtonStyle,
InteractiveReply,
MessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentMessageSpec,
} from "./components.types.js";
@@ -43,11 +48,19 @@ export function buildDiscordInteractiveComponents(
type: "actions",
buttons: block.buttons
.slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE)
.map((button) => ({
label: button.label,
style: resolveDiscordInteractiveButtonStyle(button.style),
callbackData: button.value,
})),
.map((button) => {
const spec: DiscordComponentButtonSpec = {
label: button.label,
style: button.url ? "link" : resolveDiscordInteractiveButtonStyle(button.style),
};
if (button.value) {
spec.callbackData = button.value;
}
if (button.url) {
spec.url = button.url;
}
return spec;
}),
});
}
return state;
@@ -70,3 +83,42 @@ export function buildDiscordInteractiveComponents(
);
return blocks.length > 0 ? { blocks } : undefined;
}
export function buildDiscordPresentationComponents(
presentation?: MessagePresentation,
): DiscordComponentMessageSpec | undefined {
if (!presentation) {
return undefined;
}
const spec: DiscordComponentMessageSpec = { blocks: [] };
if (presentation.title) {
spec.blocks?.push({ type: "text", text: presentation.title });
}
for (const block of presentation.blocks) {
if (block.type === "text" || block.type === "context") {
const text = block.text.trim();
if (text) {
spec.blocks?.push({
type: "text",
text: block.type === "context" ? `-# ${text}` : text,
});
}
continue;
}
if (block.type === "divider") {
spec.blocks?.push({ type: "separator" });
continue;
}
}
const interactiveSpec = buildDiscordInteractiveComponents(
presentationToInteractiveReply({
blocks: presentation.blocks.filter(
(block) => block.type === "buttons" || block.type === "select",
),
}),
);
if (interactiveSpec?.blocks?.length) {
spec.blocks?.push(...interactiveSpec.blocks);
}
return spec.blocks?.length ? spec : undefined;
}

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { feishuPlugin } from "./channel.js";
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
@@ -65,41 +64,6 @@ function getDescribedActions(cfg: OpenClawConfig, accountId?: string): string[]
return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg, accountId })?.actions ?? [])];
}
function createLegacyFeishuButtonCard(value: { command?: string; text?: string }) {
return {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Run /new" },
value,
},
],
},
],
},
};
}
async function expectLegacyFeishuCardPayloadRejected(cfg: OpenClawConfig, card: unknown) {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card },
cfg,
accountId: undefined,
toolContext: {},
} as never),
).rejects.toThrow(
"Feishu card buttons that trigger text or commands must use structured interaction envelopes.",
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
}
describe("feishuPlugin.status.probeAccount", () => {
it("uses current account credentials for multi-account config", async () => {
const cfg = {
@@ -348,12 +312,18 @@ describe("feishuPlugin actions", () => {
expect(result?.details).toMatchObject({ ok: true, messageId: "om_sent", chatId: "oc_group_1" });
});
it("sends card messages", async () => {
it("renders presentation messages as cards", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card: { schema: "2.0" } },
params: {
to: "chat:oc_group_1",
presentation: {
title: "Status",
blocks: [{ type: "text", text: "Build completed" }],
},
},
cfg,
accountId: undefined,
toolContext: {},
@@ -362,7 +332,21 @@ describe("feishuPlugin actions", () => {
expect(sendCardFeishuMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
card: { schema: "2.0" },
card: expect.objectContaining({
schema: "2.0",
header: {
title: { tag: "plain_text", content: "Status" },
template: "blue",
},
body: {
elements: [
{
tag: "markdown",
content: "Build completed",
},
],
},
}),
accountId: undefined,
replyToMessageId: undefined,
replyInThread: false,
@@ -370,34 +354,22 @@ describe("feishuPlugin actions", () => {
expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" });
});
it("allows structured card button payloads", async () => {
it("renders presentation button labels into the card fallback", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const card = {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Run /new" },
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "oc_group_1", t: "group", e: Date.now() + 60_000 },
}),
},
],
},
],
},
};
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card },
params: {
to: "chat:oc_group_1",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Run help", value: "feishu.quick_actions.help" }],
},
],
},
},
cfg,
accountId: undefined,
toolContext: {},
@@ -405,54 +377,37 @@ describe("feishuPlugin actions", () => {
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card,
card: expect.objectContaining({
body: {
elements: [
{
tag: "markdown",
content: "- Run help",
},
],
},
}),
}),
);
});
it("rejects raw legacy card command payloads", async () => {
await expectLegacyFeishuCardPayloadRejected(
cfg,
createLegacyFeishuButtonCard({ command: "/new" }),
);
});
it("rejects raw legacy card text payloads", async () => {
await expectLegacyFeishuCardPayloadRejected(
cfg,
createLegacyFeishuButtonCard({ text: "/new" }),
);
});
it("allows non-button controls to carry text metadata values", async () => {
it("renders presentation select labels into the card fallback", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const card = {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "select_static",
placeholder: { tag: "plain_text", content: "Pick one" },
value: { text: "display-only metadata" },
options: [
{
text: { tag: "plain_text", content: "Option A" },
value: "a",
},
],
},
],
},
],
},
};
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card },
params: {
to: "chat:oc_group_1",
presentation: {
blocks: [
{
type: "select",
placeholder: "Pick one",
options: [{ label: "Option A", value: "a" }],
},
],
},
},
cfg,
accountId: undefined,
toolContext: {},
@@ -460,7 +415,16 @@ describe("feishuPlugin actions", () => {
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card,
card: expect.objectContaining({
body: {
elements: [
{
tag: "markdown",
content: "Pick one:\n- Option A",
},
],
},
}),
}),
);
});

View File

@@ -1,6 +1,5 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
import {
adaptScopedAccountAccessor,
createHybridChannelConfigAdapter,
@@ -20,6 +19,10 @@ import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import {
normalizeMessagePresentation,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime";
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
@@ -118,6 +121,41 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
"feishuChannelRuntime",
);
function buildFeishuPresentationCard(params: {
presentation: NonNullable<ReturnType<typeof normalizeMessagePresentation>>;
fallbackText?: string;
}): Record<string, unknown> {
const fallbackPresentation: NonNullable<ReturnType<typeof normalizeMessagePresentation>> = {
...(params.presentation.tone ? { tone: params.presentation.tone } : {}),
blocks: params.presentation.blocks,
};
return {
schema: "2.0",
config: {
width_mode: "fill",
},
...(params.presentation.title
? {
header: {
title: { tag: "plain_text", content: params.presentation.title },
template: "blue",
},
}
: {}),
body: {
elements: [
{
tag: "markdown",
content: renderMessagePresentationFallbackText({
text: params.fallbackText,
presentation: fallbackPresentation,
}),
},
],
},
};
}
async function createFeishuActionClient(account: ResolvedFeishuAccount) {
const { createFeishuClient } = await import("./client.js");
return createFeishuClient(account);
@@ -160,14 +198,7 @@ function describeFeishuMessageTool({
if (enabledAccounts.length === 0) {
return {
actions: [],
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
capabilities: enabled ? ["presentation"] : [],
};
}
const actions = new Set<ChannelMessageActionName>([
@@ -192,14 +223,7 @@ function describeFeishuMessageTool({
}
return {
actions: Array.from(actions),
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
},
}
: null,
capabilities: enabled ? ["presentation"] : [],
};
}
@@ -668,12 +692,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (ctx.action === "thread-reply" && !replyToMessageId) {
throw new Error("Feishu thread-reply requires messageId.");
}
const card =
ctx.params.card && typeof ctx.params.card === "object"
? (ctx.params.card as Record<string, unknown>)
: undefined;
const presentation = normalizeMessagePresentation(ctx.params.presentation);
const text = readFirstString(ctx.params, ["text", "message"]);
const mediaUrl = readFeishuMediaParam(ctx.params);
const card = presentation
? buildFeishuPresentationCard({ presentation, fallbackText: text })
: undefined;
if (card && mediaUrl) {
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
}

View File

@@ -4,7 +4,6 @@
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {
"@sinclair/typebox": "0.34.49",
"ws": "^8.20.0"
},
"devDependencies": {

View File

@@ -24,7 +24,7 @@ describe("mattermost actions contract", () => {
},
} as OpenClawConfig,
expectedActions: ["send", "react"],
expectedCapabilities: ["buttons"],
expectedCapabilities: ["presentation"],
},
{
name: "reactions can be disabled while send stays available",
@@ -39,7 +39,7 @@ describe("mattermost actions contract", () => {
},
} as OpenClawConfig,
expectedActions: ["send"],
expectedCapabilities: ["buttons"],
expectedCapabilities: ["presentation"],
},
{
name: "missing bot credentials disables the actions surface",

View File

@@ -1,4 +1,3 @@
import { Type } from "@sinclair/typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { createChannelReplyPipeline } from "../runtime-api.js";
@@ -257,7 +256,7 @@ describe("mattermostPlugin", () => {
expect(actions).toEqual([]);
});
it("keeps buttons optional in message tool schema", () => {
it("declares presentation capability for message sends", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
@@ -269,12 +268,8 @@ describe("mattermostPlugin", () => {
};
const discovery = mattermostPlugin.actions?.describeMessageTool?.({ cfg });
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected mattermost message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
expect(discovery?.capabilities).toContain("presentation");
expect(discovery?.schema).toBeUndefined();
});
it("hides react when actions.reactions is false", () => {

View File

@@ -1,5 +1,3 @@
import { Type } from "@sinclair/typebox";
import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
@@ -10,6 +8,11 @@ import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import {
normalizeMessagePresentation,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
@@ -98,15 +101,7 @@ function describeMattermostMessageTool({
return {
actions,
capabilities: enabledAccounts.length > 0 ? ["buttons"] : [],
schema:
enabledAccounts.length > 0
? {
properties: {
buttons: Type.Optional(createMessageToolButtonsSchema()),
},
}
: null,
capabilities: enabledAccounts.length > 0 ? ["presentation"] : [],
};
}
@@ -180,7 +175,15 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
throw new Error("Mattermost send requires a target (to).");
}
const message = typeof params.message === "string" ? params.message : "";
const presentation = normalizeMessagePresentation(params.presentation);
const message = presentation
? renderMessagePresentationFallbackText({
text: typeof params.message === "string" ? params.message : "",
presentation,
})
: typeof params.message === "string"
? params.message
: "";
// Match the shared runner semantics: trim empty reply IDs away before
// falling back from replyToId to replyTo on direct plugin calls.
const replyToId =
@@ -195,7 +198,23 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
).sendMessageMattermost(to, message, {
accountId: resolvedAccountId,
replyToId,
buttons: Array.isArray(params.buttons) ? params.buttons : undefined,
buttons: presentation
? presentationToInteractiveReply(presentation)
?.blocks.filter((block) => block.type === "buttons")
.map((block) =>
block.buttons.flatMap((button) =>
button.value
? [
{
text: button.label,
callback_data: button.value,
style: button.style,
},
]
: [],
),
)
: undefined,
attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined,
mediaUrl,
});

View File

@@ -1,12 +1,13 @@
import { Type } from "@sinclair/typebox";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { ChannelMessageActionName, ChannelPlugin } from "./channel-api.js";
import { buildMSTeamsPresentationCard } from "./presentation.js";
import { resolveMSTeamsCredentials } from "./token.js";
const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport(
@@ -270,11 +271,10 @@ export function describeMSTeamsMessageTool({
"channel-info",
] satisfies ChannelMessageActionName[])
: [],
capabilities: enabled ? ["cards"] : [],
capabilities: enabled ? ["presentation"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
pinnedMessageId: Type.Optional(
Type.String({
description:
@@ -290,8 +290,13 @@ export function describeMSTeamsMessageTool({
export const msteamsActionsAdapter: NonNullable<ChannelPlugin["actions"]> = {
describeMessageTool: describeMSTeamsMessageTool,
handleAction: async (ctx) => {
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const presentation =
ctx.action === "send" ? normalizeMessagePresentation(ctx.params.presentation) : undefined;
if (ctx.action === "send" && presentation) {
const card = buildMSTeamsPresentationCard({
presentation,
text: resolveActionContent(ctx.params),
});
return await runWithRequiredActionTarget({
actionLabel: "Card send",
toolParams: ctx.params,

View File

@@ -77,7 +77,7 @@ const updatedText = "updated text";
const reactionTypes = ["like", "heart", "laugh", "surprised", "sad", "angry"];
const deleteMissingTargetError = "Delete requires a target (to) and messageId.";
const reactionsMissingTargetError = "Reactions requires a target (to) and messageId.";
const cardSendMissingTargetError = "Card send requires a target (to).";
const presentationSendMissingTargetError = "Card send requires a target (to).";
const reactMissingEmojiError =
"React requires an emoji (reaction type). Valid types: like, heart, laugh, surprised, sad, angry.";
const reactMissingEmojiDetail = "React requires an emoji (reaction type).";
@@ -495,14 +495,59 @@ describe("msteamsPlugin message actions", () => {
await expectActionParamError("reactions", { to: targetChannelId }, reactionsMissingTargetError);
});
it("keeps card-send target validation shared", async () => {
it("keeps presentation-card target validation shared", async () => {
await expectActionParamError(
"send",
{ card: { type: "AdaptiveCard" } },
cardSendMissingTargetError,
{ presentation: { blocks: [{ type: "text", text: "hello" }] } },
presentationSendMissingTargetError,
);
});
it("preserves message text when sending presentation cards", async () => {
await expectSuccessfulAction({
mockFn: sendAdaptiveCardMSTeamsMock,
mockResult: {
messageId: "msg-card-1",
conversationId: "conv-card-1",
},
action: "send",
actionParams: {
to: targetChannelId,
message: "Deploy finished",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Open", value: "open" }],
},
],
},
},
runtimeParams: {
to: targetChannelId,
card: {
type: "AdaptiveCard",
version: "1.4",
body: [{ type: "TextBlock", text: "Deploy finished", wrap: true }],
actions: [
{ type: "Action.Submit", title: "Open", data: { value: "open", label: "Open" } },
],
},
},
details: {
ok: true,
channel: "msteams",
messageId: "msg-card-1",
},
contentDetails: {
ok: true,
channel: "msteams",
messageId: "msg-card-1",
conversationId: "conv-card-1",
},
});
});
it("reports the allowed reaction types when emoji is missing", async () => {
await expectActionParamError(
"react",

View File

@@ -1,7 +1,6 @@
import { Type } from "@sinclair/typebox";
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import type {
ChannelMessageActionAdapter,
@@ -18,6 +17,7 @@ import {
createRuntimeDirectoryLiveAdapter,
listDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/directory-runtime";
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime";
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
@@ -34,6 +34,7 @@ import { msTeamsApprovalAuth } from "./approval-auth.js";
import { MSTeamsChannelConfigSchema } from "./config-schema.js";
import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import { buildMSTeamsPresentationCard } from "./presentation.js";
import type { ProbeMSTeamsResult } from "./probe.js";
import {
normalizeMSTeamsMessagingTarget,
@@ -384,11 +385,10 @@ function describeMSTeamsMessageTool({
"renameGroup",
] satisfies ChannelMessageActionName[])
: [],
capabilities: enabled ? ["cards"] : [],
capabilities: enabled ? ["presentation"] : [],
schema: enabled
? {
properties: {
card: createMessageToolCardSchema(),
pinnedMessageId: Type.Optional(
Type.String({
description:
@@ -631,9 +631,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actions: {
describeMessageTool: describeMSTeamsMessageTool,
handleAction: async (ctx) => {
// Handle send action with card parameter
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const presentation =
ctx.action === "send"
? normalizeMessagePresentation(ctx.params.presentation)
: undefined;
if (ctx.action === "send" && presentation) {
const card = buildMSTeamsPresentationCard({
presentation,
text: resolveActionContent(ctx.params),
});
return await runWithRequiredActionTarget({
actionLabel: "Card send",
toolParams: ctx.params,

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { buildMSTeamsPresentationCard } from "./presentation.js";
describe("buildMSTeamsPresentationCard", () => {
it("preserves message text when rendering presentation controls", () => {
expect(
buildMSTeamsPresentationCard({
text: "Deploy finished",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Open", value: "open" }],
},
],
},
}),
).toEqual({
type: "AdaptiveCard",
version: "1.4",
body: [{ type: "TextBlock", text: "Deploy finished", wrap: true }],
actions: [{ type: "Action.Submit", title: "Open", data: { value: "open", label: "Open" } }],
});
});
});

View File

@@ -0,0 +1,68 @@
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
export function buildMSTeamsPresentationCard(params: {
presentation: MessagePresentation;
text?: string | null;
}) {
const body: Record<string, unknown>[] = [];
const text = normalizeOptionalString(params.text);
if (text) {
body.push({
type: "TextBlock",
text,
wrap: true,
});
}
const { presentation } = params;
if (presentation.title) {
body.push({
type: "TextBlock",
text: presentation.title,
weight: "Bolder",
size: "Medium",
wrap: true,
});
}
const actions: Record<string, unknown>[] = [];
for (const block of presentation.blocks) {
if (block.type === "text" || block.type === "context") {
body.push({
type: "TextBlock",
text: block.text,
wrap: true,
...(block.type === "context" ? { isSubtle: true, size: "Small" } : {}),
});
continue;
}
if (block.type === "divider") {
body.push({ type: "TextBlock", text: "---", wrap: true, isSubtle: true });
continue;
}
if (block.type === "buttons") {
for (const button of block.buttons) {
if (button.url) {
actions.push({
type: "Action.OpenUrl",
title: button.label,
url: button.url,
});
continue;
}
if (button.value) {
actions.push({
type: "Action.Submit",
title: button.label,
data: { value: button.value, label: button.label },
});
}
}
}
}
return {
type: "AdaptiveCard",
version: "1.4",
body,
...(actions.length ? { actions } : {}),
};
}

View File

@@ -1,6 +1,12 @@
import type { Block, KnownBlock } from "@slack/web-api";
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import {
presentationToInteractiveReply,
reduceInteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveReply,
MessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { truncateSlackText } from "./truncate.js";
@@ -53,26 +59,33 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
return state;
}
if (block.type === "buttons") {
if (block.buttons.length === 0) {
const elements = block.buttons.flatMap((button, choiceIndex) => {
if (!button.value && !button.url) {
return [];
}
const style = resolveSlackButtonStyle(button.style);
return [
{
type: "button" as const,
action_id: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
text: {
type: "plain_text" as const,
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
...(button.value ? { value: button.value } : {}),
...(button.url ? { url: button.url } : {}),
...(style ? { style } : {}),
},
];
});
if (elements.length === 0) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
elements: block.buttons.map((button, choiceIndex) => {
const style = resolveSlackButtonStyle(button.style);
return {
type: "button",
action_id: buildSlackReplyButtonActionId(state.buttonIndex, choiceIndex),
text: {
type: "plain_text",
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
value: button.value,
...(style ? { style } : {}),
};
}),
elements,
});
return state;
}
@@ -108,3 +121,50 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
return state;
}).blocks;
}
export function buildSlackPresentationBlocks(presentation?: MessagePresentation): SlackBlock[] {
if (!presentation) {
return [];
}
const blocks: SlackBlock[] = [];
if (presentation.title) {
blocks.push({
type: "header",
text: {
type: "plain_text",
text: truncateSlackText(presentation.title, 150),
emoji: true,
},
});
}
for (const block of presentation.blocks) {
if (block.type === "text" || block.type === "context") {
const text = block.text.trim();
if (!text) {
continue;
}
if (block.type === "context") {
blocks.push({
type: "context",
elements: [{ type: "mrkdwn", text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX) }],
});
} else {
blocks.push({
type: "section",
text: { type: "mrkdwn", text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX) },
});
}
continue;
}
if (block.type === "divider") {
blocks.push({ type: "divider" });
}
}
const interactive = presentationToInteractiveReply({
blocks: presentation.blocks.filter(
(block) => block.type === "buttons" || block.type === "select",
),
});
blocks.push(...buildSlackInteractiveBlocks(interactive));
return blocks;
}

View File

@@ -40,10 +40,10 @@ describe("slack actions contract", () => {
},
} as OpenClawConfig,
expectedActions: slackDefaultActions,
expectedCapabilities: ["blocks"],
expectedCapabilities: ["presentation"],
},
{
name: "interactive replies add the shared interactive capability",
name: "interactive replies keep the shared presentation capability",
cfg: {
channels: {
slack: {
@@ -56,7 +56,7 @@ describe("slack actions contract", () => {
},
} as OpenClawConfig,
expectedActions: slackDefaultActions,
expectedCapabilities: ["blocks", "interactive"],
expectedCapabilities: ["presentation"],
},
{
name: "missing tokens disables the actions surface",

View File

@@ -1,4 +1,3 @@
import { Type } from "@sinclair/typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import { slackPlugin } from "./channel.js";
@@ -110,12 +109,8 @@ describe("slackPlugin actions", () => {
});
expect(discovery?.actions).toContain("send");
expect(discovery?.capabilities).toEqual(expect.arrayContaining(["blocks", "interactive"]));
expect(discovery?.schema).toMatchObject({
properties: {
blocks: expect.any(Object),
},
});
expect(discovery?.capabilities).toEqual(expect.arrayContaining(["presentation"]));
expect(discovery?.schema).toBeUndefined();
});
it("honors the selected Slack account during message tool discovery", () => {
@@ -171,7 +166,7 @@ describe("slackPlugin actions", () => {
expect(slackPlugin.actions?.describeMessageTool?.({ cfg, accountId: "default" })).toMatchObject(
{
actions: ["send"],
capabilities: ["blocks"],
capabilities: ["presentation"],
},
);
expect(slackPlugin.actions?.describeMessageTool?.({ cfg, accountId: "work" })).toMatchObject({
@@ -185,7 +180,7 @@ describe("slackPlugin actions", () => {
"download-file",
"upload-file",
],
capabilities: expect.arrayContaining(["blocks", "interactive"]),
capabilities: expect.arrayContaining(["presentation"]),
});
});
@@ -224,7 +219,7 @@ describe("slackPlugin actions", () => {
);
});
it("keeps blocks optional in the message tool schema", () => {
it("does not expose Slack-native message tool schema", () => {
const discovery = slackPlugin.actions?.describeMessageTool({
cfg: {
channels: {
@@ -235,12 +230,7 @@ describe("slackPlugin actions", () => {
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected slack message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
expect(discovery?.schema).toBeUndefined();
});
it("treats interactive reply payloads as structured Slack payloads", () => {
@@ -482,18 +472,8 @@ describe("slackPlugin outbound", () => {
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
slack: {
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: "Block body",
},
},
],
},
presentation: {
blocks: [{ type: "text", text: "Block body" }],
},
},
accountId: "default",
@@ -529,7 +509,7 @@ describe("slackPlugin outbound", () => {
{
type: "section",
text: {
type: "plain_text",
type: "mrkdwn",
text: "Block body",
},
},

View File

@@ -1,9 +1,11 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import {
normalizeInteractiveReply,
normalizeMessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks } from "./blocks-render.js";
type SlackActionInvoke = (
action: Record<string, unknown>,
@@ -11,10 +13,6 @@ type SlackActionInvoke = (
toolContext?: ChannelMessageActionContext["toolContext"],
) => Promise<AgentToolResult<unknown>>;
function readSlackBlocksParam(actionParams: Record<string, unknown>) {
return parseSlackBlocksInput(actionParams.blocks) as Record<string, unknown>[] | undefined;
}
/** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */
export async function handleSlackMessageAction(params: {
providerId: string;
@@ -40,9 +38,13 @@ export async function handleSlackMessageAction(params: {
allowEmpty: true,
});
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
const presentation = normalizeMessagePresentation(actionParams.presentation);
const interactive = normalizeInteractiveReply(actionParams.interactive);
const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined;
const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks;
const presentationBlocks = presentation
? buildSlackPresentationBlocks(presentation)
: undefined;
const blocks = presentationBlocks?.length ? presentationBlocks : interactiveBlocks;
if (!content && !mediaUrl && !blocks) {
throw new Error("Slack send requires message, blocks, or media.");
}
@@ -123,7 +125,8 @@ export async function handleSlackMessageAction(params: {
required: true,
});
const content = readStringParam(actionParams, "message", { allowEmpty: true });
const blocks = readSlackBlocksParam(actionParams);
const presentation = normalizeMessagePresentation(actionParams.presentation);
const blocks = presentation ? buildSlackPresentationBlocks(presentation) : undefined;
if (!content && !blocks) {
throw new Error("Slack edit requires message or blocks.");
}

View File

@@ -15,7 +15,7 @@ describe("Slack message tool public API", () => {
}),
).toMatchObject({
actions: expect.arrayContaining(["send", "upload-file", "read"]),
capabilities: expect.arrayContaining(["blocks"]),
capabilities: expect.arrayContaining(["presentation"]),
});
});

View File

@@ -1,30 +1,21 @@
import { Type } from "@sinclair/typebox";
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { listSlackMessageActions } from "./message-actions.js";
import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js";
export function describeSlackMessageTool({
cfg,
accountId,
}: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0]) {
const actions = listSlackMessageActions(cfg, accountId);
const capabilities = new Set<"blocks" | "interactive">();
const capabilities = new Set<"presentation">();
if (actions.includes("send")) {
capabilities.add("blocks");
capabilities.add("presentation");
}
if (isSlackInteractiveRepliesEnabled({ cfg, accountId })) {
capabilities.add("interactive");
capabilities.add("presentation");
}
return {
actions,
capabilities: Array.from(capabilities),
schema: actions.includes("send")
? {
properties: {
blocks: Type.Optional(createSlackMessageToolBlocksSchema()),
},
}
: null,
};
}

View File

@@ -48,15 +48,13 @@ describe("slackOutbound", () => {
payload: {
text: "final text",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
slack: {
blocks: [
{
type: "section",
text: { type: "plain_text", text: "Block body" },
},
],
},
presentation: {
blocks: [
{
type: "text",
text: "Block body",
},
],
},
},
mediaLocalRoots: ["/tmp/workspace"],
@@ -93,7 +91,7 @@ describe("slackOutbound", () => {
blocks: [
{
type: "section",
text: { type: "plain_text", text: "Block body" },
text: { type: "mrkdwn", text: "Block body" },
},
],
}),
@@ -101,6 +99,35 @@ describe("slackOutbound", () => {
expect(result).toEqual({ channel: "slack", messageId: "m-final" });
});
it("renders channelData Slack blocks on payload sends", async () => {
sendMessageSlackMock.mockResolvedValueOnce({ messageId: "m-blocks" });
const result = await slackOutbound.sendPayload!({
cfg,
to: "C123",
text: "",
payload: {
text: "fallback text",
channelData: {
slack: {
blocks: [{ type: "divider" }],
},
},
},
accountId: "default",
});
expect(sendMessageSlackMock).toHaveBeenCalledWith(
"C123",
"fallback text",
expect.objectContaining({
cfg,
blocks: [{ type: "divider" }],
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-blocks" });
});
it("cancels sendMedia when message_sending hooks block it", async () => {
hasHooksMock.mockReturnValue(true);
runMessageSendingMock.mockResolvedValue({ cancel: true });

View File

@@ -6,6 +6,7 @@ import {
import {
resolveInteractiveTextFallback,
type InteractiveReply,
type MessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import {
resolveOutboundSendDep,
@@ -20,7 +21,11 @@ import {
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
import {
buildSlackInteractiveBlocks,
buildSlackPresentationBlocks,
type SlackBlock,
} from "./blocks-render.js";
import { compileSlackInteractiveReplies } from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import type { SlackSendIdentity } from "./send.js";
@@ -154,16 +159,20 @@ async function sendSlackOutboundMessage(params: {
function resolveSlackBlocks(payload: {
channelData?: Record<string, unknown>;
interactive?: InteractiveReply;
presentation?: MessagePresentation;
}) {
const slackData = payload.channelData?.slack;
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
return renderedInteractive;
}
const existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as
| SlackBlock[]
const slackData = payload.channelData?.slack as
| { blocks?: unknown; presentationBlocks?: SlackBlock[] }
| undefined;
const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])];
const nativeBlocks = parseSlackBlocksInput(slackData?.blocks) as SlackBlock[] | undefined;
const renderedPresentation =
slackData?.presentationBlocks ?? buildSlackPresentationBlocks(payload.presentation);
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
const mergedBlocks = [
...(nativeBlocks ?? []),
...renderedPresentation,
...(renderedInteractive ?? []),
];
if (mergedBlocks.length === 0) {
return undefined;
}
@@ -180,6 +189,23 @@ export const slackOutbound: ChannelOutboundAdapter = {
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
normalizePayload: ({ payload }) => compileSlackInteractiveReplies(payload),
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
},
renderPresentation: ({ payload, presentation }) => ({
...payload,
channelData: {
...payload.channelData,
slack: {
...(payload.channelData?.slack as Record<string, unknown> | undefined),
presentationBlocks: buildSlackPresentationBlocks(presentation),
},
},
}),
sendPayload: async (ctx) => {
const payload = {
...ctx.payload,

View File

@@ -10,15 +10,11 @@ function createHarness(params: {
}
describe("slackOutbound sendPayload", () => {
it("forwards Slack blocks from channelData", async () => {
it("renders presentation blocks", async () => {
const { run, sendMock, to } = createHarness({
payload: {
text: "Fallback summary",
channelData: {
slack: {
blocks: [{ type: "divider" }],
},
},
presentation: { blocks: [{ type: "divider" }] },
},
});
@@ -35,43 +31,6 @@ describe("slackOutbound sendPayload", () => {
expect(result).toMatchObject({ channel: "slack", messageId: "sl-1" });
});
it("accepts blocks encoded as JSON strings in Slack channelData", async () => {
const { run, sendMock, to } = createHarness({
payload: {
channelData: {
slack: {
blocks: '[{"type":"section","text":{"type":"mrkdwn","text":"hello"}}]',
},
},
},
});
await run();
expect(sendMock).toHaveBeenCalledWith(
to,
"",
expect.objectContaining({
blocks: [{ type: "section", text: { type: "mrkdwn", text: "hello" } }],
}),
);
});
it("rejects invalid Slack blocks from channelData", async () => {
const { run, sendMock } = createHarness({
payload: {
channelData: {
slack: {
blocks: {},
},
},
},
});
await expect(run()).rejects.toThrow(/blocks must be an array/i);
expect(sendMock).not.toHaveBeenCalled();
});
it("sends media before a separate interactive blocks message", async () => {
const { run, sendMock, to } = createHarness({
payload: {
@@ -119,11 +78,7 @@ describe("slackOutbound sendPayload", () => {
it("fails when merged Slack blocks exceed the platform limit", async () => {
const { run, sendMock } = createHarness({
payload: {
channelData: {
slack: {
blocks: Array.from({ length: 50 }, () => ({ type: "divider" })),
},
},
presentation: { blocks: Array.from({ length: 50 }, () => ({ type: "divider" })) },
interactive: {
blocks: [
{

View File

@@ -83,6 +83,29 @@ describe("buildSlackInteractiveBlocks", () => {
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("codex:approve:thread-1");
});
it("preserves URL-only buttons as Slack link buttons", () => {
const blocks = buildSlackInteractiveBlocks({
blocks: [
{
type: "buttons",
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
});
const buttonBlock = blocks[0] as {
elements?: Array<{ value?: string; url?: string }>;
};
expect(buttonBlock.elements?.[0]).toEqual(
expect.objectContaining({
type: "button",
url: "https://example.com/docs",
}),
);
expect(buttonBlock.elements?.[0]).not.toHaveProperty("value");
});
it("maps supported button styles to Slack Block Kit styles", () => {
const blocks = buildSlackInteractiveBlocks({
blocks: [

View File

@@ -1,11 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { captureEnv } from "openclaw/plugin-sdk/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
handleTelegramAction,
readTelegramButtons,
telegramActionRuntime,
} from "./action-runtime.js";
import { handleTelegramAction, telegramActionRuntime } from "./action-runtime.js";
const originalTelegramActionRuntime = { ...telegramActionRuntime };
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
@@ -34,6 +30,11 @@ const editForumTopicTelegram = vi.fn(async () => ({
messageThreadId: 42,
name: "Renamed",
}));
const pinMessageTelegram = vi.fn(async () => ({
ok: true,
messageId: "789",
chatId: "123",
}));
const createForumTopicTelegram = vi.fn(async () => ({
topicId: 99,
name: "Topic",
@@ -76,7 +77,16 @@ describe("handleTelegramAction", () => {
action: "sendMessage",
to: params.to,
content: "Choose",
buttons: params.buttons,
presentation: {
blocks: params.buttons.map((row) => ({
type: "buttons",
buttons: row.map((button) => ({
label: button.text,
value: button.callback_data,
style: button.style,
})),
})),
},
},
telegramConfig({ capabilities: { inlineButtons: params.inlineButtons } }),
);
@@ -102,6 +112,7 @@ describe("handleTelegramAction", () => {
deleteMessageTelegram,
editMessageTelegram,
editForumTopicTelegram,
pinMessageTelegram,
createForumTopicTelegram,
});
reactMessageTelegram.mockClear();
@@ -111,6 +122,7 @@ describe("handleTelegramAction", () => {
deleteMessageTelegram.mockClear();
editMessageTelegram.mockClear();
editForumTopicTelegram.mockClear();
pinMessageTelegram.mockClear();
createForumTopicTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
});
@@ -632,6 +644,107 @@ describe("handleTelegramAction", () => {
).rejects.toThrow(/content required/i);
});
it("renders presentation text when message content is omitted", async () => {
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
presentation: {
title: "Status",
blocks: [
{ type: "text", text: "Build completed" },
{ type: "context", text: "main branch" },
],
},
},
telegramConfig(),
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456",
"Status\n\nBuild completed\n\nmain branch",
expect.objectContaining({ token: "tok" }),
);
});
it("uses presentation fallback text for button-only sends", async () => {
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
},
},
telegramConfig({ capabilities: { inlineButtons: "all" } }),
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456",
"- Approve",
expect.objectContaining({
buttons: [[{ text: "Approve", callback_data: "approve" }]],
}),
);
});
it("pins action sends when delivery pin is requested", async () => {
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
content: "Pin this",
delivery: { pin: { enabled: true } },
},
telegramConfig(),
);
expect(pinMessageTelegram).toHaveBeenCalledWith(
"123456",
"789",
expect.objectContaining({ accountId: undefined, verbose: false }),
);
});
it("passes delivery pin notify requests for action sends", async () => {
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
content: "Pin this loudly",
delivery: { pin: { enabled: true, notify: true } },
},
telegramConfig(),
);
expect(pinMessageTelegram).toHaveBeenCalledWith(
"123456",
"789",
expect.objectContaining({ notify: true }),
);
});
it("fails required action-send pins when pinning fails", async () => {
pinMessageTelegram.mockRejectedValueOnce(new Error("pin failed"));
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "123456",
content: "Pin this",
delivery: { pin: { enabled: true, required: true } },
},
telegramConfig(),
),
).rejects.toThrow(/pin failed/);
});
it("respects sendMessage gating", async () => {
const cfg = {
channels: {
@@ -730,7 +843,9 @@ describe("handleTelegramAction", () => {
action: "sendMessage",
to: "@testchannel",
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
presentation: {
blocks: [{ type: "buttons", buttons: [{ label: "Ok", value: "cmd:ok" }] }],
},
},
cfg,
);
@@ -757,7 +872,9 @@ describe("handleTelegramAction", () => {
action: "sendMessage",
to,
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
presentation: {
blocks: [{ type: "buttons", buttons: [{ label: "Ok", value: "cmd:ok" }] }],
},
},
telegramConfig({ capabilities: { inlineButtons } }),
),
@@ -829,68 +946,6 @@ describe("handleTelegramAction", () => {
});
});
describe("readTelegramButtons", () => {
it("returns trimmed button rows for valid input", () => {
const result = readTelegramButtons({
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
});
expect(result).toEqual([[{ text: "Option A", callback_data: "cmd:a" }]]);
});
it("normalizes optional style", () => {
const result = readTelegramButtons({
buttons: [
[
{
text: "Option A",
callback_data: "cmd:a",
style: " PRIMARY ",
},
],
],
});
expect(result).toEqual([
[
{
text: "Option A",
callback_data: "cmd:a",
style: "primary",
},
],
]);
});
it("rejects unsupported button style", () => {
expect(() =>
readTelegramButtons({
buttons: [[{ text: "Option A", callback_data: "cmd:a", style: "secondary" }]],
}),
).toThrow(/style must be one of danger, success, primary/i);
});
it("rejects callback_data over Telegram's 64-byte limit", () => {
expect(() =>
readTelegramButtons({
buttons: [[{ text: "Option A", callback_data: "x".repeat(65) }]],
}),
).toThrow(/callback_data too long/i);
});
it("accepts multibyte callback_data at 64 bytes and rejects 68 bytes", () => {
expect(
readTelegramButtons({
buttons: [[{ text: "Option A", callback_data: "😀".repeat(16) }]],
}),
).toEqual([[{ text: "Option A", callback_data: "😀".repeat(16) }]]);
expect(() =>
readTelegramButtons({
buttons: [[{ text: "Option A", callback_data: "😀".repeat(17) }]],
}),
).toThrow(/callback_data too long/i);
});
});
describe("handleTelegramAction per-account gating", () => {
function accountTelegramConfig(params: {
accounts: Record<

View File

@@ -12,15 +12,12 @@ import {
} from "openclaw/plugin-sdk/channel-actions";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
normalizeMessagePresentation,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js";
import {
fitsTelegramCallbackData,
TELEGRAM_CALLBACK_DATA_MAX_BYTES,
} from "./approval-callback-data.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
resolveTelegramInlineButtonsScope,
@@ -33,6 +30,7 @@ import {
deleteMessageTelegram,
editForumTopicTelegram,
editMessageTelegram,
pinMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
sendPollTelegram,
@@ -47,6 +45,7 @@ export const telegramActionRuntime = {
editForumTopicTelegram,
editMessageTelegram,
getCacheStats,
pinMessageTelegram,
reactMessageTelegram,
searchStickers,
sendMessageTelegram,
@@ -54,7 +53,6 @@ export const telegramActionRuntime = {
sendStickerTelegram,
};
const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"];
const TELEGRAM_FORUM_TOPIC_ICON_COLORS = [
0x6fb9f0, 0xffd67e, 0xcb86db, 0x8eee98, 0xff93b2, 0xfb6f5f,
] as const;
@@ -80,11 +78,6 @@ const TELEGRAM_ACTION_ALIASES = {
type TelegramActionName = (typeof TELEGRAM_ACTION_ALIASES)[keyof typeof TELEGRAM_ACTION_ALIASES];
type TelegramForumTopicIconColor = (typeof TELEGRAM_FORUM_TOPIC_ICON_COLORS)[number];
type RawTelegramButton = {
callback_data?: unknown;
style?: unknown;
text?: unknown;
};
function readTelegramForumTopicIconColor(
params: Record<string, unknown>,
@@ -98,56 +91,6 @@ function readTelegramForumTopicIconColor(
}
return iconColor as TelegramForumTopicIconColor;
}
export function readTelegramButtons(
params: Record<string, unknown>,
): TelegramInlineButtons | undefined {
const raw = params.buttons;
if (raw == null) {
return undefined;
}
if (!Array.isArray(raw)) {
throw new Error("buttons must be an array of button rows");
}
const rows = raw.map((row, rowIndex) => {
if (!Array.isArray(row)) {
throw new Error(`buttons[${rowIndex}] must be an array`);
}
return row.map((button, buttonIndex) => {
if (!button || typeof button !== "object") {
throw new Error(`buttons[${rowIndex}][${buttonIndex}] must be an object`);
}
const rawButton = button as RawTelegramButton;
const text = normalizeOptionalString(rawButton.text) ?? "";
const callbackData = normalizeOptionalString(rawButton.callback_data) ?? "";
if (!text || !callbackData) {
throw new Error(`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`);
}
if (!fitsTelegramCallbackData(callbackData)) {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] callback_data too long (max ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes)`,
);
}
const styleRaw = rawButton.style;
const style = normalizeOptionalLowercaseString(styleRaw);
if (styleRaw !== undefined && !style) {
throw new Error(`buttons[${rowIndex}][${buttonIndex}] style must be string`);
}
if (style && !TELEGRAM_BUTTON_STYLES.includes(style as TelegramButtonStyle)) {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] style must be one of ${TELEGRAM_BUTTON_STYLES.join(", ")}`,
);
}
return {
text,
callback_data: callbackData,
...(style ? { style: style as TelegramButtonStyle } : {}),
};
});
});
const filtered = rows.filter((row) => row.length > 0);
return filtered.length > 0 ? filtered : undefined;
}
function normalizeTelegramActionName(action: string): TelegramActionName {
const normalized = TELEGRAM_ACTION_ALIASES[action as keyof typeof TELEGRAM_ACTION_ALIASES];
if (!normalized) {
@@ -178,10 +121,12 @@ function readTelegramReplyToMessageId(params: Record<string, unknown>) {
);
}
function resolveTelegramButtonsFromParams(params: Record<string, unknown>) {
function resolveTelegramButtonsFromParams(
params: Record<string, unknown>,
presentation = normalizeMessagePresentation(params.presentation),
) {
return resolveTelegramInlineButtons({
buttons: readTelegramButtons(params),
interactive: params.interactive,
interactive: presentation ? presentationToInteractiveReply(presentation) : params.interactive,
});
}
@@ -189,17 +134,79 @@ function readTelegramSendContent(params: {
args: Record<string, unknown>;
mediaUrl?: string;
hasButtons: boolean;
presentation?: MessagePresentation;
}) {
const content =
const explicitContent =
readStringParam(params.args, "content", { allowEmpty: true }) ??
readStringParam(params.args, "message", { allowEmpty: true }) ??
readStringParam(params.args, "caption", { allowEmpty: true });
const presentationText =
explicitContent == null && params.presentation
? renderMessagePresentationFallbackText({ presentation: params.presentation })
: undefined;
const content = explicitContent ?? (presentationText?.trim() ? presentationText : undefined);
if (content == null && !params.mediaUrl && !params.hasButtons) {
throw new Error("content required.");
}
return content ?? "";
}
function normalizeTelegramDeliveryPin(params: Record<string, unknown>) {
const delivery = params.delivery;
const pin =
delivery && typeof delivery === "object" && !Array.isArray(delivery)
? (delivery as { pin?: unknown }).pin
: params.pin === true
? true
: undefined;
if (pin === true) {
return { enabled: true } as const;
}
if (!pin || typeof pin !== "object" || Array.isArray(pin)) {
return undefined;
}
const raw = pin as { enabled?: unknown; notify?: unknown; required?: unknown };
if (raw.enabled !== true) {
return undefined;
}
return {
enabled: true,
...(raw.notify === true ? { notify: true } : {}),
...(raw.required === true ? { required: true } : {}),
} as const;
}
async function maybePinTelegramActionSend(params: {
args: Record<string, unknown>;
cfg: OpenClawConfig;
accountId?: string;
to: string;
messageId?: string;
}) {
const pin = normalizeTelegramDeliveryPin(params.args);
if (!pin) {
return;
}
if (!params.messageId) {
if (pin.required) {
throw new Error("Telegram delivery pin requested, but no message id was returned.");
}
return;
}
try {
await telegramActionRuntime.pinMessageTelegram(params.to, params.messageId, {
cfg: params.cfg,
accountId: params.accountId,
notify: pin.notify,
verbose: false,
});
} catch (err) {
if (pin.required) {
throw err;
}
}
}
export async function handleTelegramAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
@@ -308,11 +315,13 @@ export async function handleTelegramAction(
readStringParam(params, "media", {
trim: false,
});
const buttons = resolveTelegramButtonsFromParams(params);
const presentation = normalizeMessagePresentation(params.presentation);
const buttons = resolveTelegramButtonsFromParams(params, presentation);
const content = readTelegramSendContent({
args: params,
mediaUrl: mediaUrl ?? undefined,
hasButtons: Array.isArray(buttons) && buttons.length > 0,
presentation,
});
if (buttons) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
@@ -369,6 +378,13 @@ export async function handleTelegramAction(
readBooleanParam(params, "asDocument") ??
false,
});
await maybePinTelegramActionSend({
args: params,
cfg,
accountId: accountId ?? undefined,
to,
messageId: result.messageId,
});
return jsonResult({
ok: true,
messageId: result.messageId,

View File

@@ -487,18 +487,20 @@ async function deliverMediaReply(params: {
}
async function maybePinFirstDeliveredMessage(params: {
shouldPin: boolean;
pin: NonNullable<ReplyPayload["delivery"]>["pin"] | undefined;
bot: Bot;
chatId: string;
runtime: RuntimeEnv;
firstDeliveredMessageId?: number;
}): Promise<void> {
if (!params.shouldPin || typeof params.firstDeliveredMessageId !== "number") {
const shouldPin = params.pin === true || (typeof params.pin === "object" && params.pin.enabled);
if (!shouldPin || typeof params.firstDeliveredMessageId !== "number") {
return;
}
const notify = typeof params.pin === "object" && params.pin.notify === true;
try {
await params.bot.api.pinChatMessage(params.chatId, params.firstDeliveredMessageId, {
disable_notification: true,
disable_notification: !notify,
});
} catch (err) {
logVerbose(
@@ -705,7 +707,6 @@ export async function deliverReplies(params: {
const replyToId =
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
const telegramData = reply.channelData?.telegram as TelegramReplyChannelData | undefined;
const shouldPinFirstMessage = telegramData?.pin === true;
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
let firstDeliveredMessageId: number | undefined;
if (mediaList.length === 0) {
@@ -747,7 +748,7 @@ export async function deliverReplies(params: {
});
}
await maybePinFirstDeliveredMessage({
shouldPin: shouldPinFirstMessage,
pin: reply.delivery?.pin,
bot: params.bot,
chatId: params.chatId,
runtime: params.runtime,

View File

@@ -936,7 +936,7 @@ describe("deliverReplies", () => {
const bot = createBot({ sendMessage, pinChatMessage });
await deliverReplies({
replies: [{ text: "chunk-one\n\nchunk-two", channelData: { telegram: { pin: true } } }],
replies: [{ text: "chunk-one\n\nchunk-two", delivery: { pin: true } }],
chatId: "123",
token: "tok",
runtime,
@@ -949,6 +949,25 @@ describe("deliverReplies", () => {
expect(pinChatMessage).toHaveBeenCalledWith("123", 101, { disable_notification: true });
});
it("honors notify on reply delivery pins", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({ message_id: 101, chat: { id: "123" } });
const pinChatMessage = vi.fn().mockResolvedValue(true);
const bot = createBot({ sendMessage, pinChatMessage });
await deliverReplies({
replies: [{ text: "hello", delivery: { pin: { enabled: true, notify: true } } }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4096,
});
expect(pinChatMessage).toHaveBeenCalledWith("123", 101, { disable_notification: false });
});
it("continues when pinning fails", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({ message_id: 201, chat: { id: "123" } });
@@ -956,7 +975,7 @@ describe("deliverReplies", () => {
const bot = createBot({ sendMessage, pinChatMessage });
await deliverWith({
replies: [{ text: "hello", channelData: { telegram: { pin: true } } }],
replies: [{ text: "hello", delivery: { pin: true } }],
runtime,
bot,
});

View File

@@ -30,6 +30,9 @@ function chunkInteractiveButtons(
) {
for (let i = 0; i < buttons.length; i += TELEGRAM_INTERACTIVE_ROW_SIZE) {
const row = buttons.slice(i, i + TELEGRAM_INTERACTIVE_ROW_SIZE).flatMap((button) => {
if (!button.value) {
return [];
}
const callbackData = sanitizeTelegramCallbackData(button.value);
if (!callbackData) {
return [];

View File

@@ -17,7 +17,7 @@ describe("telegram actions contract", () => {
},
} as OpenClawConfig,
expectedActions: ["send", "poll", "react", "delete", "edit", "topic-create", "topic-edit"],
expectedCapabilities: ["interactive", "buttons"],
expectedCapabilities: ["delivery-pin", "presentation"],
},
],
});

View File

@@ -1,5 +1,4 @@
import {
createMessageToolButtonsSchema,
createUnionActionGate,
listTokenSourcedAccounts,
resolveReactionMessageId,
@@ -146,13 +145,6 @@ function describeTelegramMessageTool({
actions.add("topic-edit");
}
const schema: ChannelMessageToolSchemaContribution[] = [];
if (discovery.buttonsEnabled) {
schema.push({
properties: {
buttons: createMessageToolButtonsSchema(),
},
});
}
if (discovery.pollEnabled) {
schema.push({
properties: createTelegramPollExtraToolSchemas(),
@@ -161,7 +153,7 @@ function describeTelegramMessageTool({
}
return {
actions: Array.from(actions),
capabilities: discovery.buttonsEnabled ? ["interactive", "buttons"] : [],
capabilities: discovery.buttonsEnabled ? ["presentation", "delivery-pin"] : ["delivery-pin"],
schema,
};
}

View File

@@ -629,11 +629,13 @@ export const telegramPlugin = createChatChannelPlugin({
conversationId,
threadId: threadId ?? undefined,
}),
buildBoundReplyChannelData: ({ operation, conversation }) => {
buildBoundReplyPayload: ({ operation, conversation }) => {
if (operation !== "acp-spawn") {
return null;
}
return conversation.conversationId.includes(":topic:") ? { telegram: { pin: true } } : null;
return conversation.conversationId.includes(":topic:")
? { delivery: { pin: { enabled: true, notify: false } } }
: null;
},
shouldStripThreadFromAnnounceOrigin: shouldStripTelegramThreadFromAnnounceOrigin,
createManager: ({ accountId }) =>

View File

@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageTelegramMock = vi.fn();
const pinMessageTelegramMock = vi.fn();
vi.mock("./send.js", () => ({
pinMessageTelegram: (...args: unknown[]) => pinMessageTelegramMock(...args),
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegramMock(...args),
}));
@@ -10,6 +12,7 @@ import { telegramOutbound } from "./outbound-adapter.js";
describe("telegramOutbound", () => {
beforeEach(() => {
pinMessageTelegramMock.mockReset();
sendMessageTelegramMock.mockReset();
});
@@ -94,4 +97,25 @@ describe("telegramOutbound", () => {
).toBeUndefined();
expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "12345" });
});
it("passes delivery pin notify requests to Telegram pinning", async () => {
pinMessageTelegramMock.mockResolvedValueOnce({ ok: true, messageId: "tg-1", chatId: "12345" });
await telegramOutbound.pinDeliveredMessage?.({
cfg: {} as never,
target: { channel: "telegram", to: "12345", accountId: "ops" },
messageId: "tg-1",
pin: { enabled: true, notify: true },
});
expect(pinMessageTelegramMock).toHaveBeenCalledWith(
"12345",
"tg-1",
expect.objectContaining({
accountId: "ops",
notify: true,
verbose: false,
}),
);
});
});

View File

@@ -3,7 +3,11 @@ import {
attachChannelToResult,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime";
import {
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime";
import {
resolveOutboundSendDep,
sanitizeForPlainText,
@@ -18,6 +22,7 @@ import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { markdownToTelegramHtmlChunks } from "./format.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
import { pinMessageTelegram } from "./send.js";
export const TELEGRAM_TEXT_CHUNK_LIMIT = 4000;
@@ -119,6 +124,29 @@ export const telegramOutbound: ChannelOutboundAdapter = {
textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: false,
},
deliveryCapabilities: {
pin: true,
},
renderPresentation: ({ payload, presentation }) => ({
...payload,
text: renderMessagePresentationFallbackText({ text: payload.text, presentation }),
interactive: presentationToInteractiveReply(presentation),
}),
pinDeliveredMessage: async ({ cfg, target, messageId, pin }) => {
await pinMessageTelegram(target.to, messageId, {
cfg,
accountId: target.accountId ?? undefined,
notify: pin.notify,
verbose: false,
});
},
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
...createAttachedChannelResultAdapter({

View File

@@ -295,6 +295,26 @@ describe("sendMessageTelegram", () => {
expect(botApi.unpinChatMessage).toHaveBeenCalledWith("-1001234567890", 101);
});
it("honors Telegram pin notification requests", async () => {
loadConfig.mockReturnValue({
channels: {
telegram: {
botToken: "tok",
},
},
});
botApi.pinChatMessage.mockResolvedValue(true);
await pinMessageTelegram("-1001234567890", 101, {
accountId: "default",
notify: true,
});
expect(botApi.pinChatMessage).toHaveBeenCalledWith("-1001234567890", 101, {
disable_notification: false,
});
});
it("renames a Telegram forum topic", async () => {
loadConfig.mockReturnValue({
channels: {

View File

@@ -1093,6 +1093,7 @@ type TelegramDeleteOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
notify?: boolean;
verbose?: boolean;
api?: TelegramApiOverride;
retry?: RetryConfig;
@@ -1147,7 +1148,10 @@ export async function pinMessageTelegram(
verbose: opts.verbose,
});
await requestWithDiag(
() => api.pinChatMessage(chatId, messageId, { disable_notification: true }),
() =>
api.pinChatMessage(chatId, messageId, {
disable_notification: opts.notify !== true,
}),
"pinChatMessage",
);
logVerbose(`[telegram] Pinned message ${messageId} in chat ${chatId}`);

View File

@@ -3,10 +3,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
import {
createMessageToolButtonsSchema,
createMessageToolCardSchema,
} from "../../plugin-sdk/channel-actions.js";
type CreateMessageTool = typeof import("./message-tool.js").createMessageTool;
type ResetPluginRuntimeStateForTest =
typeof import("../../plugins/runtime.js").resetPluginRuntimeStateForTest;
@@ -24,14 +20,6 @@ type DescribeMessageTool = NonNullable<
type MessageToolDiscoveryContext = Parameters<DescribeMessageTool>[0];
type MessageToolSchema = NonNullable<ReturnType<DescribeMessageTool>>["schema"];
function createDiscordMessageToolComponentsSchema() {
return Type.Object({ type: Type.Literal("discord-components") });
}
function createSlackMessageToolBlocksSchema() {
return Type.Array(Type.Object({}, { additionalProperties: true }));
}
function createTelegramPollExtraToolSchemas() {
return {
pollDurationSeconds: Type.Optional(Type.Number()),
@@ -40,24 +28,6 @@ function createTelegramPollExtraToolSchemas() {
};
}
function createCardSchemaPlugin(params: {
id: string;
label: string;
docsPath: string;
blurb: string;
}) {
return createChannelPlugin({
...params,
actions: ["send"],
capabilities: ["cards"],
toolSchema: () => ({
properties: {
card: createMessageToolCardSchema(),
},
}),
});
}
const mocks = vi.hoisted(() => ({
runMessageAction: vi.fn(),
loadConfig: vi.fn(() => ({})),
@@ -408,13 +378,8 @@ describe("message tool schema scoping", () => {
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send", "react", "poll"],
capabilities: ["interactive", "buttons"],
capabilities: ["presentation"],
toolSchema: () => [
{
properties: {
buttons: createMessageToolButtonsSchema(),
},
},
{
properties: createTelegramPollExtraToolSchemas(),
visibility: "all-configured",
@@ -428,12 +393,7 @@ describe("message tool schema scoping", () => {
docsPath: "/channels/discord",
blurb: "Discord test plugin.",
actions: ["send", "poll", "poll-vote"],
capabilities: ["interactive", "components"],
toolSchema: () => ({
properties: {
components: createDiscordMessageToolComponentsSchema(),
},
}),
capabilities: ["presentation"],
});
const slackPlugin = createChannelPlugin({
@@ -442,12 +402,7 @@ describe("message tool schema scoping", () => {
docsPath: "/channels/slack",
blurb: "Slack test plugin.",
actions: ["send", "react"],
capabilities: ["interactive", "blocks"],
toolSchema: () => ({
properties: {
blocks: createSlackMessageToolBlocksSchema(),
},
}),
capabilities: ["presentation"],
});
afterEach(() => {
@@ -457,42 +412,22 @@ describe("message tool schema scoping", () => {
it.each([
{
provider: "telegram",
expectComponents: false,
expectBlocks: false,
expectButtons: true,
expectButtonStyle: true,
expectTelegramPollExtras: true,
expectedActions: ["send", "react", "poll", "poll-vote"],
},
{
provider: "discord",
expectComponents: true,
expectBlocks: false,
expectButtons: false,
expectButtonStyle: false,
expectTelegramPollExtras: true,
expectedActions: ["send", "poll", "poll-vote", "react"],
},
{
provider: "slack",
expectComponents: false,
expectBlocks: true,
expectButtons: false,
expectButtonStyle: false,
expectTelegramPollExtras: true,
expectedActions: ["send", "react", "poll", "poll-vote"],
},
])(
"scopes schema fields for $provider",
({
provider,
expectComponents,
expectBlocks,
expectButtons,
expectButtonStyle,
expectTelegramPollExtras,
expectedActions,
}) => {
({ provider, expectTelegramPollExtras, expectedActions }) => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
@@ -508,30 +443,10 @@ describe("message tool schema scoping", () => {
const properties = getToolProperties(tool);
const actionEnum = getActionEnum(properties);
if (expectComponents) {
expect(properties.components).toBeDefined();
} else {
expect(properties.components).toBeUndefined();
}
if (expectBlocks) {
expect(properties.blocks).toBeDefined();
} else {
expect(properties.blocks).toBeUndefined();
}
if (expectButtons) {
expect(properties.buttons).toBeDefined();
} else {
expect(properties.buttons).toBeUndefined();
}
if (expectButtonStyle) {
const buttonItemProps =
(
properties.buttons as {
items?: { items?: { properties?: Record<string, unknown> } };
}
)?.items?.items?.properties ?? {};
expect(buttonItemProps.style).toBeDefined();
}
expect(properties.presentation).toBeDefined();
expect(properties.components).toBeUndefined();
expect(properties.blocks).toBeUndefined();
expect(properties.buttons).toBeUndefined();
for (const action of expectedActions) {
expect(actionEnum).toContain(action);
}
@@ -564,64 +479,6 @@ describe("message tool schema scoping", () => {
expect(actionEnum).toContain("poll");
});
it.each([
{
provider: "feishu",
plugin: createCardSchemaPlugin({
id: "feishu",
label: "Feishu",
docsPath: "/channels/feishu",
blurb: "Feishu test plugin.",
}),
},
{
provider: "msteams",
plugin: createCardSchemaPlugin({
id: "msteams",
label: "MSTeams",
docsPath: "/channels/msteams",
blurb: "MSTeams test plugin.",
}),
},
])(
"keeps $provider card schema optional after merging into the message tool schema",
({ plugin }) => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: plugin.id, source: "test", plugin }]),
);
const tool = createMessageTool({
config: {} as never,
currentChannelProvider: plugin.id,
});
const schema = tool.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
expect(schema.properties?.card).toBeDefined();
expect(schema.required ?? []).not.toContain("card");
},
);
it("keeps buttons schema optional so plain sends do not require buttons", () => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
);
const tool = createMessageTool({
config: {} as never,
currentChannelProvider: "telegram",
});
const schema = tool.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
expect(schema.properties?.buttons).toBeDefined();
expect(schema.required ?? []).not.toContain("buttons");
});
it("hides telegram poll extras when telegram polls are disabled in scoped mode", () => {
const telegramPluginWithConfig = createChannelPlugin({
id: "telegram",
@@ -634,22 +491,16 @@ describe("message tool schema scoping", () => {
return {
actions:
telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"],
capabilities: ["interactive", "buttons"],
schema: [
{
properties: {
buttons: createMessageToolButtonsSchema(),
},
},
...(telegramCfg?.actions?.poll === false
capabilities: ["presentation"],
schema:
telegramCfg?.actions?.poll === false
? []
: [
{
properties: createTelegramPollExtraToolSchemas(),
visibility: "all-configured" as const,
},
]),
],
],
};
},
});
@@ -681,7 +532,7 @@ describe("message tool schema scoping", () => {
expect(properties.pollPublic).toBeUndefined();
});
it("uses discovery account scope for capability-gated shared fields", () => {
it("uses discovery account scope for capability-gated presentation", () => {
const scopedInteractivePlugin = createChannelPlugin({
id: "telegram",
label: "Telegram",
@@ -689,7 +540,7 @@ describe("message tool schema scoping", () => {
blurb: "Telegram test plugin.",
describeMessageTool: ({ accountId }) => ({
actions: ["send"],
capabilities: accountId === "ops" ? ["interactive"] : [],
capabilities: accountId === "ops" ? ["presentation"] : [],
}),
});
@@ -709,8 +560,8 @@ describe("message tool schema scoping", () => {
currentChannelProvider: "telegram",
});
expect(getToolProperties(scopedTool).interactive).toBeDefined();
expect(getToolProperties(unscopedTool).interactive).toBeUndefined();
expect(getToolProperties(scopedTool).presentation).toBeDefined();
expect(getToolProperties(unscopedTool).presentation).toBeUndefined();
});
it("uses discovery account scope for other configured channel actions", () => {
@@ -765,7 +616,7 @@ describe("message tool schema scoping", () => {
seenContexts.push({ phase: "describeMessageTool", ...ctx });
return {
actions: ["send", "react"],
capabilities: ["interactive"],
capabilities: ["presentation"],
};
},
});

View File

@@ -54,36 +54,39 @@ function buildRoutingSchema() {
};
}
const interactiveOptionSchema = Type.Object({
const presentationOptionSchema = Type.Object({
label: Type.String(),
value: Type.String(),
});
const interactiveButtonSchema = Type.Object({
const presentationButtonSchema = Type.Object({
label: Type.String(),
value: Type.String(),
value: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger"])),
});
const interactiveBlockSchema = Type.Object({
type: stringEnum(["text", "buttons", "select"]),
const presentationBlockSchema = Type.Object({
type: stringEnum(["text", "context", "divider", "buttons", "select"]),
text: Type.Optional(Type.String()),
buttons: Type.Optional(Type.Array(interactiveButtonSchema)),
buttons: Type.Optional(Type.Array(presentationButtonSchema)),
placeholder: Type.Optional(Type.String()),
options: Type.Optional(Type.Array(interactiveOptionSchema)),
options: Type.Optional(Type.Array(presentationOptionSchema)),
});
const interactiveMessageSchema = Type.Object(
const presentationMessageSchema = Type.Object(
{
blocks: Type.Array(interactiveBlockSchema),
title: Type.Optional(Type.String()),
tone: Type.Optional(stringEnum(["info", "success", "warning", "danger", "neutral"])),
blocks: Type.Array(presentationBlockSchema),
},
{
description:
"Shared interactive message payload for buttons and selects. Channels render this into their native components when supported.",
"Shared presentation payload for rich text, buttons, selects, and context. Core degrades unsupported blocks to text.",
},
);
function buildSendSchema(options: { includeInteractive: boolean }) {
function buildSendSchema(options: { includePresentation: boolean; includeDeliveryPin: boolean }) {
const props: Record<string, TSchema> = {
message: Type.Optional(Type.String()),
effectId: Type.Optional(
@@ -130,10 +133,31 @@ function buildSendSchema(options: { includeInteractive: boolean }) {
"Send image/GIF as document to avoid Telegram compression. Alias for forceDocument (Telegram only).",
}),
),
interactive: Type.Optional(interactiveMessageSchema),
};
if (!options.includeInteractive) {
delete props.interactive;
if (options.includePresentation) {
props.presentation = Type.Optional(presentationMessageSchema);
}
if (options.includeDeliveryPin) {
props.delivery = Type.Optional(
Type.Object(
{
pin: Type.Optional(
Type.Union([
Type.Boolean(),
Type.Object({
enabled: Type.Boolean(),
notify: Type.Optional(Type.Boolean()),
required: Type.Optional(Type.Boolean()),
}),
]),
),
},
{
description:
"Shared delivery preferences. pin requests that the sent message be pinned when the channel supports it.",
},
),
);
}
return props;
}
@@ -353,7 +377,8 @@ function buildChannelManagementSchema() {
}
function buildMessageToolSchemaProps(options: {
includeInteractive: boolean;
includePresentation: boolean;
includeDeliveryPin: boolean;
extraProperties?: Record<string, TSchema>;
}) {
return {
@@ -377,7 +402,8 @@ function buildMessageToolSchemaProps(options: {
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: {
includeInteractive: boolean;
includePresentation: boolean;
includeDeliveryPin: boolean;
extraProperties?: Record<string, TSchema>;
},
) {
@@ -389,7 +415,8 @@ function buildMessageToolSchemaFromActions(
}
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeInteractive: true,
includePresentation: true,
includeDeliveryPin: true,
});
type MessageToolOptions = {
@@ -494,13 +521,18 @@ function resolveIncludeCapability(
return channelSupportsMessageCapability(params.cfg, capability);
}
function resolveIncludeInteractive(params: MessageToolDiscoveryParams): boolean {
return resolveIncludeCapability(params, "interactive");
function resolveIncludePresentation(params: MessageToolDiscoveryParams): boolean {
return resolveIncludeCapability(params, "presentation");
}
function resolveIncludeDeliveryPin(params: MessageToolDiscoveryParams): boolean {
return resolveIncludeCapability(params, "delivery-pin");
}
function buildMessageToolSchema(params: MessageToolDiscoveryParams) {
const actions = resolveMessageToolSchemaActions(params);
const includeInteractive = resolveIncludeInteractive(params);
const includePresentation = resolveIncludePresentation(params);
const includeDeliveryPin = resolveIncludeDeliveryPin(params);
const extraProperties = resolveChannelMessageToolSchemaProperties(
buildMessageActionDiscoveryInput(
params,
@@ -508,7 +540,8 @@ function buildMessageToolSchema(params: MessageToolDiscoveryParams) {
),
);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeInteractive,
includePresentation,
includeDeliveryPin,
extraProperties,
});
}

View File

@@ -1,9 +1,18 @@
import type { InteractiveReply } from "../interactive/payload.js";
import type {
InteractiveReply,
MessagePresentation,
ReplyPayloadDelivery,
} from "../interactive/payload.js";
export type ReplyPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
/** Channel-agnostic rich presentation. Core degrades or asks the channel renderer to map it. */
presentation?: MessagePresentation;
/** Channel-agnostic delivery preferences, e.g. pin the sent message when supported. */
delivery?: ReplyPayloadDelivery;
/** Internal legacy representation used by existing approval/reply helpers during migration. */
interactive?: InteractiveReply;
btw?: {
question: string;

View File

@@ -227,7 +227,7 @@ function setMinimalAcpCommandRegistryForTests(): void {
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
conversationBindings: {
defaultTopLevelPlacement: "current",
buildBoundReplyChannelData: ({
buildBoundReplyPayload: ({
operation,
conversation,
}: {
@@ -235,7 +235,7 @@ function setMinimalAcpCommandRegistryForTests(): void {
conversation: { conversationId: string };
}) =>
operation === "acp-spawn" && conversation.conversationId.includes(":topic:")
? { telegram: { pin: true } }
? { delivery: { pin: { enabled: true } } }
: null,
},
bindings: {
@@ -1252,7 +1252,7 @@ describe("/acp command", () => {
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this conversation to");
expect(result?.reply?.channelData).toEqual({ telegram: { pin: true } });
expect(result?.reply?.delivery).toEqual({ pin: { enabled: true } });
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",

View File

@@ -43,6 +43,7 @@ import {
type SessionBindingService,
} from "../../../infra/outbound/session-binding-service.js";
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
import type { ReplyPayload } from "../../types.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import {
resolveAcpCommandAccountId,
@@ -77,20 +78,19 @@ function resolveAcpBindingLabelNoun(params: {
return params.conversationId === params.threadId ? "thread" : "conversation";
}
async function resolveBoundReplyChannelData(params: {
async function resolveBoundReplyPayload(params: {
binding: SessionBindingRecord;
placement: "current" | "child";
}): Promise<Record<string, unknown> | undefined> {
}): Promise<Pick<ReplyPayload, "channelData" | "delivery" | "presentation"> | undefined> {
const channelId = normalizeChannelId(params.binding.conversation.channel);
if (!channelId) {
return undefined;
}
const buildChannelData =
getChannelPlugin(channelId)?.conversationBindings?.buildBoundReplyChannelData;
if (!buildChannelData) {
const buildPayload = getChannelPlugin(channelId)?.conversationBindings?.buildBoundReplyPayload;
if (!buildPayload) {
return undefined;
}
const resolved = await buildChannelData({
const resolved = await buildPayload({
operation: "acp-spawn",
placement: params.placement,
conversation: params.binding.conversation,
@@ -621,16 +621,16 @@ export async function handleAcpSpawnAction(
} else {
parts.push(`Created ${placementLabel} ${boundConversationId} and bound it to ${sessionKey}.`);
}
const channelData = await resolveBoundReplyChannelData({
const boundReplyPayload = await resolveBoundReplyPayload({
binding,
placement: bindingPlacement,
});
if (channelData) {
if (boundReplyPayload) {
return {
shouldContinue: false,
reply: {
text: parts.join(" "),
channelData,
...boundReplyPayload,
},
};
}

View File

@@ -52,12 +52,12 @@ function createMessageActionsPlugin(params: {
const buttonsPlugin = createMessageActionsPlugin({
id: "demo-buttons",
capabilities: ["interactive", "buttons"],
capabilities: ["presentation"],
});
const cardsPlugin = createMessageActionsPlugin({
id: "demo-cards",
capabilities: ["cards"],
capabilities: ["delivery-pin"],
});
function activateMessageActionTestRegistry() {
@@ -82,13 +82,11 @@ describe("message action capability checks", () => {
activateMessageActionTestRegistry();
expect(listChannelMessageCapabilities({} as OpenClawConfig).toSorted()).toEqual([
"buttons",
"cards",
"interactive",
"delivery-pin",
"presentation",
]);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "interactive")).toBe(true);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "buttons")).toBe(true);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "cards")).toBe(true);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "presentation")).toBe(true);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "delivery-pin")).toBe(true);
});
it("checks per-channel capabilities", () => {
@@ -99,46 +97,40 @@ describe("message action capability checks", () => {
cfg: {} as OpenClawConfig,
channel: "demo-buttons",
}),
).toEqual(["interactive", "buttons"]);
).toEqual(["presentation"]);
expect(
listChannelMessageCapabilitiesForChannel({
cfg: {} as OpenClawConfig,
channel: "demo-cards",
}),
).toEqual(["cards"]);
).toEqual(["delivery-pin"]);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-buttons" },
"interactive",
"presentation",
),
).toBe(true);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-cards" },
"interactive",
"presentation",
),
).toBe(false);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-buttons" },
"buttons",
),
).toBe(true);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-cards" },
"buttons",
"delivery-pin",
),
).toBe(false);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-cards" },
"cards",
"delivery-pin",
),
).toBe(true);
expect(channelSupportsMessageCapabilityForChannel({ cfg: {} as OpenClawConfig }, "cards")).toBe(
false,
);
expect(
channelSupportsMessageCapabilityForChannel({ cfg: {} as OpenClawConfig }, "delivery-pin"),
).toBe(false);
});
it("normalizes channel aliases for per-channel capability checks", () => {
@@ -150,7 +142,7 @@ describe("message action capability checks", () => {
plugin: createMessageActionsPlugin({
id: "demo-cards",
aliases: ["demo-cards-alias"],
capabilities: ["cards"],
capabilities: ["delivery-pin"],
}),
},
]),
@@ -161,7 +153,7 @@ describe("message action capability checks", () => {
cfg: {} as OpenClawConfig,
channel: "demo-cards-alias",
}),
).toEqual(["cards"]);
).toEqual(["delivery-pin"]);
});
it("uses unified message tool discovery for actions, capabilities, and schema", () => {
@@ -177,7 +169,7 @@ describe("message action capability checks", () => {
actions: {
describeMessageTool: () => ({
actions: ["react"],
capabilities: ["interactive"],
capabilities: ["presentation"],
schema: {
properties: {
components: Type.Array(Type.String()),
@@ -191,7 +183,7 @@ describe("message action capability checks", () => {
);
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast", "react"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual(["interactive"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual(["presentation"]);
expect(
resolveChannelMessageToolSchemaProperties({
cfg: {} as OpenClawConfig,

View File

@@ -1,9 +1,3 @@
export const CHANNEL_MESSAGE_CAPABILITIES = [
"interactive",
"buttons",
"cards",
"components",
"blocks",
] as const;
export const CHANNEL_MESSAGE_CAPABILITIES = ["presentation", "delivery-pin"] as const;
export type ChannelMessageCapability = (typeof CHANNEL_MESSAGE_CAPABILITIES)[number];

View File

@@ -32,17 +32,17 @@ const slackPlugin: Pick<ChannelPlugin, "actions"> = {
account.appToken.trim() !== "";
const capabilities = new Set<string>();
if (enabled) {
capabilities.add("blocks");
capabilities.add("presentation");
}
if (
account?.capabilities &&
(account.capabilities as { interactiveReplies?: unknown }).interactiveReplies === true
) {
capabilities.add("interactive");
capabilities.add("presentation");
}
return {
actions: enabled ? ["send"] : [],
capabilities: Array.from(capabilities) as Array<"blocks" | "interactive">,
capabilities: Array.from(capabilities) as Array<"presentation">,
};
},
supportsAction: () => true,
@@ -61,7 +61,7 @@ const mattermostPlugin: Pick<ChannelPlugin, "actions"> = {
account.baseUrl.trim() !== "";
return {
actions: enabled ? ["send"] : [],
capabilities: enabled ? (["buttons"] as const) : [],
capabilities: enabled ? (["presentation"] as const) : [],
};
},
supportsAction: () => true,
@@ -80,7 +80,7 @@ const feishuPlugin: Pick<ChannelPlugin, "actions"> = {
account.appSecret.trim() !== "";
return {
actions: enabled ? ["send"] : [],
capabilities: enabled ? (["cards"] as const) : [],
capabilities: enabled ? (["presentation"] as const) : [],
};
},
supportsAction: () => true,
@@ -101,7 +101,7 @@ const msteamsPlugin: Pick<ChannelPlugin, "actions"> = {
account.appPassword.trim() !== "";
return {
actions: enabled ? ["poll"] : [],
capabilities: enabled ? (["cards"] as const) : [],
capabilities: enabled ? (["presentation"] as const) : [],
};
},
supportsAction: () => true,
@@ -127,7 +127,7 @@ describe("channel action capability matrix", () => {
return [...(describeMessageTool?.({ cfg })?.capabilities ?? [])];
}
it("exposes Slack blocks by default and interactive when enabled", () => {
it("exposes Slack presentation when configured", () => {
const baseCfg = {
channels: {
slack: {
@@ -146,26 +146,26 @@ describe("channel action capability matrix", () => {
},
} as OpenClawConfig;
expect(getCapabilities(slackPlugin, baseCfg)).toEqual(["blocks"]);
expect(getCapabilities(slackPlugin, interactiveCfg)).toEqual(["blocks", "interactive"]);
expect(getCapabilities(slackPlugin, baseCfg)).toEqual(["presentation"]);
expect(getCapabilities(slackPlugin, interactiveCfg)).toEqual(["presentation"]);
});
it("forwards Telegram action capabilities through the channel wrapper", () => {
telegramDescribeMessageToolMock.mockReturnValue({
capabilities: ["interactive", "buttons"],
capabilities: ["presentation"],
});
const result = getCapabilities(telegramPlugin, {} as OpenClawConfig);
expect(result).toEqual(["interactive", "buttons"]);
expect(result).toEqual(["presentation"]);
expect(telegramDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} });
discordDescribeMessageToolMock.mockReturnValue({
capabilities: ["interactive", "components"],
capabilities: ["presentation"],
});
const discordResult = getCapabilities(discordPlugin, {} as OpenClawConfig);
expect(discordResult).toEqual(["interactive", "components"]);
expect(discordResult).toEqual(["presentation"]);
expect(discordDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} });
});
@@ -225,11 +225,11 @@ describe("channel action capability matrix", () => {
},
} as OpenClawConfig;
expect(getCapabilities(mattermostPlugin, configuredCfg)).toEqual(["buttons"]);
expect(getCapabilities(mattermostPlugin, configuredCfg)).toEqual(["presentation"]);
expect(getCapabilities(mattermostPlugin, unconfiguredCfg)).toEqual([]);
expect(getCapabilities(feishuPlugin, configuredFeishuCfg)).toEqual(["cards"]);
expect(getCapabilities(feishuPlugin, configuredFeishuCfg)).toEqual(["presentation"]);
expect(getCapabilities(feishuPlugin, disabledFeishuCfg)).toEqual([]);
expect(getCapabilities(msteamsPlugin, configuredMsteamsCfg)).toEqual(["cards"]);
expect(getCapabilities(msteamsPlugin, configuredMsteamsCfg)).toEqual(["presentation"]);
expect(getCapabilities(msteamsPlugin, disabledMsteamsCfg)).toEqual([]);
});

View File

@@ -7,7 +7,7 @@ const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({
return {
describeMessageTool: () => ({
actions: ["send", "upload-file"],
capabilities: ["blocks"],
capabilities: ["presentation"],
schema: null,
}),
};
@@ -45,7 +45,7 @@ describe("bundled channel message tool fast path", () => {
const adapter = resolveBundledChannelMessageToolDiscoveryAdapter("slack");
expect(adapter?.describeMessageTool?.({ cfg: {} })).toMatchObject({
actions: ["send", "upload-file"],
capabilities: ["blocks"],
capabilities: ["presentation"],
});
expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({
dirName: "slack",
@@ -61,7 +61,7 @@ describe("bundled channel message tool fast path", () => {
}),
).toMatchObject({
actions: ["send", "upload-file"],
capabilities: ["blocks"],
capabilities: ["presentation"],
});
});

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver-types.js";
import type { OutboundIdentity } from "../../infra/outbound/identity-types.js";
import type { OutboundSendDeps } from "../../infra/outbound/send-deps.js";
import type { MessagePresentation, ReplyPayloadDeliveryPin } from "../../interactive/payload.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
import type {
ChannelOutboundTargetMode,
@@ -35,6 +36,18 @@ export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
payload: ReplyPayload;
};
export type ChannelPresentationCapabilities = {
supported?: boolean;
buttons?: boolean;
selects?: boolean;
context?: boolean;
divider?: boolean;
};
export type ChannelDeliveryCapabilities = {
pin?: boolean;
};
export type ChannelOutboundPayloadHint =
| { kind: "approval-pending"; approvalKind: "exec" | "plugin" }
| { kind: "approval-resolved"; approvalKind: "exec" | "plugin" };
@@ -78,6 +91,19 @@ export type ChannelOutboundAdapter = {
payload: ReplyPayload;
hint?: ChannelOutboundPayloadHint;
}) => Promise<void> | void;
presentationCapabilities?: ChannelPresentationCapabilities;
deliveryCapabilities?: ChannelDeliveryCapabilities;
renderPresentation?: (params: {
payload: ReplyPayload;
presentation: MessagePresentation;
ctx: ChannelOutboundPayloadContext;
}) => Promise<ReplyPayload | null> | ReplyPayload | null;
pinDeliveredMessage?: (params: {
cfg: OpenClawConfig;
target: ChannelOutboundTargetRef;
messageId: string;
pin: ReplyPayloadDeliveryPin;
}) => Promise<void> | void;
/**
* @deprecated Use shouldTreatDeliveredTextAsVisible instead.
*/

View File

@@ -762,7 +762,7 @@ export type ChannelConversationBindingSupport = {
conversationId: string;
parentConversationId?: string;
} | null;
buildBoundReplyChannelData?: (params: {
buildBoundReplyPayload?: (params: {
operation: "acp-spawn";
placement: "current" | "child";
conversation: {
@@ -771,7 +771,10 @@ export type ChannelConversationBindingSupport = {
conversationId: string;
parentConversationId?: string;
};
}) => ReplyPayload["channelData"] | null | Promise<ReplyPayload["channelData"] | null>;
}) =>
| Pick<ReplyPayload, "channelData" | "delivery" | "presentation">
| null
| Promise<Pick<ReplyPayload, "channelData" | "delivery" | "presentation"> | null>;
buildModelOverrideParentCandidates?: (params: {
parentConversationId?: string | null;
}) => string[] | null | undefined;

View File

@@ -5,6 +5,7 @@ import type { MsgContext } from "../../auto-reply/templating.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { GatewayClientMode, GatewayClientName } from "../../gateway/protocol/client-info.js";
import type { MessagePresentation } from "../../interactive/payload.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
import type { PollInput } from "../../polls.js";
import type { ChatType } from "../chat-type.js";
@@ -322,12 +323,12 @@ export type ChannelStreamingAdapter = {
// their side and cast at the boundary.
export type ChannelStructuredComponents = unknown[];
export type ChannelCrossContextComponentsFactory = (params: {
export type ChannelCrossContextPresentationFactory = (params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) => ChannelStructuredComponents;
}) => MessagePresentation;
export type ChannelReplyTransport = {
replyToId?: string | null;
@@ -520,7 +521,7 @@ export type ChannelMessagingAdapter = {
* is part of the destination identity, not a transient reply thread.
*/
preserveHeartbeatThreadIdForGroupRoute?: boolean;
buildCrossContextComponents?: ChannelCrossContextComponentsFactory;
buildCrossContextPresentation?: ChannelCrossContextPresentationFactory;
transformReplyPayload?: (params: {
payload: ReplyPayload;
cfg: OpenClawConfig;

View File

@@ -79,6 +79,7 @@ export type {
ChannelStatusIssue,
ChannelStreamingAdapter,
ChannelStructuredComponents,
ChannelCrossContextPresentationFactory,
ChannelThreadingAdapter,
ChannelThreadingContext,
ChannelThreadingToolContext,

View File

@@ -428,23 +428,34 @@ describe("runDaemonInstall", () => {
},
} as never);
await runDaemonInstall({ json: true, force: true });
const previous = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
await runDaemonInstall({ json: true, force: true });
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENAI_API_KEY: "service-openai-key",
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENAI_API_KEY: "service-openai-key",
}),
}),
}),
);
const [firstArg] =
(buildGatewayInstallPlanMock.mock.calls.at(0) as [Record<string, unknown>] | undefined) ?? [];
const env = firstArg?.env as Record<string, string | undefined>;
expect(env.OPENCLAW_STATE_DIR).toBeUndefined();
expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(env.NODE_OPTIONS).toBeUndefined();
expect(env.PATH).not.toContain("/tmp/doctor-bin");
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
);
const [firstArg] =
(buildGatewayInstallPlanMock.mock.calls.at(0) as [Record<string, unknown>] | undefined) ??
[];
const env = firstArg?.env as Record<string, string | undefined>;
expect(env.OPENCLAW_STATE_DIR).toBeUndefined();
expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(env.NODE_OPTIONS).toBeUndefined();
expect(env.PATH).not.toContain("/tmp/doctor-bin");
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
} finally {
if (previous === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = previous;
}
}
});
});

View File

@@ -16,15 +16,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option(
"--interactive <json>",
"Shared interactive payload as JSON (buttons/selects rendered natively by supported channels)",
"--presentation <json>",
"Shared presentation payload as JSON (text, context, dividers, buttons, selects)",
)
.option(
"--buttons <json>",
"Telegram inline keyboard buttons as JSON (array of button rows)",
)
.option("--components <json>", "Discord components payload as JSON")
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
.option("--delivery <json>", "Shared delivery preferences as JSON")
.option("--pin", "Request that the delivered message be pinned when supported", false)
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)

View File

@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { getChannelMessageAdapter } from "./channel-adapters.js";
class TestTextDisplay {
constructor(readonly content: string) {}
}
class TestSeparator {
constructor(readonly options: { divider: boolean; spacing: string }) {}
}
class TestRichUiContainer {
constructor(readonly components: Array<TestTextDisplay | TestSeparator>) {}
}
const richCrossContextPlugin: Pick<
ChannelPlugin,
"id" | "meta" | "capabilities" | "config" | "messaging"
> = {
...createChannelTestPluginBase({ id: "rich-chat" }),
messaging: {
buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => {
const trimmed = message.trim();
const components: Array<TestTextDisplay | TestSeparator> = [];
if (trimmed) {
components.push(new TestTextDisplay(message));
components.push(new TestSeparator({ divider: true, spacing: "small" }));
}
components.push(new TestTextDisplay(`*From ${originLabel}*`));
void cfg;
void accountId;
return [new TestRichUiContainer(components)];
},
},
};
describe("getChannelMessageAdapter", () => {
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "rich-chat", plugin: richCrossContextPlugin, source: "test" },
{
pluginId: "plain-chat",
plugin: createChannelTestPluginBase({ id: "plain-chat" }),
source: "test",
},
]),
);
});
it("returns the default adapter for channels without structured component support", () => {
expect(getChannelMessageAdapter("plain-chat")).toEqual({
supportsComponentsV2: false,
});
});
it("returns an adapter with a cross-context component builder", () => {
const adapter = getChannelMessageAdapter("rich-chat");
expect(adapter.supportsComponentsV2).toBe(true);
expect(adapter.buildCrossContextComponents).toBeTypeOf("function");
const components = adapter.buildCrossContextComponents?.({
originLabel: "Forum",
message: "Hello from chat",
cfg: {} as never,
accountId: "primary",
});
const container = components?.[0] as TestRichUiContainer | undefined;
expect(components).toHaveLength(1);
expect(container).toBeInstanceOf(TestRichUiContainer);
expect(container?.components).toEqual([
expect.any(TestTextDisplay),
expect.any(TestSeparator),
expect.any(TestTextDisplay),
]);
});
it.each([
{
message: "Hello from chat",
originLabel: "Forum",
accountId: "primary",
expectedComponents: [
expect.any(TestTextDisplay),
expect.any(TestSeparator),
expect.any(TestTextDisplay),
],
},
{
message: " ",
originLabel: "Pager",
expectedComponents: [expect.any(TestTextDisplay)],
},
])(
"builds cross-context components for %j",
({ message, originLabel, accountId, expectedComponents }) => {
const adapter = getChannelMessageAdapter("rich-chat");
const components = adapter.buildCrossContextComponents?.({
originLabel,
message,
cfg: {} as never,
...(accountId ? { accountId } : {}),
});
const container = components?.[0] as TestRichUiContainer | undefined;
expect(components).toHaveLength(1);
expect(container?.components).toEqual(expectedComponents);
},
);
});

View File

@@ -1,35 +0,0 @@
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type {
ChannelId,
ChannelStructuredComponents,
} from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
export type CrossContextComponentsBuilder = (message: string) => ChannelStructuredComponents;
export type CrossContextComponentsFactory = (params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) => ChannelStructuredComponents;
export type ChannelMessageAdapter = {
supportsComponentsV2: boolean;
buildCrossContextComponents?: CrossContextComponentsFactory;
};
const DEFAULT_ADAPTER: ChannelMessageAdapter = {
supportsComponentsV2: false,
};
export function getChannelMessageAdapter(channel: ChannelId): ChannelMessageAdapter {
const adapter = getChannelPlugin(channel)?.messaging?.buildCrossContextComponents;
if (adapter) {
return {
supportsComponentsV2: true,
buildCrossContextComponents: adapter,
};
}
return DEFAULT_ADAPTER;
}

View File

@@ -1129,6 +1129,145 @@ describe("deliverOutboundPayloads", () => {
);
});
it("does not fail successful sends when optional delivery pinning fails", async () => {
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
const pinDeliveredMessage = vi.fn().mockRejectedValue(new Error("pin denied"));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText, pinDeliveredMessage },
}),
},
]),
);
const results = await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: "hello", delivery: { pin: true } }],
});
expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]);
expect(pinDeliveredMessage).toHaveBeenCalledTimes(1);
expect(logMocks.warn).toHaveBeenCalledWith(
"Delivery pin requested, but channel failed to pin delivered message.",
expect.objectContaining({
channel: "matrix",
messageId: "mx-1",
error: "pin denied",
}),
);
});
it("fails sends when required delivery pinning fails", async () => {
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
const pinDeliveredMessage = vi.fn().mockRejectedValue(new Error("pin denied"));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText, pinDeliveredMessage },
}),
},
]),
);
await expect(
deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: "hello", delivery: { pin: { enabled: true, required: true } } }],
}),
).rejects.toThrow("pin denied");
});
it("pins the first delivered text chunk for chunked payloads", async () => {
const sendText = vi
.fn()
.mockResolvedValueOnce({ channel: "matrix", messageId: "mx-1" })
.mockResolvedValueOnce({ channel: "matrix", messageId: "mx-2" });
const pinDeliveredMessage = vi.fn();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 2,
sendText,
pinDeliveredMessage,
},
}),
},
]),
);
await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: "abcd", delivery: { pin: true } }],
});
expect(sendText).toHaveBeenCalledTimes(2);
expect(pinDeliveredMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageId: "mx-1" }),
);
});
it("pins the first delivered media message for multi-media payloads", async () => {
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-text" });
const sendMedia = vi
.fn()
.mockResolvedValueOnce({ channel: "matrix", messageId: "mx-1" })
.mockResolvedValueOnce({ channel: "matrix", messageId: "mx-2" });
const pinDeliveredMessage = vi.fn();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText, sendMedia, pinDeliveredMessage },
}),
},
]),
);
await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [
{
text: "caption",
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
delivery: { pin: true },
},
],
});
expect(sendMedia).toHaveBeenCalledTimes(2);
expect(pinDeliveredMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageId: "mx-1" }),
);
});
it("preserves channelData-only payloads with empty text for sendPayload channels", async () => {
const sendPayload = vi.fn().mockResolvedValue({ channel: "line", messageId: "ln-1" });
const sendText = vi.fn();

View File

@@ -10,6 +10,8 @@ import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load
import type {
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelOutboundPayloadContext,
ChannelOutboundTargetRef,
} from "../../channels/plugins/types.adapters.js";
import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -21,7 +23,12 @@ import {
toPluginMessageContext,
toPluginMessageSentEvent,
} from "../../hooks/message-hook-mappers.js";
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import {
hasReplyPayloadContent,
normalizeMessagePresentation,
renderMessagePresentationFallbackText,
type ReplyPayloadDeliveryPin,
} from "../../interactive/payload.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
@@ -76,6 +83,13 @@ type ChannelHandler = {
supportsMedia: boolean;
sanitizeText?: (payload: ReplyPayload) => string;
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
renderPresentation?: (payload: ReplyPayload) => Promise<ReplyPayload | null>;
pinDeliveredMessage?: (params: {
target: ChannelOutboundTargetRef;
messageId: string;
pin: ReplyPayloadDeliveryPin;
}) => Promise<void>;
buildTargetRef: (overrides?: { threadId?: string | number | null }) => ChannelOutboundTargetRef;
shouldSkipPlainTextSanitization?: (payload: ReplyPayload) => boolean;
resolveEffectiveTextChunkLimit?: (fallbackLimit?: number) => number | undefined;
sendPayload?: (
@@ -178,6 +192,14 @@ function createPluginHandler(
threadId: overrides?.threadId ?? baseCtx.threadId,
audioAsVoice: overrides?.audioAsVoice,
});
const buildTargetRef = (overrides?: {
threadId?: string | number | null;
}): ChannelOutboundTargetRef => ({
channel: params.channel,
to: params.to,
accountId: params.accountId ?? undefined,
threadId: overrides?.threadId ?? baseCtx.threadId,
});
return {
chunker,
chunkerMode,
@@ -189,6 +211,34 @@ function createPluginHandler(
normalizePayload: outbound.normalizePayload
? (payload) => outbound.normalizePayload!({ payload })
: undefined,
renderPresentation: outbound.renderPresentation
? async (payload) => {
const presentation = normalizeMessagePresentation(payload.presentation);
if (!presentation) {
return payload;
}
const ctx: ChannelOutboundPayloadContext = {
...resolveCtx({
replyToId: payload.replyToId ?? baseCtx.replyToId,
threadId: baseCtx.threadId,
audioAsVoice: payload.audioAsVoice,
}),
text: payload.text ?? "",
mediaUrl: payload.mediaUrl,
payload,
};
return await outbound.renderPresentation!({ payload, presentation, ctx });
}
: undefined,
pinDeliveredMessage: outbound.pinDeliveredMessage
? async ({ target, messageId, pin }) =>
outbound.pinDeliveredMessage!({
cfg: params.cfg,
target,
messageId,
pin,
})
: undefined,
shouldSkipPlainTextSanitization: outbound.shouldSkipPlainTextSanitization
? (payload) => outbound.shouldSkipPlainTextSanitization!({ payload })
: undefined,
@@ -229,6 +279,7 @@ function createPluginHandler(
...resolveCtx(overrides),
text,
}),
buildTargetRef,
sendMedia: async (caption, mediaUrl, overrides) => {
if (sendMedia) {
return sendMedia({
@@ -355,6 +406,99 @@ function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
return summarizeOutboundPayloadForTransport(payload);
}
function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined {
const pin = payload.delivery?.pin;
if (pin === true) {
return { enabled: true };
}
if (!pin || typeof pin !== "object" || Array.isArray(pin)) {
return undefined;
}
if (!pin.enabled) {
return undefined;
}
const normalized: ReplyPayloadDeliveryPin = { enabled: true };
if (pin.notify === true) {
normalized.notify = true;
}
if (pin.required === true) {
normalized.required = true;
}
return normalized;
}
async function maybePinDeliveredMessage(params: {
handler: ChannelHandler;
payload: ReplyPayload;
target: ChannelOutboundTargetRef;
messageId?: string;
}): Promise<void> {
const pin = normalizeDeliveryPin(params.payload);
if (!pin) {
return;
}
if (!params.messageId) {
if (pin.required) {
throw new Error("Delivery pin requested, but no delivered message id was returned.");
}
log.warn("Delivery pin requested, but no delivered message id was returned.", {
channel: params.target.channel,
to: params.target.to,
});
return;
}
if (!params.handler.pinDeliveredMessage) {
if (pin.required) {
throw new Error(`Delivery pin is not supported by channel: ${params.target.channel}`);
}
log.warn("Delivery pin requested, but channel does not support pinning delivered messages.", {
channel: params.target.channel,
to: params.target.to,
});
return;
}
try {
await params.handler.pinDeliveredMessage({
target: params.target,
messageId: params.messageId,
pin,
});
} catch (err) {
if (pin.required) {
throw err;
}
log.warn("Delivery pin requested, but channel failed to pin delivered message.", {
channel: params.target.channel,
to: params.target.to,
messageId: params.messageId,
error: formatErrorMessage(err),
});
}
}
async function renderPresentationForDelivery(
handler: ChannelHandler,
payload: ReplyPayload,
): Promise<ReplyPayload> {
const presentation = normalizeMessagePresentation(payload.presentation);
if (!presentation) {
return payload;
}
const rendered = handler.renderPresentation ? await handler.renderPresentation(payload) : null;
if (rendered) {
const { presentation: _presentation, ...withoutPresentation } = rendered;
return withoutPresentation;
}
const { presentation: _presentation, ...withoutPresentation } = payload;
return {
...withoutPresentation,
text: renderMessagePresentationFallbackText({
text: payload.text,
presentation,
}),
};
}
function createMessageSentEmitter(params: {
hookRunner: ReturnType<typeof getGlobalHookRunner>;
channel: Exclude<OutboundChannel, "none">;
@@ -687,8 +831,8 @@ async function deliverOutboundPayloadsCore(
if (hookResult.cancelled) {
continue;
}
const effectivePayload = hookResult.payload;
payloadSummary = hookResult.payloadSummary;
const effectivePayload = await renderPresentationForDelivery(handler, hookResult.payload);
payloadSummary = buildPayloadSummary(effectivePayload);
params.onPayload?.(payloadSummary);
const sendOverrides = {
@@ -697,15 +841,23 @@ async function deliverOutboundPayloadsCore(
audioAsVoice: effectivePayload.audioAsVoice === true ? true : undefined,
forceDocument: params.forceDocument,
};
const deliveryTarget = handler.buildTargetRef({ threadId: sendOverrides.threadId });
if (
handler.sendPayload &&
hasReplyPayloadContent({
presentation: effectivePayload.presentation,
interactive: effectivePayload.interactive,
channelData: effectivePayload.channelData,
})
) {
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);
results.push(delivery);
await maybePinDeliveredMessage({
handler,
payload: effectivePayload,
target: deliveryTarget,
messageId: delivery.messageId,
});
emitMessageSent({
success: true,
content: payloadSummary.text,
@@ -720,7 +872,15 @@ async function deliverOutboundPayloadsCore(
} else {
await sendTextChunks(payloadSummary.text, sendOverrides);
}
const deliveredResults = results.slice(beforeCount);
const messageId = results.at(-1)?.messageId;
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
await maybePinDeliveredMessage({
handler,
payload: effectivePayload,
target: deliveryTarget,
messageId: pinMessageId,
});
emitMessageSent({
success: results.length > beforeCount,
content: payloadSummary.text,
@@ -746,7 +906,15 @@ async function deliverOutboundPayloadsCore(
}
const beforeCount = results.length;
await sendTextChunks(fallbackText, sendOverrides);
const deliveredResults = results.slice(beforeCount);
const messageId = results.at(-1)?.messageId;
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
await maybePinDeliveredMessage({
handler,
payload: effectivePayload,
target: deliveryTarget,
messageId: pinMessageId,
});
emitMessageSent({
success: results.length > beforeCount,
content: payloadSummary.text,
@@ -755,6 +923,7 @@ async function deliverOutboundPayloadsCore(
continue;
}
let firstMessageId: string | undefined;
let lastMessageId: string | undefined;
await sendMediaWithLeadingCaption({
mediaUrls: payloadSummary.mediaUrls,
@@ -768,14 +937,22 @@ async function deliverOutboundPayloadsCore(
sendOverrides,
);
results.push(delivery);
firstMessageId ??= delivery.messageId;
lastMessageId = delivery.messageId;
return;
}
const delivery = await handler.sendMedia(caption ?? "", mediaUrl, sendOverrides);
results.push(delivery);
firstMessageId ??= delivery.messageId;
lastMessageId = delivery.messageId;
},
});
await maybePinDeliveredMessage({
handler,
payload: effectivePayload,
target: deliveryTarget,
messageId: firstMessageId,
});
emitMessageSent({
success: true,
content: payloadSummary.text,

View File

@@ -5,14 +5,11 @@ const STANDARD_MESSAGE_ACTION_PARAM_KEYS = new Set([
"asDocument",
"base64",
"bestEffort",
"blocks",
"buttons",
"caption",
"card",
"channel",
"channelId",
"components",
"contentType",
"delivery",
"dryRun",
"filePath",
"fileUrl",
@@ -32,6 +29,8 @@ const STANDARD_MESSAGE_ACTION_PARAM_KEYS = new Set([
"pollOption",
"pollPublic",
"pollQuestion",
"pin",
"presentation",
"replyTo",
"silent",
"target",

View File

@@ -399,54 +399,20 @@ export async function hydrateAttachmentParamsForAction(params: {
});
}
export function parseButtonsParam(params: Record<string, unknown>): void {
const raw = params.buttons;
export function parseJsonMessageParam(params: Record<string, unknown>, key: string): void {
const raw = params[key];
if (typeof raw !== "string") {
return;
}
const trimmed = raw.trim();
if (!trimmed) {
delete params.buttons;
delete params[key];
return;
}
try {
params.buttons = JSON.parse(trimmed) as unknown;
params[key] = JSON.parse(trimmed) as unknown;
} catch {
throw new Error("--buttons must be valid JSON");
}
}
export function parseCardParam(params: Record<string, unknown>): void {
const raw = params.card;
if (typeof raw !== "string") {
return;
}
const trimmed = raw.trim();
if (!trimmed) {
delete params.card;
return;
}
try {
params.card = JSON.parse(trimmed) as unknown;
} catch {
throw new Error("--card must be valid JSON");
}
}
export function parseComponentsParam(params: Record<string, unknown>): void {
const raw = params.components;
if (typeof raw !== "string") {
return;
}
const trimmed = raw.trim();
if (!trimmed) {
delete params.components;
return;
}
try {
params.components = JSON.parse(trimmed) as unknown;
} catch {
throw new Error("--components must be valid JSON");
throw new Error(`--${key} must be valid JSON`);
}
}

View File

@@ -743,11 +743,11 @@ describe("runMessageAction plugin dispatch", () => {
});
});
describe("card-only send behavior", () => {
describe("presentation-only send behavior", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
card: params.card ?? null,
presentation: params.presentation ?? null,
message: params.message ?? null,
}),
);
@@ -764,7 +764,7 @@ describe("runMessageAction plugin dispatch", () => {
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
actions: {
describeMessageTool: () => ({ actions: ["send"] }),
describeMessageTool: () => ({ actions: ["send"], capabilities: ["presentation"] }),
supportsAction: ({ action }) => action === "send",
handleAction,
},
@@ -788,7 +788,7 @@ describe("runMessageAction plugin dispatch", () => {
vi.clearAllMocks();
});
it("allows card-only sends without text or media", async () => {
it("allows presentation-only sends without text or media", async () => {
const cfg = {
channels: {
cardchat: {
@@ -797,10 +797,8 @@ describe("runMessageAction plugin dispatch", () => {
},
} as OpenClawConfig;
const card = {
type: "AdaptiveCard",
version: "1.4",
body: [{ type: "TextBlock", text: "Card-only payload" }],
const presentation = {
blocks: [{ type: "text", text: "Presentation-only payload" }],
};
const result = await runMessageAction({
@@ -809,7 +807,7 @@ describe("runMessageAction plugin dispatch", () => {
params: {
channel: "cardchat",
target: "channel:test-card",
card,
presentation,
},
dryRun: false,
});
@@ -819,7 +817,7 @@ describe("runMessageAction plugin dispatch", () => {
expect(handleAction).toHaveBeenCalled();
expect(result.payload).toMatchObject({
ok: true,
card,
presentation,
});
});
});
@@ -994,11 +992,11 @@ describe("runMessageAction plugin dispatch", () => {
});
});
describe("components parsing", () => {
describe("presentation parsing", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
components: params.components ?? null,
presentation: params.presentation ?? null,
}),
);
@@ -1014,7 +1012,7 @@ describe("runMessageAction plugin dispatch", () => {
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig({}),
actions: {
describeMessageTool: () => ({ actions: ["send"] }),
describeMessageTool: () => ({ actions: ["send"], capabilities: ["presentation"] }),
supportsAction: ({ action }) => action === "send",
handleAction,
},
@@ -1038,10 +1036,9 @@ describe("runMessageAction plugin dispatch", () => {
vi.clearAllMocks();
});
it("parses components JSON strings before plugin dispatch", async () => {
const components = {
text: "hello",
buttons: [{ label: "A", customId: "a" }],
it("parses presentation JSON strings before plugin dispatch", async () => {
const presentation = {
blocks: [{ type: "buttons", buttons: [{ label: "A", value: "a" }] }],
};
const result = await runMessageAction({
cfg: {} as OpenClawConfig,
@@ -1050,17 +1047,17 @@ describe("runMessageAction plugin dispatch", () => {
channel: "componentchat",
target: "channel:123",
message: "hi",
components: JSON.stringify(components),
presentation: JSON.stringify(presentation),
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(handleAction).toHaveBeenCalled();
expect(result.payload).toMatchObject({ ok: true, components });
expect(result.payload).toMatchObject({ ok: true, presentation });
});
it("throws on invalid components JSON strings", async () => {
it("throws on invalid presentation JSON strings", async () => {
await expect(
runMessageAction({
cfg: {} as OpenClawConfig,
@@ -1069,11 +1066,11 @@ describe("runMessageAction plugin dispatch", () => {
channel: "componentchat",
target: "channel:123",
message: "hi",
components: "{not-json}",
presentation: "{not-json}",
},
dryRun: false,
}),
).rejects.toThrow(/--components must be valid JSON/);
).rejects.toThrow(/--presentation must be valid JSON/);
expect(handleAction).not.toHaveBeenCalled();
});

View File

@@ -44,7 +44,7 @@ describe("runMessageAction send validation", () => {
).rejects.toThrow(/message required/i);
});
it("allows send when only shared interactive payloads are provided", async () => {
it("allows send when only presentation payloads are provided", async () => {
const result = await runDrySend({
cfg: {
channels: {
@@ -56,7 +56,7 @@ describe("runMessageAction send validation", () => {
actionParams: {
channel: "forum",
target: "123456",
interactive: {
presentation: {
blocks: [
{
type: "buttons",
@@ -70,13 +70,13 @@ describe("runMessageAction send validation", () => {
expect(result.kind).toBe("send");
});
it("allows send when only channel-specific blocks are provided", async () => {
it("allows send when only generic presentation blocks are provided", async () => {
const result = await runDrySend({
cfg: workspaceConfig,
actionParams: {
channel: "workspace",
target: "#C12345678",
blocks: [{ type: "divider" }],
presentation: { blocks: [{ type: "divider" }] },
},
toolContext: { currentChannelId: "C12345678" },
});

View File

@@ -14,7 +14,12 @@ import type {
ChannelThreadingToolContext,
} from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { hasInteractiveReplyBlocks, hasReplyPayloadContent } from "../../interactive/payload.js";
import {
hasInteractiveReplyBlocks,
hasMessagePresentationBlocks,
hasReplyPayloadContent,
normalizeMessagePresentation,
} from "../../interactive/payload.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
@@ -46,10 +51,8 @@ import {
hydrateAttachmentParamsForAction,
normalizeSandboxMediaList,
normalizeSandboxMediaParams,
parseButtonsParam,
parseCardParam,
parseComponentsParam,
parseInteractiveParam,
parseJsonMessageParam,
readBooleanParam,
resolveAttachmentMediaPolicy,
resolveExtraActionMediaSourceParamKeys,
@@ -209,21 +212,27 @@ function applyCrossContextMessageDecoration({
params,
message,
decoration,
preferComponents,
preferPresentation,
}: {
params: Record<string, unknown>;
message: string;
decoration: CrossContextDecoration;
preferComponents: boolean;
preferPresentation: boolean;
}): string {
const applied = applyCrossContextDecoration({
message,
decoration,
preferComponents,
preferPresentation,
});
params.message = applied.message;
if (applied.componentsBuilder) {
params.components = applied.componentsBuilder;
if (applied.presentation) {
const existing = normalizeMessagePresentation(params.presentation);
params.presentation = existing
? {
...existing,
blocks: [...applied.presentation.blocks, ...existing.blocks],
}
: applied.presentation;
}
return applied.message;
}
@@ -237,7 +246,7 @@ async function maybeApplyCrossContextMarker(params: {
accountId?: string | null;
args: Record<string, unknown>;
message: string;
preferComponents: boolean;
preferPresentation: boolean;
}): Promise<string> {
if (!shouldApplyCrossContextMarker(params.action) || !params.toolContext) {
return params.message;
@@ -256,7 +265,7 @@ async function maybeApplyCrossContextMarker(params: {
params: params.args,
message: params.message,
decoration,
preferComponents: params.preferComponents,
preferPresentation: params.preferPresentation,
});
}
@@ -465,6 +474,9 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
throwIfAborted(abortSignal);
const action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true });
if (params.pin === true && params.delivery == null) {
params.delivery = { pin: { enabled: true } };
}
// Support media, path, and filePath parameters for attachments
const mediaHint =
readStringParam(params, "media", { trim: false }) ??
@@ -472,18 +484,12 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false }) ??
readStringParam(params, "fileUrl", { trim: false });
const hasButtons = Array.isArray(params.buttons) && params.buttons.length > 0;
const hasCard = params.card != null && typeof params.card === "object";
const hasComponents = params.components != null && typeof params.components === "object";
const hasPresentation = hasMessagePresentationBlocks(params.presentation);
const hasInteractive = hasInteractiveReplyBlocks(params.interactive);
const hasBlocks =
(Array.isArray(params.blocks) && params.blocks.length > 0) ||
(typeof params.blocks === "string" && params.blocks.trim().length > 0);
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
let message =
readStringParam(params, "message", {
required:
!mediaHint && !hasButtons && !hasCard && !hasComponents && !hasInteractive && !hasBlocks,
required: !mediaHint && !hasPresentation && !hasInteractive,
allowEmpty: true,
}) ?? "";
if (message.includes("\\n")) {
@@ -539,22 +545,18 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
accountId,
args: params,
message,
preferComponents: true,
preferPresentation: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
if (
!hasReplyPayloadContent(
{
text: message,
mediaUrl,
mediaUrls: mergedMediaUrls,
interactive: params.interactive,
},
{
extraContent: hasButtons || hasCard || hasComponents || hasBlocks,
},
)
!hasReplyPayloadContent({
text: message,
mediaUrl,
mediaUrls: mergedMediaUrls,
presentation: params.presentation,
interactive: params.interactive,
})
) {
throw new Error("send requires text or media");
}
@@ -665,7 +667,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
accountId,
args: params,
message: base,
preferComponents: false,
preferPresentation: false,
});
const poll = await executePollAction({
@@ -825,9 +827,8 @@ export async function runMessageAction(
(input.sessionKey
? resolveSessionAgentId({ sessionKey: input.sessionKey, config: cfg })
: undefined);
parseButtonsParam(params);
parseCardParam(params);
parseComponentsParam(params);
parseJsonMessageParam(params, "presentation");
parseJsonMessageParam(params, "delivery");
parseInteractiveParam(params);
const action = input.action;

View File

@@ -8,41 +8,29 @@ let buildCrossContextDecoration: typeof import("./outbound-policy.js").buildCros
let enforceCrossContextPolicy: typeof import("./outbound-policy.js").enforceCrossContextPolicy;
let shouldApplyCrossContextMarker: typeof import("./outbound-policy.js").shouldApplyCrossContextMarker;
class TestTextDisplay {
constructor(readonly content: string) {}
}
class TestSeparator {
constructor(readonly options: { divider: boolean; spacing: string }) {}
}
class TestRichUiContainer {
constructor(readonly components: Array<TestTextDisplay | TestSeparator>) {}
}
const mocks = vi.hoisted(() => ({
getChannelMessageAdapter: vi.fn((channel: string) =>
getChannelPlugin: vi.fn((channel: string) =>
channel === "richchat"
? {
supportsComponentsV2: true,
buildCrossContextComponents: ({
originLabel,
message,
}: {
originLabel: string;
message: string;
}) => {
const trimmed = message.trim();
const components: Array<TestTextDisplay | TestSeparator> = [];
if (trimmed) {
components.push(new TestTextDisplay(message));
components.push(new TestSeparator({ divider: true, spacing: "small" }));
}
components.push(new TestTextDisplay(`*From ${originLabel}*`));
return [new TestRichUiContainer(components)];
messaging: {
buildCrossContextPresentation: ({
originLabel,
message,
}: {
originLabel: string;
message: string;
}) => {
const trimmed = message.trim();
return {
blocks: [
...(trimmed ? [{ type: "text" as const, text: message }] : []),
{ type: "context" as const, text: `From ${originLabel}` },
],
};
},
},
}
: { supportsComponentsV2: false },
: undefined,
),
normalizeTargetForProvider: vi.fn((channel: string, raw: string) => {
const trimmed = raw.trim();
@@ -62,8 +50,8 @@ const mocks = vi.hoisted(() => ({
),
}));
vi.mock("./channel-adapters.js", () => ({
getChannelMessageAdapter: mocks.getChannelMessageAdapter,
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: mocks.getChannelPlugin,
}));
vi.mock("./target-normalization.js", () => ({
@@ -187,7 +175,7 @@ describe("outbound policy helpers", () => {
expectCrossContextPolicyResult(params);
});
it("uses components when available and preferred", async () => {
it("uses presentation when available and preferred", async () => {
const decoration = await buildCrossContextDecoration({
cfg: richChatConfig,
channel: "richchat",
@@ -199,12 +187,11 @@ describe("outbound policy helpers", () => {
const applied = applyCrossContextDecoration({
message: "hello",
decoration: decoration!,
preferComponents: true,
preferPresentation: true,
});
expect(applied.usedComponents).toBe(true);
expect(applied.componentsBuilder).toBeDefined();
expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0);
expect(applied.usedPresentation).toBe(true);
expect(applied.presentation?.blocks.length).toBeGreaterThan(0);
expect(applied.message).toBe("hello");
});
@@ -225,11 +212,11 @@ describe("outbound policy helpers", () => {
const applied = applyCrossContextDecoration({
message: "hello",
decoration: { prefix: "[from ops] ", suffix: " [cc]" },
preferComponents: true,
preferPresentation: true,
});
expect(applied).toEqual({
message: "[from ops] hello [cc]",
usedComponents: false,
usedPresentation: false,
});
});

View File

@@ -1,20 +1,20 @@
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type {
ChannelId,
ChannelMessageActionName,
ChannelThreadingToolContext,
} from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
getChannelMessageAdapter,
type CrossContextComponentsBuilder,
} from "./channel-adapters.js";
import type { MessagePresentation } from "../../interactive/payload.js";
import { normalizeTargetForProvider } from "./target-normalization.js";
import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
export type CrossContextPresentationBuilder = (message: string) => MessagePresentation;
export type CrossContextDecoration = {
prefix: string;
suffix: string;
componentsBuilder?: CrossContextComponentsBuilder;
presentationBuilder?: CrossContextPresentationBuilder;
};
const CONTEXT_GUARDED_ACTIONS = new Set<ChannelMessageActionName>([
@@ -181,20 +181,19 @@ export async function buildCrossContextDecoration(params: {
const prefix = prefixTemplate.replaceAll("{channel}", originLabel);
const suffix = suffixTemplate.replaceAll("{channel}", originLabel);
const adapter = getChannelMessageAdapter(params.channel);
const componentsBuilder = adapter.supportsComponentsV2
? adapter.buildCrossContextComponents
? (message: string) =>
adapter.buildCrossContextComponents!({
originLabel,
message,
cfg: params.cfg,
accountId: params.accountId ?? undefined,
})
: undefined
const buildPresentation = getChannelPlugin(params.channel)?.messaging
?.buildCrossContextPresentation;
const presentationBuilder = buildPresentation
? (message: string) =>
buildPresentation({
originLabel,
message,
cfg: params.cfg,
accountId: params.accountId ?? undefined,
})
: undefined;
return { prefix, suffix, componentsBuilder };
return { prefix, suffix, presentationBuilder };
}
export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean {
@@ -204,20 +203,20 @@ export function shouldApplyCrossContextMarker(action: ChannelMessageActionName):
export function applyCrossContextDecoration(params: {
message: string;
decoration: CrossContextDecoration;
preferComponents: boolean;
preferPresentation: boolean;
}): {
message: string;
componentsBuilder?: CrossContextComponentsBuilder;
usedComponents: boolean;
presentation?: MessagePresentation;
usedPresentation: boolean;
} {
const useComponents = params.preferComponents && params.decoration.componentsBuilder;
if (useComponents) {
const usePresentation = params.preferPresentation && params.decoration.presentationBuilder;
if (usePresentation) {
return {
message: params.message,
componentsBuilder: params.decoration.componentsBuilder,
usedComponents: true,
presentation: params.decoration.presentationBuilder?.(params.message),
usedPresentation: true,
};
}
const message = `${params.decoration.prefix}${params.message}${params.decoration.suffix}`;
return { message, usedComponents: false };
return { message, usedPresentation: false };
}

View File

@@ -10,9 +10,12 @@ import { resolveSilentReplySettings } from "../../config/silent-reply.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
hasInteractiveReplyBlocks,
hasMessagePresentationBlocks,
hasReplyChannelData,
hasReplyPayloadContent,
type InteractiveReply,
type MessagePresentation,
type ReplyPayloadDelivery,
} from "../../interactive/payload.js";
import {
resolveSilentReplyRewriteText,
@@ -23,6 +26,8 @@ export type NormalizedOutboundPayload = {
text: string;
mediaUrls: string[];
audioAsVoice?: boolean;
presentation?: MessagePresentation;
delivery?: ReplyPayloadDelivery;
interactive?: InteractiveReply;
channelData?: Record<string, unknown>;
};
@@ -32,6 +37,8 @@ export type OutboundPayloadJson = {
mediaUrl: string | null;
mediaUrls?: string[];
audioAsVoice?: boolean;
presentation?: MessagePresentation;
delivery?: ReplyPayloadDelivery;
interactive?: InteractiveReply;
channelData?: Record<string, unknown>;
};
@@ -39,6 +46,7 @@ export type OutboundPayloadJson = {
export type OutboundPayloadPlan = {
payload: ReplyPayload;
parts: ReturnType<typeof resolveSendableOutboundReplyParts>;
hasPresentation: boolean;
hasInteractive: boolean;
hasChannelData: boolean;
};
@@ -104,6 +112,7 @@ function mergeMediaUrls(...lists: Array<ReadonlyArray<string | undefined> | unde
type PreparedOutboundPayloadPlanEntry = {
payload: ReplyPayload;
hasPresentation: boolean;
hasInteractive: boolean;
hasChannelData: boolean;
isSilent: boolean;
@@ -149,6 +158,7 @@ function createOutboundPayloadPlanEntry(
const hasChannelData = hasReplyChannelData(normalizedPayload.channelData);
return {
payload: normalizedPayload,
hasPresentation: hasMessagePresentationBlocks(normalizedPayload.presentation),
hasInteractive: hasInteractiveReplyBlocks(normalizedPayload.interactive),
hasChannelData,
isSilent,
@@ -192,6 +202,7 @@ export function createOutboundPayloadPlan(
plan.push({
payload: entry.payload,
parts: resolveSendableOutboundReplyParts(entry.payload),
hasPresentation: entry.hasPresentation,
hasInteractive: entry.hasInteractive,
hasChannelData: entry.hasChannelData,
});
@@ -211,6 +222,7 @@ export function createOutboundPayloadPlan(
plan.push({
payload: visibleSilentPayload,
parts: resolveSendableOutboundReplyParts(visibleSilentPayload),
hasPresentation: entry.hasPresentation,
hasInteractive: entry.hasInteractive,
hasChannelData: entry.hasChannelData,
});
@@ -228,6 +240,7 @@ export function createOutboundPayloadPlan(
plan.push({
payload: rewrittenPayload,
parts: resolveSendableOutboundReplyParts(rewrittenPayload),
hasPresentation: entry.hasPresentation,
hasInteractive: entry.hasInteractive,
hasChannelData: entry.hasChannelData,
});
@@ -260,6 +273,8 @@ export function projectOutboundPayloadPlanForOutbound(
text,
mediaUrls: entry.parts.mediaUrls,
audioAsVoice: payload.audioAsVoice === true ? true : undefined,
...(entry.hasPresentation ? { presentation: payload.presentation } : {}),
...(payload.delivery ? { delivery: payload.delivery } : {}),
...(entry.hasInteractive ? { interactive: payload.interactive } : {}),
...(entry.hasChannelData ? { channelData: payload.channelData } : {}),
});
@@ -278,6 +293,8 @@ export function projectOutboundPayloadPlanForJson(
mediaUrl: payload.mediaUrl ?? null,
mediaUrls: entry.parts.mediaUrls.length ? entry.parts.mediaUrls : undefined,
audioAsVoice: payload.audioAsVoice === true ? true : undefined,
presentation: payload.presentation,
delivery: payload.delivery,
interactive: payload.interactive,
channelData: payload.channelData,
});
@@ -305,6 +322,8 @@ export function summarizeOutboundPayloadForTransport(
text: parts.text,
mediaUrls: parts.mediaUrls,
audioAsVoice: payload.audioAsVoice === true ? true : undefined,
presentation: payload.presentation,
delivery: payload.delivery,
interactive: payload.interactive,
channelData: payload.channelData,
};

View File

@@ -4,6 +4,8 @@ import {
hasReplyContent,
hasReplyPayloadContent,
normalizeInteractiveReply,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
} from "./payload.js";
@@ -105,4 +107,27 @@ describe("interactive payload helpers", () => {
});
expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond");
});
it("preserves URL-only presentation buttons for native link renderers and fallback text", () => {
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
};
expect(presentationToInteractiveReply(presentation)).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
});
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
"- Docs: https://example.com/docs",
);
});
});

View File

@@ -7,7 +7,8 @@ export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "dang
export type InteractiveReplyButton = {
label: string;
value: string;
value?: string;
url?: string;
style?: InteractiveButtonStyle;
};
@@ -41,6 +42,70 @@ export type InteractiveReply = {
blocks: InteractiveReplyBlock[];
};
export type MessagePresentationTone = "info" | "success" | "warning" | "danger" | "neutral";
export type MessagePresentationButtonStyle = InteractiveButtonStyle;
export type MessagePresentationButton = {
label: string;
value?: string;
url?: string;
style?: MessagePresentationButtonStyle;
};
export type MessagePresentationOption = {
label: string;
value: string;
};
export type MessagePresentationTextBlock = {
type: "text";
text: string;
};
export type MessagePresentationContextBlock = {
type: "context";
text: string;
};
export type MessagePresentationDividerBlock = {
type: "divider";
};
export type MessagePresentationButtonsBlock = {
type: "buttons";
buttons: MessagePresentationButton[];
};
export type MessagePresentationSelectBlock = {
type: "select";
placeholder?: string;
options: MessagePresentationOption[];
};
export type MessagePresentationBlock =
| MessagePresentationTextBlock
| MessagePresentationContextBlock
| MessagePresentationDividerBlock
| MessagePresentationButtonsBlock
| MessagePresentationSelectBlock;
export type MessagePresentation = {
title?: string;
tone?: MessagePresentationTone;
blocks: MessagePresentationBlock[];
};
export type ReplyPayloadDeliveryPin = {
enabled: boolean;
notify?: boolean;
required?: boolean;
};
export type ReplyPayloadDelivery = {
pin?: boolean | ReplyPayloadDeliveryPin;
};
function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined {
const style = normalizeOptionalLowercaseString(value);
return style === "primary" || style === "secondary" || style === "success" || style === "danger"
@@ -48,6 +113,17 @@ function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefine
: undefined;
}
function normalizePresentationTone(value: unknown): MessagePresentationTone | undefined {
const tone = normalizeOptionalLowercaseString(value);
return tone === "info" ||
tone === "success" ||
tone === "warning" ||
tone === "danger" ||
tone === "neutral"
? tone
: undefined;
}
function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
@@ -58,12 +134,14 @@ function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | unde
normalizeOptionalString(record.value) ??
normalizeOptionalString(record.callbackData) ??
normalizeOptionalString(record.callback_data);
if (!label || !value) {
const url = normalizeOptionalString(record.url);
if (!label || (!value && !url)) {
return undefined;
}
return {
label,
value,
...(value ? { value } : {}),
...(url ? { url } : {}),
style: normalizeButtonStyle(record.style),
};
}
@@ -129,10 +207,204 @@ export function normalizeInteractiveReply(raw: unknown): InteractiveReply | unde
return blocks.length > 0 ? { blocks } : undefined;
}
function normalizePresentationButton(raw: unknown): MessagePresentationButton | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const label = normalizeOptionalString(record.label) ?? normalizeOptionalString(record.text);
const value =
normalizeOptionalString(record.value) ??
normalizeOptionalString(record.callbackData) ??
normalizeOptionalString(record.callback_data);
const url = normalizeOptionalString(record.url);
if (!label || (!value && !url)) {
return undefined;
}
return {
label,
...(value ? { value } : {}),
...(url ? { url } : {}),
style: normalizeButtonStyle(record.style),
};
}
function normalizePresentationOption(raw: unknown): MessagePresentationOption | undefined {
const option = normalizeInteractiveOption(raw);
return option ? { label: option.label, value: option.value } : undefined;
}
function normalizePresentationBlock(raw: unknown): MessagePresentationBlock | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const type = normalizeOptionalLowercaseString(record.type);
if (type === "text" || type === "context") {
const text = normalizeOptionalString(record.text);
return text ? { type, text } : undefined;
}
if (type === "divider") {
return { type: "divider" };
}
if (type === "buttons") {
const buttons = Array.isArray(record.buttons)
? record.buttons
.map((entry) => normalizePresentationButton(entry))
.filter((entry): entry is MessagePresentationButton => Boolean(entry))
: [];
return buttons.length > 0 ? { type: "buttons", buttons } : undefined;
}
if (type === "select") {
const options = Array.isArray(record.options)
? record.options
.map((entry) => normalizePresentationOption(entry))
.filter((entry): entry is MessagePresentationOption => Boolean(entry))
: [];
return options.length > 0
? {
type: "select",
placeholder: normalizeOptionalString(record.placeholder),
options,
}
: undefined;
}
return undefined;
}
export function normalizeMessagePresentation(raw: unknown): MessagePresentation | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const blocks = Array.isArray(record.blocks)
? record.blocks
.map((entry) => normalizePresentationBlock(entry))
.filter((entry): entry is MessagePresentationBlock => Boolean(entry))
: [];
const title = normalizeOptionalString(record.title);
if (!title && blocks.length === 0) {
return undefined;
}
return {
...(title ? { title } : {}),
tone: normalizePresentationTone(record.tone),
blocks,
};
}
export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply {
return Boolean(normalizeInteractiveReply(value));
}
export function hasMessagePresentationBlocks(value: unknown): value is MessagePresentation {
return Boolean(normalizeMessagePresentation(value));
}
export function presentationToInteractiveReply(
presentation: MessagePresentation,
): InteractiveReply | undefined {
const blocks: InteractiveReplyBlock[] = [];
if (presentation.title) {
blocks.push({ type: "text", text: presentation.title });
}
for (const block of presentation.blocks) {
if (block.type === "text" || block.type === "context") {
blocks.push({ type: "text", text: block.text });
continue;
}
if (block.type === "buttons") {
const buttons = block.buttons
.filter((button) => button.value || button.url)
.map((button) => {
const interactiveButton: InteractiveReplyButton = {
label: button.label,
style: button.style,
};
if (button.value) {
interactiveButton.value = button.value;
}
if (button.url) {
interactiveButton.url = button.url;
}
return interactiveButton;
});
if (buttons.length > 0) {
blocks.push({ type: "buttons", buttons });
}
continue;
}
if (block.type === "select") {
blocks.push({
type: "select",
placeholder: block.placeholder,
options: block.options,
});
}
}
return blocks.length > 0 ? { blocks } : undefined;
}
export function interactiveReplyToPresentation(
interactive: InteractiveReply,
): MessagePresentation | undefined {
const blocks = interactive.blocks.map((block): MessagePresentationBlock => {
if (block.type === "text") {
return { type: "text", text: block.text };
}
if (block.type === "buttons") {
return { type: "buttons", buttons: block.buttons };
}
return {
type: "select",
placeholder: block.placeholder,
options: block.options,
};
});
return blocks.length > 0 ? { blocks } : undefined;
}
export function renderMessagePresentationFallbackText(params: {
presentation?: MessagePresentation;
text?: string | null;
}): string {
const lines: string[] = [];
const text = normalizeOptionalString(params.text);
if (text) {
lines.push(text);
}
const presentation = params.presentation;
if (!presentation) {
return lines.join("\n\n");
}
if (presentation.title) {
lines.push(presentation.title);
}
for (const block of presentation.blocks) {
if (block.type === "text" || block.type === "context") {
lines.push(block.text);
continue;
}
if (block.type === "buttons") {
const labels = block.buttons
.map((button) => (button.url ? `${button.label}: ${button.url}` : button.label))
.filter(Boolean);
if (labels.length > 0) {
lines.push(labels.map((label) => `- ${label}`).join("\n"));
}
continue;
}
if (block.type === "select") {
const labels = block.options.map((option) => option.label).filter(Boolean);
if (labels.length > 0) {
const heading = block.placeholder ? `${block.placeholder}:` : "Options:";
lines.push(`${heading}\n${labels.map((label) => `- ${label}`).join("\n")}`);
}
}
}
return lines.join("\n\n");
}
export function hasReplyChannelData(value: unknown): value is Record<string, unknown> {
return Boolean(
value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0,
@@ -144,6 +416,7 @@ export function hasReplyContent(params: {
mediaUrl?: string | null;
mediaUrls?: ReadonlyArray<string | null | undefined>;
interactive?: unknown;
presentation?: unknown;
hasChannelData?: boolean;
extraContent?: boolean;
}): boolean {
@@ -153,6 +426,7 @@ export function hasReplyContent(params: {
text ||
mediaUrl ||
params.mediaUrls?.some((entry) => Boolean(normalizeOptionalString(entry))) ||
hasMessagePresentationBlocks(params.presentation) ||
hasInteractiveReplyBlocks(params.interactive) ||
params.hasChannelData ||
params.extraContent,
@@ -165,6 +439,7 @@ export function hasReplyPayloadContent(
mediaUrl?: string | null;
mediaUrls?: ReadonlyArray<string | null | undefined>;
interactive?: unknown;
presentation?: unknown;
channelData?: unknown;
},
options?: {
@@ -178,6 +453,7 @@ export function hasReplyPayloadContent(
mediaUrl: payload.mediaUrl,
mediaUrls: payload.mediaUrls,
interactive: payload.interactive,
presentation: payload.presentation,
hasChannelData: options?.hasChannelData ?? hasReplyChannelData(payload.channelData),
extraContent: options?.extraContent,
});

View File

@@ -70,7 +70,7 @@ export function installChannelSurfaceContractSuite(params: {
messaging?.normalizeTarget,
messaging?.parseExplicitTarget,
messaging?.inferTargetChatType,
messaging?.buildCrossContextComponents,
messaging?.buildCrossContextPresentation,
messaging?.enableInteractiveReplies,
messaging?.hasStructuredReplyPayload,
messaging?.formatTargetDisplay,