From fd0970c077377a30847efb3d118bac7dd093891b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 21:20:26 +0100 Subject: [PATCH] refactor(channels): decouple presentation rendering --- docs/channels/msteams.md | 30 +- docs/channels/telegram.md | 3 +- docs/cli/message.md | 28 +- docs/plan/ui-channels.md | 250 ++++++++++++++++ .../discord/src/actions/handle-action.ts | 12 +- .../src/channel-actions.contract.test.ts | 2 +- .../discord/src/channel-actions.test.ts | 14 +- extensions/discord/src/channel-actions.ts | 9 +- extensions/discord/src/channel.ts | 47 +-- .../discord/src/outbound-adapter.test.ts | 57 +++- extensions/discord/src/outbound-adapter.ts | 32 +- .../discord/src/shared-interactive.test.ts | 51 +++- extensions/discord/src/shared-interactive.ts | 64 +++- extensions/feishu/src/channel.test.ts | 172 +++++------ extensions/feishu/src/channel.ts | 66 ++-- extensions/mattermost/package.json | 1 - ...nnel-actions-setup-status.contract.test.ts | 4 +- extensions/mattermost/src/channel.test.ts | 11 +- extensions/mattermost/src/channel.ts | 45 ++- extensions/msteams/src/actions.ts | 15 +- .../msteams/src/channel.actions.test.ts | 53 +++- extensions/msteams/src/channel.ts | 18 +- extensions/msteams/src/presentation.test.ts | 25 ++ extensions/msteams/src/presentation.ts | 68 +++++ extensions/slack/src/blocks-render.ts | 94 ++++-- ...nnel-actions-setup-status.contract.test.ts | 6 +- extensions/slack/src/channel.test.ts | 38 +-- .../slack/src/message-action-dispatch.ts | 21 +- extensions/slack/src/message-tool-api.test.ts | 2 +- extensions/slack/src/message-tool-api.ts | 15 +- extensions/slack/src/outbound-adapter.test.ts | 47 ++- extensions/slack/src/outbound-adapter.ts | 44 ++- extensions/slack/src/outbound-payload.test.ts | 51 +--- .../slack/src/shared-interactive.test.ts | 23 ++ .../telegram/src/action-runtime.test.ts | 195 +++++++----- extensions/telegram/src/action-runtime.ts | 154 +++++----- .../telegram/src/bot/delivery.replies.ts | 11 +- extensions/telegram/src/bot/delivery.test.ts | 23 +- extensions/telegram/src/button-types.ts | 3 + .../src/channel-actions.contract.test.ts | 2 +- extensions/telegram/src/channel-actions.ts | 10 +- extensions/telegram/src/channel.ts | 6 +- .../telegram/src/outbound-adapter.test.ts | 24 ++ extensions/telegram/src/outbound-adapter.ts | 30 +- extensions/telegram/src/send.test.ts | 20 ++ extensions/telegram/src/send.ts | 6 +- src/agents/tools/message-tool.test.ts | 183 ++---------- src/agents/tools/message-tool.ts | 75 +++-- src/auto-reply/reply-payload.ts | 11 +- src/auto-reply/reply/commands-acp.test.ts | 6 +- .../reply/commands-acp/lifecycle.ts | 18 +- src/channels/plugins/message-actions.test.ts | 46 ++- src/channels/plugins/message-capabilities.ts | 8 +- .../plugins/message-capability-matrix.test.ts | 32 +- src/channels/plugins/message-tool-api.test.ts | 6 +- src/channels/plugins/outbound.types.ts | 26 ++ src/channels/plugins/types.adapters.ts | 7 +- src/channels/plugins/types.core.ts | 7 +- src/channels/plugins/types.ts | 1 + src/cli/daemon-cli/install.test.ts | 43 ++- src/cli/program/message/register.send.ts | 12 +- src/infra/outbound/channel-adapters.test.ts | 118 -------- src/infra/outbound/channel-adapters.ts | 35 --- src/infra/outbound/deliver.test.ts | 139 +++++++++ src/infra/outbound/deliver.ts | 183 +++++++++++- .../outbound/message-action-param-keys.ts | 7 +- src/infra/outbound/message-action-params.ts | 44 +-- ...sage-action-runner.plugin-dispatch.test.ts | 41 ++- ...sage-action-runner.send-validation.test.ts | 8 +- src/infra/outbound/message-action-runner.ts | 71 ++--- src/infra/outbound/outbound-policy.test.ts | 65 ++-- src/infra/outbound/outbound-policy.ts | 49 ++- src/infra/outbound/payloads.ts | 19 ++ src/interactive/payload.test.ts | 25 ++ src/interactive/payload.ts | 282 +++++++++++++++++- .../channels/surface-contract-suite.ts | 2 +- 76 files changed, 2290 insertions(+), 1181 deletions(-) create mode 100644 docs/plan/ui-channels.md create mode 100644 extensions/msteams/src/presentation.test.ts create mode 100644 extensions/msteams/src/presentation.ts delete mode 100644 src/infra/outbound/channel-adapters.test.ts delete mode 100644 src/infra/outbound/channel-adapters.ts diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 4d618c6dfde..74af3463b6f 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -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:", - 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" }], }, } ``` diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index bea8c1c0f0f..37fab1f1695 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -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: diff --git a/docs/cli/message.md b/docs/cli/message.md index 6c8cff399e8..14e272f084f 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -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: diff --git a/docs/plan/ui-channels.md b/docs/plan/ui-channels.md new file mode 100644 index 00000000000..48bed715ad6 --- /dev/null +++ b/docs/plan/ui-channels.md @@ -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; +}; +``` + +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? diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index a7bd674bca7..18fb49b6735 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -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) && diff --git a/extensions/discord/src/channel-actions.contract.test.ts b/extensions/discord/src/channel-actions.contract.test.ts index 0fd7e980f5e..c0db13297f4 100644 --- a/extensions/discord/src/channel-actions.contract.test.ts +++ b/extensions/discord/src/channel-actions.contract.test.ts @@ -38,7 +38,7 @@ describe("discord actions contract", () => { }, } as OpenClawConfig, expectedActions: ["send", "poll", "react", "reactions", "emoji-list"], - expectedCapabilities: ["interactive", "components"], + expectedCapabilities: ["presentation"], }, ], }); diff --git a/extensions/discord/src/channel-actions.test.ts b/extensions/discord/src/channel-actions.test.ts index 806f27554cd..28b7b74fcc7 100644 --- a/extensions/discord/src/channel-actions.test.ts +++ b/extensions/discord/src/channel-actions.test.ts @@ -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", () => { diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 93879ddb045..96fd7907675 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -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 @@ -157,12 +155,7 @@ function describeDiscordMessageTool({ } return { actions: Array.from(actions), - capabilities: ["interactive", "components"], - schema: { - properties: { - components: Type.Optional(createDiscordMessageToolComponentsSchema()), - }, - }, + capabilities: ["presentation"], }; } diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 38946de245b..87b00d302d5 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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; -type DiscordSeparator = InstanceType; let discordProviderRuntimePromise: | Promise @@ -83,7 +78,6 @@ let discordProbeRuntimePromise: Promise | u let discordAuditModulePromise: Promise | undefined; let discordSendModulePromise: Promise | undefined; let discordDirectoryLiveModulePromise: Promise | 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 = []; - 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 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, diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 6a2b0e34e62..86749f8f9b6 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -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: {}, diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 2302422f483..156ff51becf 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -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 | 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, diff --git a/extensions/discord/src/shared-interactive.test.ts b/extensions/discord/src/shared-interactive.test.ts index 33ce8f68ec1..9b7cd8e27f0 100644 --- a/extensions/discord/src/shared-interactive.test.ts +++ b/extensions/discord/src/shared-interactive.test.ts @@ -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" }], + }, + ], + }); + }); }); diff --git a/extensions/discord/src/shared-interactive.ts b/extensions/discord/src/shared-interactive.ts index 3898a3962fd..d0246dd6fb7 100644 --- a/extensions/discord/src/shared-interactive.ts +++ b/extensions/discord/src/shared-interactive.ts @@ -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; +} diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 7cc59b004be..05ecaf65b71 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -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", + }, + ], + }, + }), }), ); }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index a080b406588..1c2de22d542 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -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>; + fallbackText?: string; +}): Record { + const fallbackPresentation: NonNullable> = { + ...(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([ @@ -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) - : 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.`); } diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 8ad89488e3b..789e0ba75ad 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -4,7 +4,6 @@ "description": "OpenClaw Mattermost channel plugin", "type": "module", "dependencies": { - "@sinclair/typebox": "0.34.49", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts index d2f6c0b6890..39d26ee2bcd 100644 --- a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts @@ -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", diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 2bcac8648e1..1034e0b55cb 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -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", () => { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index ee064f73934..1f559186ae2 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -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, }); diff --git a/extensions/msteams/src/actions.ts b/extensions/msteams/src/actions.ts index 5d6d6bcca53..3c00031dea3 100644 --- a/extensions/msteams/src/actions.ts +++ b/extensions/msteams/src/actions.ts @@ -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 = { describeMessageTool: describeMSTeamsMessageTool, handleAction: async (ctx) => { - if (ctx.action === "send" && ctx.params.card) { - const card = ctx.params.card as Record; + 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, diff --git a/extensions/msteams/src/channel.actions.test.ts b/extensions/msteams/src/channel.actions.test.ts index 13da09b6aa6..c9f0ccca421 100644 --- a/extensions/msteams/src/channel.actions.test.ts +++ b/extensions/msteams/src/channel.actions.test.ts @@ -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", diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d7e66b6969d..f70c4247610 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -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 { - // Handle send action with card parameter - if (ctx.action === "send" && ctx.params.card) { - const card = ctx.params.card as Record; + 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, diff --git a/extensions/msteams/src/presentation.test.ts b/extensions/msteams/src/presentation.test.ts new file mode 100644 index 00000000000..66f1070b44d --- /dev/null +++ b/extensions/msteams/src/presentation.test.ts @@ -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" } }], + }); + }); +}); diff --git a/extensions/msteams/src/presentation.ts b/extensions/msteams/src/presentation.ts new file mode 100644 index 00000000000..f98149aea31 --- /dev/null +++ b/extensions/msteams/src/presentation.ts @@ -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[] = []; + 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[] = []; + 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 } : {}), + }; +} diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index 6bb614b4521..e5e517b2d21 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -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; +} diff --git a/extensions/slack/src/channel-actions-setup-status.contract.test.ts b/extensions/slack/src/channel-actions-setup-status.contract.test.ts index ef709796752..f0a3f46f31b 100644 --- a/extensions/slack/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/slack/src/channel-actions-setup-status.contract.test.ts @@ -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", diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index aa84be2b8a4..0175ba14a4e 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -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", }, }, diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index c8bcf1c6294..232ccfaca3d 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -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, @@ -11,10 +13,6 @@ type SlackActionInvoke = ( toolContext?: ChannelMessageActionContext["toolContext"], ) => Promise>; -function readSlackBlocksParam(actionParams: Record) { - return parseSlackBlocksInput(actionParams.blocks) as Record[] | 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."); } diff --git a/extensions/slack/src/message-tool-api.test.ts b/extensions/slack/src/message-tool-api.test.ts index d3c9c7b6c2f..8c69b882ae6 100644 --- a/extensions/slack/src/message-tool-api.test.ts +++ b/extensions/slack/src/message-tool-api.test.ts @@ -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"]), }); }); diff --git a/extensions/slack/src/message-tool-api.ts b/extensions/slack/src/message-tool-api.ts index 4fd5fb7f2eb..d611f4ef30f 100644 --- a/extensions/slack/src/message-tool-api.ts +++ b/extensions/slack/src/message-tool-api.ts @@ -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>[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, }; } diff --git a/extensions/slack/src/outbound-adapter.test.ts b/extensions/slack/src/outbound-adapter.test.ts index e76ca48a745..5ec1b09e100 100644 --- a/extensions/slack/src/outbound-adapter.test.ts +++ b/extensions/slack/src/outbound-adapter.test.ts @@ -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 }); diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 603df7343dc..9eaaf47435a 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -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; 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 | undefined), + presentationBlocks: buildSlackPresentationBlocks(presentation), + }, + }, + }), sendPayload: async (ctx) => { const payload = { ...ctx.payload, diff --git a/extensions/slack/src/outbound-payload.test.ts b/extensions/slack/src/outbound-payload.test.ts index 639a97929f4..bc753eebae2 100644 --- a/extensions/slack/src/outbound-payload.test.ts +++ b/extensions/slack/src/outbound-payload.test.ts @@ -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: [ { diff --git a/extensions/slack/src/shared-interactive.test.ts b/extensions/slack/src/shared-interactive.test.ts index ed40a95fa83..e09c3cf1065 100644 --- a/extensions/slack/src/shared-interactive.test.ts +++ b/extensions/slack/src/shared-interactive.test.ts @@ -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: [ diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index ff9c2f178db..6898afbd82a 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -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< diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index f9791729bf2..5b80b8eb8eb 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -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, @@ -98,56 +91,6 @@ function readTelegramForumTopicIconColor( } return iconColor as TelegramForumTopicIconColor; } -export function readTelegramButtons( - params: Record, -): 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) { ); } -function resolveTelegramButtonsFromParams(params: Record) { +function resolveTelegramButtonsFromParams( + params: Record, + 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; 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) { + 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; + 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, 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, diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 12ee1ec9d04..9e46a51da75 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -487,18 +487,20 @@ async function deliverMediaReply(params: { } async function maybePinFirstDeliveredMessage(params: { - shouldPin: boolean; + pin: NonNullable["pin"] | undefined; bot: Bot; chatId: string; runtime: RuntimeEnv; firstDeliveredMessageId?: number; }): Promise { - 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, diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index f374c937452..9e8a4394214 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -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, }); diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index cb9864448dd..fd640a4d8fc 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -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 []; diff --git a/extensions/telegram/src/channel-actions.contract.test.ts b/extensions/telegram/src/channel-actions.contract.test.ts index cfe70faf795..64f1ae31b27 100644 --- a/extensions/telegram/src/channel-actions.contract.test.ts +++ b/extensions/telegram/src/channel-actions.contract.test.ts @@ -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"], }, ], }); diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index e4dd9eb578d..0ba850c8cb8 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -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, }; } diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 772efa11c9f..21f471583fd 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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 }) => diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index bebc173fb71..0641b2129fd 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -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, + }), + ); + }); }); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 8be02021c85..ac8f4405ad2 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -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({ diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index af8312c5319..2abb1d2bee6 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -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: { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 862e11c3ee7..e0a15d16989 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1093,6 +1093,7 @@ type TelegramDeleteOpts = { cfg?: ReturnType; 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}`); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 19a8e7bf1b1..f26bb654753 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -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[0]; type MessageToolSchema = NonNullable>["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 } }; - } - )?.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; - 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; - 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"], }; }, }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index c081992368f..acabf2e3849 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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 = { 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; }) { return { @@ -377,7 +402,8 @@ function buildMessageToolSchemaProps(options: { function buildMessageToolSchemaFromActions( actions: readonly string[], options: { - includeInteractive: boolean; + includePresentation: boolean; + includeDeliveryPin: boolean; extraProperties?: Record; }, ) { @@ -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, }); } diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 6660fc83df2..da257eca2ad 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -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; diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index fb03b6616f5..09416fdb244 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -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", diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 6037f0244cb..d22f4bc3a65 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -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 | undefined> { +}): Promise | 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, }, }; } diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 58858be9a44..0e619c696cb 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -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, diff --git a/src/channels/plugins/message-capabilities.ts b/src/channels/plugins/message-capabilities.ts index b7c2abd2a24..7b64faff542 100644 --- a/src/channels/plugins/message-capabilities.ts +++ b/src/channels/plugins/message-capabilities.ts @@ -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]; diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index bbe4c0bb744..59a80312206 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -32,17 +32,17 @@ const slackPlugin: Pick = { account.appToken.trim() !== ""; const capabilities = new Set(); 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 = { 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 = { 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 = { 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([]); }); diff --git a/src/channels/plugins/message-tool-api.test.ts b/src/channels/plugins/message-tool-api.test.ts index 30bd5dd9421..a2084930afe 100644 --- a/src/channels/plugins/message-tool-api.test.ts +++ b/src/channels/plugins/message-tool-api.test.ts @@ -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"], }); }); diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index 2c78a68f0f0..98879380f4c 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -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; + presentationCapabilities?: ChannelPresentationCapabilities; + deliveryCapabilities?: ChannelDeliveryCapabilities; + renderPresentation?: (params: { + payload: ReplyPayload; + presentation: MessagePresentation; + ctx: ChannelOutboundPayloadContext; + }) => Promise | ReplyPayload | null; + pinDeliveredMessage?: (params: { + cfg: OpenClawConfig; + target: ChannelOutboundTargetRef; + messageId: string; + pin: ReplyPayloadDeliveryPin; + }) => Promise | void; /** * @deprecated Use shouldTreatDeliveredTextAsVisible instead. */ diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 792d39f02a2..aa156f8cec7 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -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; + }) => + | Pick + | null + | Promise | null>; buildModelOverrideParentCandidates?: (params: { parentConversationId?: string | null; }) => string[] | null | undefined; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 03392626b04..4b0baaa3da0 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index e46997a8395..48af67955a3 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -79,6 +79,7 @@ export type { ChannelStatusIssue, ChannelStreamingAdapter, ChannelStructuredComponents, + ChannelCrossContextPresentationFactory, ChannelThreadingAdapter, ChannelThreadingContext, ChannelThreadingToolContext, diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index f72123acdf4..5179ca44a39 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -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] | undefined) ?? []; - const env = firstArg?.env as Record; - 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] | undefined) ?? + []; + const env = firstArg?.env as Record; + 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; + } + } }); }); diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index 7b499207fd5..5a208f8bc85 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -16,15 +16,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli "Attach media (image/audio/video/document). Accepts local paths or URLs.", ) .option( - "--interactive ", - "Shared interactive payload as JSON (buttons/selects rendered natively by supported channels)", + "--presentation ", + "Shared presentation payload as JSON (text, context, dividers, buttons, selects)", ) - .option( - "--buttons ", - "Telegram inline keyboard buttons as JSON (array of button rows)", - ) - .option("--components ", "Discord components payload as JSON") - .option("--card ", "Adaptive Card JSON object (when supported by the channel)") + .option("--delivery ", "Shared delivery preferences as JSON") + .option("--pin", "Request that the delivered message be pinned when supported", false) .option("--reply-to ", "Reply-to message id") .option("--thread-id ", "Thread id (Telegram forum thread)") .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false) diff --git a/src/infra/outbound/channel-adapters.test.ts b/src/infra/outbound/channel-adapters.test.ts deleted file mode 100644 index bd9d110bc44..00000000000 --- a/src/infra/outbound/channel-adapters.test.ts +++ /dev/null @@ -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) {} -} - -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 = []; - 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); - }, - ); -}); diff --git a/src/infra/outbound/channel-adapters.ts b/src/infra/outbound/channel-adapters.ts deleted file mode 100644 index e47b2dbfb14..00000000000 --- a/src/infra/outbound/channel-adapters.ts +++ /dev/null @@ -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; -} diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0f01869cd92..dcdcad69365 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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(); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 1636fb56b11..01f1c0e3c3c 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -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; + pinDeliveredMessage?: (params: { + target: ChannelOutboundTargetRef; + messageId: string; + pin: ReplyPayloadDeliveryPin; + }) => Promise; + 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 { + 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 { + 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; channel: Exclude; @@ -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, diff --git a/src/infra/outbound/message-action-param-keys.ts b/src/infra/outbound/message-action-param-keys.ts index e6eda89e585..b91e4f0a57c 100644 --- a/src/infra/outbound/message-action-param-keys.ts +++ b/src/infra/outbound/message-action-param-keys.ts @@ -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", diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index 5f61570ee6c..08ab16b52c2 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -399,54 +399,20 @@ export async function hydrateAttachmentParamsForAction(params: { }); } -export function parseButtonsParam(params: Record): void { - const raw = params.buttons; +export function parseJsonMessageParam(params: Record, 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): 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): 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`); } } diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 6dc9b6ae2a9..ffa4c317965 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -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 }) => 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 }) => 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(); }); diff --git a/src/infra/outbound/message-action-runner.send-validation.test.ts b/src/infra/outbound/message-action-runner.send-validation.test.ts index 2cc2f7cb23c..04aaef8cb70 100644 --- a/src/infra/outbound/message-action-runner.send-validation.test.ts +++ b/src/infra/outbound/message-action-runner.send-validation.test.ts @@ -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" }, }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 31d2c9acdd6..2560daf4488 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -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; 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; message: string; - preferComponents: boolean; + preferPresentation: boolean; }): Promise { 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 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) {} -} - 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 = []; - 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, }); }); diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 469e4c858a3..9beb167a50d 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -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([ @@ -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 }; } diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index d9ed5f47e66..e413aa82ace 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -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; }; @@ -32,6 +37,8 @@ export type OutboundPayloadJson = { mediaUrl: string | null; mediaUrls?: string[]; audioAsVoice?: boolean; + presentation?: MessagePresentation; + delivery?: ReplyPayloadDelivery; interactive?: InteractiveReply; channelData?: Record; }; @@ -39,6 +46,7 @@ export type OutboundPayloadJson = { export type OutboundPayloadPlan = { payload: ReplyPayload; parts: ReturnType; + hasPresentation: boolean; hasInteractive: boolean; hasChannelData: boolean; }; @@ -104,6 +112,7 @@ function mergeMediaUrls(...lists: Array | 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, }; diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts index 44b1d5f96dd..edc14d095e7 100644 --- a/src/interactive/payload.test.ts +++ b/src/interactive/payload.test.ts @@ -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", + ); + }); }); diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 54d7fd6fd9c..316ae88257a 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -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; + 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; + 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; + 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 { 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; 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; 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, }); diff --git a/test/helpers/channels/surface-contract-suite.ts b/test/helpers/channels/surface-contract-suite.ts index 85f7d189776..905cfe313be 100644 --- a/test/helpers/channels/surface-contract-suite.ts +++ b/test/helpers/channels/surface-contract-suite.ts @@ -70,7 +70,7 @@ export function installChannelSurfaceContractSuite(params: { messaging?.normalizeTarget, messaging?.parseExplicitTarget, messaging?.inferTargetChatType, - messaging?.buildCrossContextComponents, + messaging?.buildCrossContextPresentation, messaging?.enableInteractiveReplies, messaging?.hasStructuredReplyPayload, messaging?.formatTargetDisplay,