mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
refactor(channels): decouple presentation rendering
This commit is contained in:
@@ -611,7 +611,7 @@ Teams markdown is more limited than Slack or Discord:
|
||||
|
||||
- Basic formatting works: **bold**, _italic_, `code`, links
|
||||
- Complex markdown (tables, nested lists) may not render correctly
|
||||
- Adaptive Cards are supported for polls and arbitrary card sends (see below)
|
||||
- Adaptive Cards are supported for polls and semantic presentation sends (see below)
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -783,11 +783,11 @@ OpenClaw sends Teams polls as Adaptive Cards (there is no native Teams poll API)
|
||||
- The gateway must stay online to record votes.
|
||||
- Polls do not auto-post result summaries yet (inspect the store file if needed).
|
||||
|
||||
## Adaptive Cards (arbitrary)
|
||||
## Presentation Cards
|
||||
|
||||
Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI.
|
||||
Send semantic presentation payloads to Teams users or conversations using the `message` tool or CLI. OpenClaw renders them as Teams Adaptive Cards from the generic presentation contract.
|
||||
|
||||
The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional.
|
||||
The `presentation` parameter accepts semantic blocks. When `presentation` is provided, the message text is optional.
|
||||
|
||||
**Agent tool:**
|
||||
|
||||
@@ -796,10 +796,9 @@ The `card` parameter accepts an Adaptive Card JSON object. When `card` is provid
|
||||
action: "send",
|
||||
channel: "msteams",
|
||||
target: "user:<id>",
|
||||
card: {
|
||||
type: "AdaptiveCard",
|
||||
version: "1.5",
|
||||
body: [{ type: "TextBlock", text: "Hello!" }],
|
||||
presentation: {
|
||||
title: "Hello",
|
||||
blocks: [{ type: "text", text: "Hello!" }],
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -809,10 +808,10 @@ The `card` parameter accepts an Adaptive Card JSON object. When `card` is provid
|
||||
```bash
|
||||
openclaw message send --channel msteams \
|
||||
--target "conversation:19:abc...@thread.tacv2" \
|
||||
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}'
|
||||
--presentation '{"title":"Hello","blocks":[{"type":"text","text":"Hello!"}]}'
|
||||
```
|
||||
|
||||
See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below.
|
||||
For target format details, see [Target formats](#target-formats) below.
|
||||
|
||||
## Target formats
|
||||
|
||||
@@ -837,9 +836,9 @@ openclaw message send --channel msteams --target "user:John Smith" --message "He
|
||||
# Send to a group chat or channel
|
||||
openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello"
|
||||
|
||||
# Send an Adaptive Card to a conversation
|
||||
# Send a presentation card to a conversation
|
||||
openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \
|
||||
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}'
|
||||
--presentation '{"title":"Hello","blocks":[{"type":"text","text":"Hello"}]}'
|
||||
```
|
||||
|
||||
**Agent tool examples:**
|
||||
@@ -858,10 +857,9 @@ openclaw message send --channel msteams --target "conversation:19:abc...@thread.
|
||||
action: "send",
|
||||
channel: "msteams",
|
||||
target: "conversation:19:abc...@thread.tacv2",
|
||||
card: {
|
||||
type: "AdaptiveCard",
|
||||
version: "1.5",
|
||||
body: [{ type: "TextBlock", text: "Hello" }],
|
||||
presentation: {
|
||||
title: "Hello",
|
||||
blocks: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -67,15 +67,13 @@ Name lookup:
|
||||
|
||||
- `send`
|
||||
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/Matrix/Microsoft Teams
|
||||
- Required: `--target`, plus `--message` or `--media`
|
||||
- Optional: `--media`, `--interactive`, `--buttons`, `--components`, `--card`, `--reply-to`, `--thread-id`, `--gif-playback`, `--force-document`, `--silent`
|
||||
- Shared interactive payloads: `--interactive` sends a channel-native interactive JSON payload when supported
|
||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||
- Required: `--target`, plus `--message`, `--media`, or `--presentation`
|
||||
- Optional: `--media`, `--presentation`, `--delivery`, `--pin`, `--reply-to`, `--thread-id`, `--gif-playback`, `--force-document`, `--silent`
|
||||
- Shared presentation payloads: `--presentation` sends semantic blocks (`text`, `context`, `divider`, `buttons`, `select`) that core renders through the selected channel's declared capabilities.
|
||||
- Generic delivery preferences: `--delivery` accepts delivery hints such as `{ "pin": true }`; `--pin` is shorthand for pinned delivery when the channel supports it.
|
||||
- Telegram only: `--force-document` (send images and GIFs as documents to avoid Telegram compression)
|
||||
- Telegram only: `--thread-id` (forum topic id)
|
||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||
- Discord only: `--components` JSON payload
|
||||
- Adaptive-card channels: `--card` JSON payload when supported
|
||||
- Telegram + Discord: `--silent`
|
||||
- WhatsApp only: `--gif-playback`
|
||||
|
||||
@@ -208,22 +206,22 @@ openclaw message send --channel discord \
|
||||
--target channel:123 --message "hi" --reply-to 456
|
||||
```
|
||||
|
||||
Send a Discord message with components:
|
||||
Send a message with semantic buttons:
|
||||
|
||||
```
|
||||
openclaw message send --channel discord \
|
||||
--target channel:123 --message "Choose:" \
|
||||
--components '{"text":"Choose a path","blocks":[{"type":"actions","buttons":[{"label":"Approve","style":"success"},{"label":"Decline","style":"danger"}]}]}'
|
||||
--presentation '{"blocks":[{"type":"buttons","buttons":[{"label":"Approve","value":"approve","style":"success"},{"label":"Decline","value":"decline","style":"danger"}]}]}'
|
||||
```
|
||||
|
||||
See [Discord components](/channels/discord#interactive-components) for the full schema.
|
||||
Core renders the same `presentation` payload into Discord components, Slack blocks, Telegram inline buttons, Mattermost props, or Teams/Feishu cards depending on channel capability.
|
||||
|
||||
Send a shared interactive payload:
|
||||
Send a richer presentation payload:
|
||||
|
||||
```bash
|
||||
openclaw message send --channel googlechat --target spaces/AAA... \
|
||||
--message "Choose:" \
|
||||
--interactive '{"text":"Choose a path","blocks":[{"type":"actions","buttons":[{"label":"Approve"},{"label":"Decline"}]}]}'
|
||||
--presentation '{"title":"Deploy approval","tone":"warning","blocks":[{"type":"text","text":"Choose a path"},{"type":"buttons","buttons":[{"label":"Approve","value":"approve"},{"label":"Decline","value":"decline"}]}]}'
|
||||
```
|
||||
|
||||
Create a Discord poll:
|
||||
@@ -277,19 +275,19 @@ openclaw message react --channel signal \
|
||||
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
|
||||
```
|
||||
|
||||
Send Telegram inline buttons:
|
||||
Send Telegram inline buttons through generic presentation:
|
||||
|
||||
```
|
||||
openclaw message send --channel telegram --target @mychat --message "Choose:" \
|
||||
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||
--presentation '{"blocks":[{"type":"buttons","buttons":[{"label":"Yes","value":"cmd:yes"},{"label":"No","value":"cmd:no"}]}]}'
|
||||
```
|
||||
|
||||
Send a Teams Adaptive Card:
|
||||
Send a Teams card through generic presentation:
|
||||
|
||||
```bash
|
||||
openclaw message send --channel msteams \
|
||||
--target conversation:19:abc@thread.tacv2 \
|
||||
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Status update"}]}'
|
||||
--presentation '{"title":"Status update","blocks":[{"type":"text","text":"Build completed"}]}'
|
||||
```
|
||||
|
||||
Send a Telegram image as a document to avoid compression:
|
||||
|
||||
250
docs/plan/ui-channels.md
Normal file
250
docs/plan/ui-channels.md
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: Channel Presentation Refactor Plan
|
||||
summary: Decouple semantic message presentation from channel native UI renderers.
|
||||
read_when:
|
||||
- Refactoring channel message UI, interactive payloads, or native channel renderers
|
||||
- Changing message tool capabilities, delivery hints, or cross-context markers
|
||||
- Debugging Discord Carbon import fanout or channel plugin runtime laziness
|
||||
---
|
||||
|
||||
# Channel Presentation Refactor Plan
|
||||
|
||||
## Status
|
||||
|
||||
Implemented for the shared agent, CLI, plugin capability, and outbound delivery surfaces:
|
||||
|
||||
- `ReplyPayload.presentation` carries semantic message UI.
|
||||
- `ReplyPayload.delivery.pin` carries sent-message pin requests.
|
||||
- Shared message actions expose `presentation`, `delivery`, and `pin` instead of provider-native `components`, `blocks`, `buttons`, or `card`.
|
||||
- Core renders or auto-degrades presentation through plugin-declared outbound capabilities.
|
||||
- Discord, Slack, Telegram, Mattermost, MS Teams, and Feishu renderers consume the generic contract.
|
||||
- Discord channel control-plane code no longer imports Carbon-backed UI containers.
|
||||
|
||||
## Problem
|
||||
|
||||
Channel UI is currently split across several incompatible surfaces:
|
||||
|
||||
- Core owns a Discord-shaped cross-context renderer hook through `buildCrossContextComponents`.
|
||||
- Discord `channel.ts` can import native Carbon UI through `DiscordUiContainer`, which pulls runtime UI dependencies into the channel plugin control plane.
|
||||
- The agent and CLI expose native payload escape hatches such as Discord `components`, Slack `blocks`, Telegram or Mattermost `buttons`, and Teams or Feishu `card`.
|
||||
- `ReplyPayload.channelData` carries both transport hints and native UI envelopes.
|
||||
- The generic `interactive` model exists, but it is narrower than the richer layouts already used by Discord, Slack, Teams, Feishu, LINE, Telegram, and Mattermost.
|
||||
|
||||
This makes core aware of native UI shapes, weakens plugin runtime laziness, and gives agents too many provider-specific ways to express the same message intent.
|
||||
|
||||
## Goals
|
||||
|
||||
- Core decides the best semantic presentation for a message from declared capabilities.
|
||||
- Extensions declare capabilities and render semantic presentation into native transport payloads.
|
||||
- Web Control UI remains separate from chat native UI.
|
||||
- Native channel payloads are not exposed through the shared agent or CLI message surface.
|
||||
- Unsupported presentation features auto-degrade to the best text representation.
|
||||
- Delivery behavior such as pinning a sent message is generic delivery metadata, not presentation.
|
||||
|
||||
## Non Goals
|
||||
|
||||
- No backwards compatibility shim for `buildCrossContextComponents`.
|
||||
- No public native escape hatches for `components`, `blocks`, `buttons`, or `card`.
|
||||
- No core imports of channel-native UI libraries.
|
||||
- No provider-specific SDK seams for bundled channels.
|
||||
|
||||
## Target Model
|
||||
|
||||
Add a core-owned `presentation` field to `ReplyPayload`.
|
||||
|
||||
```ts
|
||||
type MessagePresentationTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
type MessagePresentation = {
|
||||
tone?: MessagePresentationTone;
|
||||
title?: string;
|
||||
blocks: MessagePresentationBlock[];
|
||||
};
|
||||
|
||||
type MessagePresentationBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "context"; text: string }
|
||||
| { type: "divider" }
|
||||
| { type: "buttons"; buttons: MessagePresentationButton[] }
|
||||
| { type: "select"; placeholder?: string; options: MessagePresentationOption[] };
|
||||
|
||||
type MessagePresentationButton = {
|
||||
label: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
style?: "primary" | "secondary" | "success" | "danger";
|
||||
};
|
||||
|
||||
type MessagePresentationOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
```
|
||||
|
||||
`interactive` becomes a subset of `presentation` during migration:
|
||||
|
||||
- `interactive` text block maps to `presentation.blocks[].type = "text"`.
|
||||
- `interactive` buttons block maps to `presentation.blocks[].type = "buttons"`.
|
||||
- `interactive` select block maps to `presentation.blocks[].type = "select"`.
|
||||
|
||||
The external agent and CLI schemas now use `presentation`; `interactive` remains an internal legacy parser/rendering helper for existing reply producers.
|
||||
|
||||
## Delivery Metadata
|
||||
|
||||
Add a core-owned `delivery` field for send behavior that is not UI.
|
||||
|
||||
```ts
|
||||
type ReplyPayloadDelivery = {
|
||||
pin?:
|
||||
| boolean
|
||||
| {
|
||||
enabled: boolean;
|
||||
notify?: boolean;
|
||||
required?: boolean;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- `delivery.pin = true` means pin the first successfully delivered message.
|
||||
- `notify` defaults to `false`.
|
||||
- `required` defaults to `false`; unsupported channels or failed pinning auto-degrade by continuing delivery.
|
||||
- Manual `pin`, `unpin`, and `list-pins` message actions remain for existing messages.
|
||||
|
||||
Current Telegram ACP topic binding should move from `channelData.telegram.pin = true` to `delivery.pin = true`.
|
||||
|
||||
## Runtime Capability Contract
|
||||
|
||||
Add presentation and delivery render hooks to the runtime outbound adapter, not the control-plane channel plugin.
|
||||
|
||||
```ts
|
||||
type ChannelPresentationCapabilities = {
|
||||
supported: boolean;
|
||||
buttons?: boolean;
|
||||
selects?: boolean;
|
||||
context?: boolean;
|
||||
divider?: boolean;
|
||||
tones?: MessagePresentationTone[];
|
||||
};
|
||||
|
||||
type ChannelDeliveryCapabilities = {
|
||||
pinSentMessage?: boolean;
|
||||
};
|
||||
|
||||
type ChannelOutboundAdapter = {
|
||||
presentationCapabilities?: ChannelPresentationCapabilities;
|
||||
|
||||
renderPresentation?: (params: {
|
||||
payload: ReplyPayload;
|
||||
presentation: MessagePresentation;
|
||||
ctx: ChannelOutboundSendContext;
|
||||
}) => ReplyPayload | null;
|
||||
|
||||
deliveryCapabilities?: ChannelDeliveryCapabilities;
|
||||
|
||||
pinDeliveredMessage?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
threadId?: string | number | null;
|
||||
messageId: string;
|
||||
notify: boolean;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
Core behavior:
|
||||
|
||||
- Resolve target channel and runtime adapter.
|
||||
- Ask for presentation capabilities.
|
||||
- Degrade unsupported blocks before rendering.
|
||||
- Call `renderPresentation`.
|
||||
- If no renderer exists, convert presentation to text fallback.
|
||||
- After successful send, call `pinDeliveredMessage` when `delivery.pin` is requested and supported.
|
||||
|
||||
## Channel Mapping
|
||||
|
||||
Discord:
|
||||
|
||||
- Render `presentation` to components v2 and Carbon containers in runtime-only modules.
|
||||
- Keep accent color helpers in light modules.
|
||||
- Remove `DiscordUiContainer` imports from channel plugin control-plane code.
|
||||
|
||||
Slack:
|
||||
|
||||
- Render `presentation` to Block Kit.
|
||||
- Remove agent and CLI `blocks` input.
|
||||
|
||||
Telegram:
|
||||
|
||||
- Render text, context, and dividers as text.
|
||||
- Render actions and select as inline keyboards when configured and allowed for the target surface.
|
||||
- Use text fallback when inline buttons are disabled.
|
||||
- Move ACP topic pinning to `delivery.pin`.
|
||||
|
||||
Mattermost:
|
||||
|
||||
- Render actions as interactive buttons where configured.
|
||||
- Render other blocks as text fallback.
|
||||
|
||||
MS Teams:
|
||||
|
||||
- Render `presentation` to Adaptive Cards.
|
||||
- Keep manual pin/unpin/list-pins actions.
|
||||
- Optionally implement `pinDeliveredMessage` if Graph support is reliable for the target conversation.
|
||||
|
||||
Feishu:
|
||||
|
||||
- Render `presentation` to interactive cards.
|
||||
- Keep manual pin/unpin/list-pins actions.
|
||||
- Optionally implement `pinDeliveredMessage` for sent-message pinning if API behavior is reliable.
|
||||
|
||||
LINE:
|
||||
|
||||
- Render `presentation` to Flex or template messages where possible.
|
||||
- Fall back to text for unsupported blocks.
|
||||
- Remove LINE UI payloads from `channelData`.
|
||||
|
||||
Plain or limited channels:
|
||||
|
||||
- Convert presentation to text with conservative formatting.
|
||||
|
||||
## Refactor Steps
|
||||
|
||||
1. Reapply the Discord release fix that splits `ui-colors.ts` from Carbon-backed UI and removes `DiscordUiContainer` from `extensions/discord/src/channel.ts`.
|
||||
2. Add `presentation` and `delivery` to `ReplyPayload`, outbound payload normalization, delivery summaries, and hook payloads.
|
||||
3. Add `MessagePresentation` schema and parser helpers in a narrow SDK/runtime subpath.
|
||||
4. Replace message capabilities `buttons`, `cards`, `components`, and `blocks` with semantic presentation capabilities.
|
||||
5. Add runtime outbound adapter hooks for presentation render and delivery pinning.
|
||||
6. Replace cross-context component construction with `buildCrossContextPresentation`.
|
||||
7. Delete `src/infra/outbound/channel-adapters.ts` and remove `buildCrossContextComponents` from channel plugin types.
|
||||
8. Change `maybeApplyCrossContextMarker` to attach `presentation` instead of native params.
|
||||
9. Update plugin-dispatch send paths to consume only semantic presentation and delivery metadata.
|
||||
10. Remove agent and CLI native payload params: `components`, `blocks`, `buttons`, and `card`.
|
||||
11. Remove SDK helpers that create native message-tool schemas, replacing them with presentation schema helpers.
|
||||
12. Remove UI/native envelopes from `channelData`; keep only transport metadata until each remaining field is reviewed.
|
||||
13. Migrate Discord, Slack, Telegram, Mattermost, MS Teams, Feishu, and LINE renderers.
|
||||
14. Update docs for message CLI, channel pages, plugin SDK, and capability cookbook.
|
||||
15. Run import fanout profiling for Discord and affected channel entrypoints.
|
||||
|
||||
Steps 1-11 and 13-14 are implemented in this refactor for the shared agent, CLI, plugin capability, and outbound adapter contracts. Step 12 remains a deeper internal cleanup pass for provider-private `channelData` transport envelopes. Step 15 remains follow-up validation if we want quantified import-fanout numbers beyond the type/test gate.
|
||||
|
||||
## Tests
|
||||
|
||||
Add or update:
|
||||
|
||||
- Presentation normalization tests.
|
||||
- Presentation auto-degrade tests for unsupported blocks.
|
||||
- Cross-context marker tests for plugin dispatch and core delivery paths.
|
||||
- Channel render matrix tests for Discord, Slack, Telegram, Mattermost, MS Teams, Feishu, LINE, and text fallback.
|
||||
- Message tool schema tests proving native fields are gone.
|
||||
- CLI tests proving native flags are gone.
|
||||
- Discord entrypoint import-laziness regression covering Carbon.
|
||||
- Delivery pin tests covering Telegram and generic fallback.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should `delivery.pin` be implemented for Discord, Slack, MS Teams, and Feishu in the first pass, or only Telegram first?
|
||||
- Should `delivery` eventually absorb existing fields such as `replyToId`, `replyToCurrent`, `silent`, and `audioAsVoice`, or stay focused on post-send behaviors?
|
||||
- Should presentation support images or file references directly, or should media remain separate from UI layout for now?
|
||||
@@ -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) &&
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("discord actions contract", () => {
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expectedActions: ["send", "poll", "react", "reactions", "emoji-list"],
|
||||
expectedCapabilities: ["interactive", "components"],
|
||||
expectedCapabilities: ["presentation"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createUnionActionGate,
|
||||
listTokenSourcedAccounts,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
listEnabledDiscordAccounts,
|
||||
resolveDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { createDiscordMessageToolComponentsSchema } from "./message-tool-schema.js";
|
||||
|
||||
let discordChannelActionsRuntimePromise:
|
||||
| Promise<typeof import("./channel-actions.runtime.js")>
|
||||
@@ -157,12 +155,7 @@ function describeDiscordMessageTool({
|
||||
}
|
||||
return {
|
||||
actions: Array.from(actions),
|
||||
capabilities: ["interactive", "components"],
|
||||
schema: {
|
||||
properties: {
|
||||
components: Type.Optional(createDiscordMessageToolComponentsSchema()),
|
||||
},
|
||||
},
|
||||
capabilities: ["presentation"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
buildLegacyDmAccountAllowlistAdapter,
|
||||
createAccountScopedAllowlistNameResolver,
|
||||
@@ -69,12 +68,8 @@ import { discordSetupAdapter } from "./setup-adapter.js";
|
||||
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues.js";
|
||||
import { parseDiscordTarget } from "./target-parsing.js";
|
||||
import { normalizeDiscordAccentColor, resolveDiscordAccentColor } from "./ui-colors.js";
|
||||
|
||||
type DiscordSendFn = typeof import("./send.js").sendMessageDiscord;
|
||||
type DiscordCarbonModule = typeof import("@buape/carbon");
|
||||
type DiscordTextDisplay = InstanceType<DiscordCarbonModule["TextDisplay"]>;
|
||||
type DiscordSeparator = InstanceType<DiscordCarbonModule["Separator"]>;
|
||||
|
||||
let discordProviderRuntimePromise:
|
||||
| Promise<typeof import("./monitor/provider.runtime.js")>
|
||||
@@ -83,7 +78,6 @@ let discordProbeRuntimePromise: Promise<typeof import("./probe.runtime.js")> | u
|
||||
let discordAuditModulePromise: Promise<typeof import("./audit.js")> | undefined;
|
||||
let discordSendModulePromise: Promise<typeof import("./send.js")> | undefined;
|
||||
let discordDirectoryLiveModulePromise: Promise<typeof import("./directory-live.js")> | undefined;
|
||||
let discordCarbonModuleCache: DiscordCarbonModule | null = null;
|
||||
|
||||
const loadDiscordDirectoryConfigModule = createLazyRuntimeModule(
|
||||
() => import("./directory-config.js"),
|
||||
@@ -96,8 +90,6 @@ const loadDiscordThreadBindingsManagerModule = createLazyRuntimeModule(
|
||||
() => import("./monitor/thread-bindings.manager.js"),
|
||||
);
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
async function loadDiscordProviderRuntime() {
|
||||
discordProviderRuntimePromise ??= import("./monitor/provider.runtime.js");
|
||||
return await discordProviderRuntimePromise;
|
||||
@@ -123,11 +115,6 @@ async function loadDiscordDirectoryLiveModule() {
|
||||
return await discordDirectoryLiveModulePromise;
|
||||
}
|
||||
|
||||
function loadDiscordCarbonModule() {
|
||||
discordCarbonModuleCache ??= require("@buape/carbon") as DiscordCarbonModule;
|
||||
return discordCarbonModuleCache;
|
||||
}
|
||||
|
||||
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
|
||||
function resolveDiscordAttachedOutboundTarget(params: {
|
||||
@@ -229,29 +216,17 @@ function formatDiscordIntents(intents?: {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildDiscordCrossContextComponents(params: {
|
||||
originLabel: string;
|
||||
message: string;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) {
|
||||
const { Container, Separator, TextDisplay } = loadDiscordCarbonModule();
|
||||
function buildDiscordCrossContextPresentation(params: { originLabel: string; message: string }) {
|
||||
const trimmed = params.message.trim();
|
||||
const components: Array<DiscordTextDisplay | DiscordSeparator> = [];
|
||||
if (trimmed) {
|
||||
components.push(new TextDisplay(params.message));
|
||||
components.push(new Separator({ divider: true, spacing: "small" }));
|
||||
}
|
||||
components.push(new TextDisplay(`*From ${params.originLabel}*`));
|
||||
const configuredAccent = resolveDiscordAccentColor({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return [
|
||||
new Container(components, {
|
||||
accentColor: normalizeDiscordAccentColor(configuredAccent) ?? configuredAccent,
|
||||
}),
|
||||
];
|
||||
return {
|
||||
tone: "neutral" as const,
|
||||
blocks: [
|
||||
...(trimmed
|
||||
? ([{ type: "text" as const, text: params.message }, { type: "divider" as const }] as const)
|
||||
: []),
|
||||
{ type: "context" as const, text: `From ${params.originLabel}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
|
||||
@@ -449,7 +424,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
|
||||
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
|
||||
buildCrossContextComponents: buildDiscordCrossContextComponents,
|
||||
buildCrossContextPresentation: buildDiscordCrossContextPresentation,
|
||||
resolveOutboundSessionRoute: (params) => resolveDiscordOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeDiscordTargetId,
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
normalizeOptionalStringifiedId,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { DiscordComponentMessageSpec } from "./components.js";
|
||||
import { readDiscordComponentSpec, type DiscordComponentMessageSpec } from "./components.js";
|
||||
import type { ThreadBindingRecord } from "./monitor/thread-bindings.js";
|
||||
import { normalizeDiscordOutboundTarget } from "./normalize.js";
|
||||
|
||||
@@ -160,6 +160,31 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT,
|
||||
pollMaxOptions: 10,
|
||||
normalizePayload: ({ payload }) => normalizeDiscordApprovalPayload(payload),
|
||||
presentationCapabilities: {
|
||||
supported: true,
|
||||
buttons: true,
|
||||
selects: true,
|
||||
context: true,
|
||||
divider: true,
|
||||
},
|
||||
renderPresentation: async ({ payload, presentation }) => {
|
||||
const componentSpec = (await loadDiscordSharedInteractive()).buildDiscordPresentationComponents(
|
||||
presentation,
|
||||
);
|
||||
if (!componentSpec) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
discord: {
|
||||
...(payload.channelData?.discord as Record<string, unknown> | undefined),
|
||||
presentationComponents: componentSpec,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
|
||||
sendPayload: async (ctx) => {
|
||||
const payload = normalizeDiscordApprovalPayload({
|
||||
@@ -167,10 +192,11 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
text: ctx.payload.text ?? "",
|
||||
});
|
||||
const discordData = payload.channelData?.discord as
|
||||
| { components?: DiscordComponentMessageSpec }
|
||||
| { components?: unknown; presentationComponents?: DiscordComponentMessageSpec }
|
||||
| undefined;
|
||||
const rawComponentSpec =
|
||||
discordData?.components ??
|
||||
discordData?.presentationComponents ??
|
||||
readDiscordComponentSpec(discordData?.components) ??
|
||||
(payload.interactive
|
||||
? (await loadDiscordSharedInteractive()).buildDiscordInteractiveComponents(
|
||||
payload.interactive,
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createHybridChannelConfigAdapter,
|
||||
@@ -20,6 +19,10 @@ import {
|
||||
createChannelDirectoryAdapter,
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import {
|
||||
normalizeMessagePresentation,
|
||||
renderMessagePresentationFallbackText,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
||||
@@ -118,6 +121,41 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
|
||||
"feishuChannelRuntime",
|
||||
);
|
||||
|
||||
function buildFeishuPresentationCard(params: {
|
||||
presentation: NonNullable<ReturnType<typeof normalizeMessagePresentation>>;
|
||||
fallbackText?: string;
|
||||
}): Record<string, unknown> {
|
||||
const fallbackPresentation: NonNullable<ReturnType<typeof normalizeMessagePresentation>> = {
|
||||
...(params.presentation.tone ? { tone: params.presentation.tone } : {}),
|
||||
blocks: params.presentation.blocks,
|
||||
};
|
||||
return {
|
||||
schema: "2.0",
|
||||
config: {
|
||||
width_mode: "fill",
|
||||
},
|
||||
...(params.presentation.title
|
||||
? {
|
||||
header: {
|
||||
title: { tag: "plain_text", content: params.presentation.title },
|
||||
template: "blue",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
body: {
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: renderMessagePresentationFallbackText({
|
||||
text: params.fallbackText,
|
||||
presentation: fallbackPresentation,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createFeishuActionClient(account: ResolvedFeishuAccount) {
|
||||
const { createFeishuClient } = await import("./client.js");
|
||||
return createFeishuClient(account);
|
||||
@@ -160,14 +198,7 @@ function describeFeishuMessageTool({
|
||||
if (enabledAccounts.length === 0) {
|
||||
return {
|
||||
actions: [],
|
||||
capabilities: enabled ? ["cards"] : [],
|
||||
schema: enabled
|
||||
? {
|
||||
properties: {
|
||||
card: createMessageToolCardSchema(),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
capabilities: enabled ? ["presentation"] : [],
|
||||
};
|
||||
}
|
||||
const actions = new Set<ChannelMessageActionName>([
|
||||
@@ -192,14 +223,7 @@ function describeFeishuMessageTool({
|
||||
}
|
||||
return {
|
||||
actions: Array.from(actions),
|
||||
capabilities: enabled ? ["cards"] : [],
|
||||
schema: enabled
|
||||
? {
|
||||
properties: {
|
||||
card: createMessageToolCardSchema(),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
capabilities: enabled ? ["presentation"] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -668,12 +692,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
if (ctx.action === "thread-reply" && !replyToMessageId) {
|
||||
throw new Error("Feishu thread-reply requires messageId.");
|
||||
}
|
||||
const card =
|
||||
ctx.params.card && typeof ctx.params.card === "object"
|
||||
? (ctx.params.card as Record<string, unknown>)
|
||||
: undefined;
|
||||
const presentation = normalizeMessagePresentation(ctx.params.presentation);
|
||||
const text = readFirstString(ctx.params, ["text", "message"]);
|
||||
const mediaUrl = readFeishuMediaParam(ctx.params);
|
||||
const card = presentation
|
||||
? buildFeishuPresentationCard({ presentation, fallbackText: text })
|
||||
: undefined;
|
||||
if (card && mediaUrl) {
|
||||
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.49",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageToolDiscovery,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "./channel-api.js";
|
||||
import { buildMSTeamsPresentationCard } from "./presentation.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport(
|
||||
@@ -270,11 +271,10 @@ export function describeMSTeamsMessageTool({
|
||||
"channel-info",
|
||||
] satisfies ChannelMessageActionName[])
|
||||
: [],
|
||||
capabilities: enabled ? ["cards"] : [],
|
||||
capabilities: enabled ? ["presentation"] : [],
|
||||
schema: enabled
|
||||
? {
|
||||
properties: {
|
||||
card: createMessageToolCardSchema(),
|
||||
pinnedMessageId: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
@@ -290,8 +290,13 @@ export function describeMSTeamsMessageTool({
|
||||
export const msteamsActionsAdapter: NonNullable<ChannelPlugin["actions"]> = {
|
||||
describeMessageTool: describeMSTeamsMessageTool,
|
||||
handleAction: async (ctx) => {
|
||||
if (ctx.action === "send" && ctx.params.card) {
|
||||
const card = ctx.params.card as Record<string, unknown>;
|
||||
const presentation =
|
||||
ctx.action === "send" ? normalizeMessagePresentation(ctx.params.presentation) : undefined;
|
||||
if (ctx.action === "send" && presentation) {
|
||||
const card = buildMSTeamsPresentationCard({
|
||||
presentation,
|
||||
text: resolveActionContent(ctx.params),
|
||||
});
|
||||
return await runWithRequiredActionTarget({
|
||||
actionLabel: "Card send",
|
||||
toolParams: ctx.params,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
|
||||
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
listDirectoryEntriesFromSources,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
||||
@@ -34,6 +34,7 @@ import { msTeamsApprovalAuth } from "./approval-auth.js";
|
||||
import { MSTeamsChannelConfigSchema } from "./config-schema.js";
|
||||
import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
||||
import { buildMSTeamsPresentationCard } from "./presentation.js";
|
||||
import type { ProbeMSTeamsResult } from "./probe.js";
|
||||
import {
|
||||
normalizeMSTeamsMessagingTarget,
|
||||
@@ -384,11 +385,10 @@ function describeMSTeamsMessageTool({
|
||||
"renameGroup",
|
||||
] satisfies ChannelMessageActionName[])
|
||||
: [],
|
||||
capabilities: enabled ? ["cards"] : [],
|
||||
capabilities: enabled ? ["presentation"] : [],
|
||||
schema: enabled
|
||||
? {
|
||||
properties: {
|
||||
card: createMessageToolCardSchema(),
|
||||
pinnedMessageId: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
@@ -631,9 +631,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
||||
actions: {
|
||||
describeMessageTool: describeMSTeamsMessageTool,
|
||||
handleAction: async (ctx) => {
|
||||
// Handle send action with card parameter
|
||||
if (ctx.action === "send" && ctx.params.card) {
|
||||
const card = ctx.params.card as Record<string, unknown>;
|
||||
const presentation =
|
||||
ctx.action === "send"
|
||||
? normalizeMessagePresentation(ctx.params.presentation)
|
||||
: undefined;
|
||||
if (ctx.action === "send" && presentation) {
|
||||
const card = buildMSTeamsPresentationCard({
|
||||
presentation,
|
||||
text: resolveActionContent(ctx.params),
|
||||
});
|
||||
return await runWithRequiredActionTarget({
|
||||
actionLabel: "Card send",
|
||||
toolParams: ctx.params,
|
||||
|
||||
25
extensions/msteams/src/presentation.test.ts
Normal file
25
extensions/msteams/src/presentation.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMSTeamsPresentationCard } from "./presentation.js";
|
||||
|
||||
describe("buildMSTeamsPresentationCard", () => {
|
||||
it("preserves message text when rendering presentation controls", () => {
|
||||
expect(
|
||||
buildMSTeamsPresentationCard({
|
||||
text: "Deploy finished",
|
||||
presentation: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Open", value: "open" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "AdaptiveCard",
|
||||
version: "1.4",
|
||||
body: [{ type: "TextBlock", text: "Deploy finished", wrap: true }],
|
||||
actions: [{ type: "Action.Submit", title: "Open", data: { value: "open", label: "Open" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
68
extensions/msteams/src/presentation.ts
Normal file
68
extensions/msteams/src/presentation.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export function buildMSTeamsPresentationCard(params: {
|
||||
presentation: MessagePresentation;
|
||||
text?: string | null;
|
||||
}) {
|
||||
const body: Record<string, unknown>[] = [];
|
||||
const text = normalizeOptionalString(params.text);
|
||||
if (text) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text,
|
||||
wrap: true,
|
||||
});
|
||||
}
|
||||
const { presentation } = params;
|
||||
if (presentation.title) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: presentation.title,
|
||||
weight: "Bolder",
|
||||
size: "Medium",
|
||||
wrap: true,
|
||||
});
|
||||
}
|
||||
const actions: Record<string, unknown>[] = [];
|
||||
for (const block of presentation.blocks) {
|
||||
if (block.type === "text" || block.type === "context") {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: block.text,
|
||||
wrap: true,
|
||||
...(block.type === "context" ? { isSubtle: true, size: "Small" } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (block.type === "divider") {
|
||||
body.push({ type: "TextBlock", text: "---", wrap: true, isSubtle: true });
|
||||
continue;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
for (const button of block.buttons) {
|
||||
if (button.url) {
|
||||
actions.push({
|
||||
type: "Action.OpenUrl",
|
||||
title: button.label,
|
||||
url: button.url,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (button.value) {
|
||||
actions.push({
|
||||
type: "Action.Submit",
|
||||
title: button.label,
|
||||
data: { value: button.value, label: button.label },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "AdaptiveCard",
|
||||
version: "1.4",
|
||||
body,
|
||||
...(actions.length ? { actions } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import {
|
||||
normalizeInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
|
||||
import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks } from "./blocks-render.js";
|
||||
|
||||
type SlackActionInvoke = (
|
||||
action: Record<string, unknown>,
|
||||
@@ -11,10 +13,6 @@ type SlackActionInvoke = (
|
||||
toolContext?: ChannelMessageActionContext["toolContext"],
|
||||
) => Promise<AgentToolResult<unknown>>;
|
||||
|
||||
function readSlackBlocksParam(actionParams: Record<string, unknown>) {
|
||||
return parseSlackBlocksInput(actionParams.blocks) as Record<string, unknown>[] | undefined;
|
||||
}
|
||||
|
||||
/** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */
|
||||
export async function handleSlackMessageAction(params: {
|
||||
providerId: string;
|
||||
@@ -40,9 +38,13 @@ export async function handleSlackMessageAction(params: {
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
|
||||
const presentation = normalizeMessagePresentation(actionParams.presentation);
|
||||
const interactive = normalizeInteractiveReply(actionParams.interactive);
|
||||
const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined;
|
||||
const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks;
|
||||
const presentationBlocks = presentation
|
||||
? buildSlackPresentationBlocks(presentation)
|
||||
: undefined;
|
||||
const blocks = presentationBlocks?.length ? presentationBlocks : interactiveBlocks;
|
||||
if (!content && !mediaUrl && !blocks) {
|
||||
throw new Error("Slack send requires message, blocks, or media.");
|
||||
}
|
||||
@@ -123,7 +125,8 @@ export async function handleSlackMessageAction(params: {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(actionParams, "message", { allowEmpty: true });
|
||||
const blocks = readSlackBlocksParam(actionParams);
|
||||
const presentation = normalizeMessagePresentation(actionParams.presentation);
|
||||
const blocks = presentation ? buildSlackPresentationBlocks(presentation) : undefined;
|
||||
if (!content && !blocks) {
|
||||
throw new Error("Slack edit requires message or blocks.");
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
|
||||
import { listSlackMessageActions } from "./message-actions.js";
|
||||
import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js";
|
||||
|
||||
export function describeSlackMessageTool({
|
||||
cfg,
|
||||
accountId,
|
||||
}: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0]) {
|
||||
const actions = listSlackMessageActions(cfg, accountId);
|
||||
const capabilities = new Set<"blocks" | "interactive">();
|
||||
const capabilities = new Set<"presentation">();
|
||||
if (actions.includes("send")) {
|
||||
capabilities.add("blocks");
|
||||
capabilities.add("presentation");
|
||||
}
|
||||
if (isSlackInteractiveRepliesEnabled({ cfg, accountId })) {
|
||||
capabilities.add("interactive");
|
||||
capabilities.add("presentation");
|
||||
}
|
||||
return {
|
||||
actions,
|
||||
capabilities: Array.from(capabilities),
|
||||
schema: actions.includes("send")
|
||||
? {
|
||||
properties: {
|
||||
blocks: Type.Optional(createSlackMessageToolBlocksSchema()),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
resolveInteractiveTextFallback,
|
||||
type InteractiveReply,
|
||||
type MessagePresentation,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
@@ -20,7 +21,11 @@ import {
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
|
||||
import {
|
||||
buildSlackInteractiveBlocks,
|
||||
buildSlackPresentationBlocks,
|
||||
type SlackBlock,
|
||||
} from "./blocks-render.js";
|
||||
import { compileSlackInteractiveReplies } from "./interactive-replies.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
import type { SlackSendIdentity } from "./send.js";
|
||||
@@ -154,16 +159,20 @@ async function sendSlackOutboundMessage(params: {
|
||||
function resolveSlackBlocks(payload: {
|
||||
channelData?: Record<string, unknown>;
|
||||
interactive?: InteractiveReply;
|
||||
presentation?: MessagePresentation;
|
||||
}) {
|
||||
const slackData = payload.channelData?.slack;
|
||||
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
|
||||
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
|
||||
return renderedInteractive;
|
||||
}
|
||||
const existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as
|
||||
| SlackBlock[]
|
||||
const slackData = payload.channelData?.slack as
|
||||
| { blocks?: unknown; presentationBlocks?: SlackBlock[] }
|
||||
| undefined;
|
||||
const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])];
|
||||
const nativeBlocks = parseSlackBlocksInput(slackData?.blocks) as SlackBlock[] | undefined;
|
||||
const renderedPresentation =
|
||||
slackData?.presentationBlocks ?? buildSlackPresentationBlocks(payload.presentation);
|
||||
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
|
||||
const mergedBlocks = [
|
||||
...(nativeBlocks ?? []),
|
||||
...renderedPresentation,
|
||||
...(renderedInteractive ?? []),
|
||||
];
|
||||
if (mergedBlocks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -180,6 +189,23 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
chunker: null,
|
||||
textChunkLimit: SLACK_TEXT_LIMIT,
|
||||
normalizePayload: ({ payload }) => compileSlackInteractiveReplies(payload),
|
||||
presentationCapabilities: {
|
||||
supported: true,
|
||||
buttons: true,
|
||||
selects: true,
|
||||
context: true,
|
||||
divider: true,
|
||||
},
|
||||
renderPresentation: ({ payload, presentation }) => ({
|
||||
...payload,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
slack: {
|
||||
...(payload.channelData?.slack as Record<string, unknown> | undefined),
|
||||
presentationBlocks: buildSlackPresentationBlocks(presentation),
|
||||
},
|
||||
},
|
||||
}),
|
||||
sendPayload: async (ctx) => {
|
||||
const payload = {
|
||||
...ctx.payload,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -12,15 +12,12 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
normalizeMessagePresentation,
|
||||
presentationToInteractiveReply,
|
||||
renderMessagePresentationFallbackText,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js";
|
||||
import {
|
||||
fitsTelegramCallbackData,
|
||||
TELEGRAM_CALLBACK_DATA_MAX_BYTES,
|
||||
} from "./approval-callback-data.js";
|
||||
import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
@@ -33,6 +30,7 @@ import {
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageTelegram,
|
||||
pinMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
@@ -47,6 +45,7 @@ export const telegramActionRuntime = {
|
||||
editForumTopicTelegram,
|
||||
editMessageTelegram,
|
||||
getCacheStats,
|
||||
pinMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
searchStickers,
|
||||
sendMessageTelegram,
|
||||
@@ -54,7 +53,6 @@ export const telegramActionRuntime = {
|
||||
sendStickerTelegram,
|
||||
};
|
||||
|
||||
const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"];
|
||||
const TELEGRAM_FORUM_TOPIC_ICON_COLORS = [
|
||||
0x6fb9f0, 0xffd67e, 0xcb86db, 0x8eee98, 0xff93b2, 0xfb6f5f,
|
||||
] as const;
|
||||
@@ -80,11 +78,6 @@ const TELEGRAM_ACTION_ALIASES = {
|
||||
|
||||
type TelegramActionName = (typeof TELEGRAM_ACTION_ALIASES)[keyof typeof TELEGRAM_ACTION_ALIASES];
|
||||
type TelegramForumTopicIconColor = (typeof TELEGRAM_FORUM_TOPIC_ICON_COLORS)[number];
|
||||
type RawTelegramButton = {
|
||||
callback_data?: unknown;
|
||||
style?: unknown;
|
||||
text?: unknown;
|
||||
};
|
||||
|
||||
function readTelegramForumTopicIconColor(
|
||||
params: Record<string, unknown>,
|
||||
@@ -98,56 +91,6 @@ function readTelegramForumTopicIconColor(
|
||||
}
|
||||
return iconColor as TelegramForumTopicIconColor;
|
||||
}
|
||||
export function readTelegramButtons(
|
||||
params: Record<string, unknown>,
|
||||
): TelegramInlineButtons | undefined {
|
||||
const raw = params.buttons;
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error("buttons must be an array of button rows");
|
||||
}
|
||||
const rows = raw.map((row, rowIndex) => {
|
||||
if (!Array.isArray(row)) {
|
||||
throw new Error(`buttons[${rowIndex}] must be an array`);
|
||||
}
|
||||
return row.map((button, buttonIndex) => {
|
||||
if (!button || typeof button !== "object") {
|
||||
throw new Error(`buttons[${rowIndex}][${buttonIndex}] must be an object`);
|
||||
}
|
||||
const rawButton = button as RawTelegramButton;
|
||||
const text = normalizeOptionalString(rawButton.text) ?? "";
|
||||
const callbackData = normalizeOptionalString(rawButton.callback_data) ?? "";
|
||||
if (!text || !callbackData) {
|
||||
throw new Error(`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`);
|
||||
}
|
||||
if (!fitsTelegramCallbackData(callbackData)) {
|
||||
throw new Error(
|
||||
`buttons[${rowIndex}][${buttonIndex}] callback_data too long (max ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes)`,
|
||||
);
|
||||
}
|
||||
const styleRaw = rawButton.style;
|
||||
const style = normalizeOptionalLowercaseString(styleRaw);
|
||||
if (styleRaw !== undefined && !style) {
|
||||
throw new Error(`buttons[${rowIndex}][${buttonIndex}] style must be string`);
|
||||
}
|
||||
if (style && !TELEGRAM_BUTTON_STYLES.includes(style as TelegramButtonStyle)) {
|
||||
throw new Error(
|
||||
`buttons[${rowIndex}][${buttonIndex}] style must be one of ${TELEGRAM_BUTTON_STYLES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
text,
|
||||
callback_data: callbackData,
|
||||
...(style ? { style: style as TelegramButtonStyle } : {}),
|
||||
};
|
||||
});
|
||||
});
|
||||
const filtered = rows.filter((row) => row.length > 0);
|
||||
return filtered.length > 0 ? filtered : undefined;
|
||||
}
|
||||
|
||||
function normalizeTelegramActionName(action: string): TelegramActionName {
|
||||
const normalized = TELEGRAM_ACTION_ALIASES[action as keyof typeof TELEGRAM_ACTION_ALIASES];
|
||||
if (!normalized) {
|
||||
@@ -178,10 +121,12 @@ function readTelegramReplyToMessageId(params: Record<string, unknown>) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTelegramButtonsFromParams(params: Record<string, unknown>) {
|
||||
function resolveTelegramButtonsFromParams(
|
||||
params: Record<string, unknown>,
|
||||
presentation = normalizeMessagePresentation(params.presentation),
|
||||
) {
|
||||
return resolveTelegramInlineButtons({
|
||||
buttons: readTelegramButtons(params),
|
||||
interactive: params.interactive,
|
||||
interactive: presentation ? presentationToInteractiveReply(presentation) : params.interactive,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,17 +134,79 @@ function readTelegramSendContent(params: {
|
||||
args: Record<string, unknown>;
|
||||
mediaUrl?: string;
|
||||
hasButtons: boolean;
|
||||
presentation?: MessagePresentation;
|
||||
}) {
|
||||
const content =
|
||||
const explicitContent =
|
||||
readStringParam(params.args, "content", { allowEmpty: true }) ??
|
||||
readStringParam(params.args, "message", { allowEmpty: true }) ??
|
||||
readStringParam(params.args, "caption", { allowEmpty: true });
|
||||
const presentationText =
|
||||
explicitContent == null && params.presentation
|
||||
? renderMessagePresentationFallbackText({ presentation: params.presentation })
|
||||
: undefined;
|
||||
const content = explicitContent ?? (presentationText?.trim() ? presentationText : undefined);
|
||||
if (content == null && !params.mediaUrl && !params.hasButtons) {
|
||||
throw new Error("content required.");
|
||||
}
|
||||
return content ?? "";
|
||||
}
|
||||
|
||||
function normalizeTelegramDeliveryPin(params: Record<string, unknown>) {
|
||||
const delivery = params.delivery;
|
||||
const pin =
|
||||
delivery && typeof delivery === "object" && !Array.isArray(delivery)
|
||||
? (delivery as { pin?: unknown }).pin
|
||||
: params.pin === true
|
||||
? true
|
||||
: undefined;
|
||||
if (pin === true) {
|
||||
return { enabled: true } as const;
|
||||
}
|
||||
if (!pin || typeof pin !== "object" || Array.isArray(pin)) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = pin as { enabled?: unknown; notify?: unknown; required?: unknown };
|
||||
if (raw.enabled !== true) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
...(raw.notify === true ? { notify: true } : {}),
|
||||
...(raw.required === true ? { required: true } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function maybePinTelegramActionSend(params: {
|
||||
args: Record<string, unknown>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
to: string;
|
||||
messageId?: string;
|
||||
}) {
|
||||
const pin = normalizeTelegramDeliveryPin(params.args);
|
||||
if (!pin) {
|
||||
return;
|
||||
}
|
||||
if (!params.messageId) {
|
||||
if (pin.required) {
|
||||
throw new Error("Telegram delivery pin requested, but no message id was returned.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await telegramActionRuntime.pinMessageTelegram(params.to, params.messageId, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
notify: pin.notify,
|
||||
verbose: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (pin.required) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTelegramAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
@@ -308,11 +315,13 @@ export async function handleTelegramAction(
|
||||
readStringParam(params, "media", {
|
||||
trim: false,
|
||||
});
|
||||
const buttons = resolveTelegramButtonsFromParams(params);
|
||||
const presentation = normalizeMessagePresentation(params.presentation);
|
||||
const buttons = resolveTelegramButtonsFromParams(params, presentation);
|
||||
const content = readTelegramSendContent({
|
||||
args: params,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
hasButtons: Array.isArray(buttons) && buttons.length > 0,
|
||||
presentation,
|
||||
});
|
||||
if (buttons) {
|
||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||
@@ -369,6 +378,13 @@ export async function handleTelegramAction(
|
||||
readBooleanParam(params, "asDocument") ??
|
||||
false,
|
||||
});
|
||||
await maybePinTelegramActionSend({
|
||||
args: params,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
to,
|
||||
messageId: result.messageId,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
|
||||
@@ -487,18 +487,20 @@ async function deliverMediaReply(params: {
|
||||
}
|
||||
|
||||
async function maybePinFirstDeliveredMessage(params: {
|
||||
shouldPin: boolean;
|
||||
pin: NonNullable<ReplyPayload["delivery"]>["pin"] | undefined;
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
firstDeliveredMessageId?: number;
|
||||
}): Promise<void> {
|
||||
if (!params.shouldPin || typeof params.firstDeliveredMessageId !== "number") {
|
||||
const shouldPin = params.pin === true || (typeof params.pin === "object" && params.pin.enabled);
|
||||
if (!shouldPin || typeof params.firstDeliveredMessageId !== "number") {
|
||||
return;
|
||||
}
|
||||
const notify = typeof params.pin === "object" && params.pin.notify === true;
|
||||
try {
|
||||
await params.bot.api.pinChatMessage(params.chatId, params.firstDeliveredMessageId, {
|
||||
disable_notification: true,
|
||||
disable_notification: !notify,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
@@ -705,7 +707,6 @@ export async function deliverReplies(params: {
|
||||
const replyToId =
|
||||
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
||||
const telegramData = reply.channelData?.telegram as TelegramReplyChannelData | undefined;
|
||||
const shouldPinFirstMessage = telegramData?.pin === true;
|
||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
if (mediaList.length === 0) {
|
||||
@@ -747,7 +748,7 @@ export async function deliverReplies(params: {
|
||||
});
|
||||
}
|
||||
await maybePinFirstDeliveredMessage({
|
||||
shouldPin: shouldPinFirstMessage,
|
||||
pin: reply.delivery?.pin,
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1093,6 +1093,7 @@ type TelegramDeleteOpts = {
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
notify?: boolean;
|
||||
verbose?: boolean;
|
||||
api?: TelegramApiOverride;
|
||||
retry?: RetryConfig;
|
||||
@@ -1147,7 +1148,10 @@ export async function pinMessageTelegram(
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
await requestWithDiag(
|
||||
() => api.pinChatMessage(chatId, messageId, { disable_notification: true }),
|
||||
() =>
|
||||
api.pinChatMessage(chatId, messageId, {
|
||||
disable_notification: opts.notify !== true,
|
||||
}),
|
||||
"pinChatMessage",
|
||||
);
|
||||
logVerbose(`[telegram] Pinned message ${messageId} in chat ${chatId}`);
|
||||
|
||||
@@ -3,10 +3,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
|
||||
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
import {
|
||||
createMessageToolButtonsSchema,
|
||||
createMessageToolCardSchema,
|
||||
} from "../../plugin-sdk/channel-actions.js";
|
||||
type CreateMessageTool = typeof import("./message-tool.js").createMessageTool;
|
||||
type ResetPluginRuntimeStateForTest =
|
||||
typeof import("../../plugins/runtime.js").resetPluginRuntimeStateForTest;
|
||||
@@ -24,14 +20,6 @@ type DescribeMessageTool = NonNullable<
|
||||
type MessageToolDiscoveryContext = Parameters<DescribeMessageTool>[0];
|
||||
type MessageToolSchema = NonNullable<ReturnType<DescribeMessageTool>>["schema"];
|
||||
|
||||
function createDiscordMessageToolComponentsSchema() {
|
||||
return Type.Object({ type: Type.Literal("discord-components") });
|
||||
}
|
||||
|
||||
function createSlackMessageToolBlocksSchema() {
|
||||
return Type.Array(Type.Object({}, { additionalProperties: true }));
|
||||
}
|
||||
|
||||
function createTelegramPollExtraToolSchemas() {
|
||||
return {
|
||||
pollDurationSeconds: Type.Optional(Type.Number()),
|
||||
@@ -40,24 +28,6 @@ function createTelegramPollExtraToolSchemas() {
|
||||
};
|
||||
}
|
||||
|
||||
function createCardSchemaPlugin(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
blurb: string;
|
||||
}) {
|
||||
return createChannelPlugin({
|
||||
...params,
|
||||
actions: ["send"],
|
||||
capabilities: ["cards"],
|
||||
toolSchema: () => ({
|
||||
properties: {
|
||||
card: createMessageToolCardSchema(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runMessageAction: vi.fn(),
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
@@ -408,13 +378,8 @@ describe("message tool schema scoping", () => {
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
actions: ["send", "react", "poll"],
|
||||
capabilities: ["interactive", "buttons"],
|
||||
capabilities: ["presentation"],
|
||||
toolSchema: () => [
|
||||
{
|
||||
properties: {
|
||||
buttons: createMessageToolButtonsSchema(),
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: createTelegramPollExtraToolSchemas(),
|
||||
visibility: "all-configured",
|
||||
@@ -428,12 +393,7 @@ describe("message tool schema scoping", () => {
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "Discord test plugin.",
|
||||
actions: ["send", "poll", "poll-vote"],
|
||||
capabilities: ["interactive", "components"],
|
||||
toolSchema: () => ({
|
||||
properties: {
|
||||
components: createDiscordMessageToolComponentsSchema(),
|
||||
},
|
||||
}),
|
||||
capabilities: ["presentation"],
|
||||
});
|
||||
|
||||
const slackPlugin = createChannelPlugin({
|
||||
@@ -442,12 +402,7 @@ describe("message tool schema scoping", () => {
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "Slack test plugin.",
|
||||
actions: ["send", "react"],
|
||||
capabilities: ["interactive", "blocks"],
|
||||
toolSchema: () => ({
|
||||
properties: {
|
||||
blocks: createSlackMessageToolBlocksSchema(),
|
||||
},
|
||||
}),
|
||||
capabilities: ["presentation"],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -457,42 +412,22 @@ describe("message tool schema scoping", () => {
|
||||
it.each([
|
||||
{
|
||||
provider: "telegram",
|
||||
expectComponents: false,
|
||||
expectBlocks: false,
|
||||
expectButtons: true,
|
||||
expectButtonStyle: true,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "react", "poll", "poll-vote"],
|
||||
},
|
||||
{
|
||||
provider: "discord",
|
||||
expectComponents: true,
|
||||
expectBlocks: false,
|
||||
expectButtons: false,
|
||||
expectButtonStyle: false,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "poll", "poll-vote", "react"],
|
||||
},
|
||||
{
|
||||
provider: "slack",
|
||||
expectComponents: false,
|
||||
expectBlocks: true,
|
||||
expectButtons: false,
|
||||
expectButtonStyle: false,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "react", "poll", "poll-vote"],
|
||||
},
|
||||
])(
|
||||
"scopes schema fields for $provider",
|
||||
({
|
||||
provider,
|
||||
expectComponents,
|
||||
expectBlocks,
|
||||
expectButtons,
|
||||
expectButtonStyle,
|
||||
expectTelegramPollExtras,
|
||||
expectedActions,
|
||||
}) => {
|
||||
({ provider, expectTelegramPollExtras, expectedActions }) => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||
@@ -508,30 +443,10 @@ describe("message tool schema scoping", () => {
|
||||
const properties = getToolProperties(tool);
|
||||
const actionEnum = getActionEnum(properties);
|
||||
|
||||
if (expectComponents) {
|
||||
expect(properties.components).toBeDefined();
|
||||
} else {
|
||||
expect(properties.components).toBeUndefined();
|
||||
}
|
||||
if (expectBlocks) {
|
||||
expect(properties.blocks).toBeDefined();
|
||||
} else {
|
||||
expect(properties.blocks).toBeUndefined();
|
||||
}
|
||||
if (expectButtons) {
|
||||
expect(properties.buttons).toBeDefined();
|
||||
} else {
|
||||
expect(properties.buttons).toBeUndefined();
|
||||
}
|
||||
if (expectButtonStyle) {
|
||||
const buttonItemProps =
|
||||
(
|
||||
properties.buttons as {
|
||||
items?: { items?: { properties?: Record<string, unknown> } };
|
||||
}
|
||||
)?.items?.items?.properties ?? {};
|
||||
expect(buttonItemProps.style).toBeDefined();
|
||||
}
|
||||
expect(properties.presentation).toBeDefined();
|
||||
expect(properties.components).toBeUndefined();
|
||||
expect(properties.blocks).toBeUndefined();
|
||||
expect(properties.buttons).toBeUndefined();
|
||||
for (const action of expectedActions) {
|
||||
expect(actionEnum).toContain(action);
|
||||
}
|
||||
@@ -564,64 +479,6 @@ describe("message tool schema scoping", () => {
|
||||
expect(actionEnum).toContain("poll");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
provider: "feishu",
|
||||
plugin: createCardSchemaPlugin({
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
docsPath: "/channels/feishu",
|
||||
blurb: "Feishu test plugin.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: "msteams",
|
||||
plugin: createCardSchemaPlugin({
|
||||
id: "msteams",
|
||||
label: "MSTeams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "MSTeams test plugin.",
|
||||
}),
|
||||
},
|
||||
])(
|
||||
"keeps $provider card schema optional after merging into the message tool schema",
|
||||
({ plugin }) => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: plugin.id, source: "test", plugin }]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: plugin.id,
|
||||
});
|
||||
const schema = tool.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
expect(schema.properties?.card).toBeDefined();
|
||||
expect(schema.required ?? []).not.toContain("card");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps buttons schema optional so plain sends do not require buttons", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "telegram",
|
||||
});
|
||||
const schema = tool.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
expect(schema.properties?.buttons).toBeDefined();
|
||||
expect(schema.required ?? []).not.toContain("buttons");
|
||||
});
|
||||
|
||||
it("hides telegram poll extras when telegram polls are disabled in scoped mode", () => {
|
||||
const telegramPluginWithConfig = createChannelPlugin({
|
||||
id: "telegram",
|
||||
@@ -634,22 +491,16 @@ describe("message tool schema scoping", () => {
|
||||
return {
|
||||
actions:
|
||||
telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"],
|
||||
capabilities: ["interactive", "buttons"],
|
||||
schema: [
|
||||
{
|
||||
properties: {
|
||||
buttons: createMessageToolButtonsSchema(),
|
||||
},
|
||||
},
|
||||
...(telegramCfg?.actions?.poll === false
|
||||
capabilities: ["presentation"],
|
||||
schema:
|
||||
telegramCfg?.actions?.poll === false
|
||||
? []
|
||||
: [
|
||||
{
|
||||
properties: createTelegramPollExtraToolSchemas(),
|
||||
visibility: "all-configured" as const,
|
||||
},
|
||||
]),
|
||||
],
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -681,7 +532,7 @@ describe("message tool schema scoping", () => {
|
||||
expect(properties.pollPublic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses discovery account scope for capability-gated shared fields", () => {
|
||||
it("uses discovery account scope for capability-gated presentation", () => {
|
||||
const scopedInteractivePlugin = createChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
@@ -689,7 +540,7 @@ describe("message tool schema scoping", () => {
|
||||
blurb: "Telegram test plugin.",
|
||||
describeMessageTool: ({ accountId }) => ({
|
||||
actions: ["send"],
|
||||
capabilities: accountId === "ops" ? ["interactive"] : [],
|
||||
capabilities: accountId === "ops" ? ["presentation"] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -709,8 +560,8 @@ describe("message tool schema scoping", () => {
|
||||
currentChannelProvider: "telegram",
|
||||
});
|
||||
|
||||
expect(getToolProperties(scopedTool).interactive).toBeDefined();
|
||||
expect(getToolProperties(unscopedTool).interactive).toBeUndefined();
|
||||
expect(getToolProperties(scopedTool).presentation).toBeDefined();
|
||||
expect(getToolProperties(unscopedTool).presentation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses discovery account scope for other configured channel actions", () => {
|
||||
@@ -765,7 +616,7 @@ describe("message tool schema scoping", () => {
|
||||
seenContexts.push({ phase: "describeMessageTool", ...ctx });
|
||||
return {
|
||||
actions: ["send", "react"],
|
||||
capabilities: ["interactive"],
|
||||
capabilities: ["presentation"],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,36 +54,39 @@ function buildRoutingSchema() {
|
||||
};
|
||||
}
|
||||
|
||||
const interactiveOptionSchema = Type.Object({
|
||||
const presentationOptionSchema = Type.Object({
|
||||
label: Type.String(),
|
||||
value: Type.String(),
|
||||
});
|
||||
|
||||
const interactiveButtonSchema = Type.Object({
|
||||
const presentationButtonSchema = Type.Object({
|
||||
label: Type.String(),
|
||||
value: Type.String(),
|
||||
value: Type.Optional(Type.String()),
|
||||
url: Type.Optional(Type.String()),
|
||||
style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger"])),
|
||||
});
|
||||
|
||||
const interactiveBlockSchema = Type.Object({
|
||||
type: stringEnum(["text", "buttons", "select"]),
|
||||
const presentationBlockSchema = Type.Object({
|
||||
type: stringEnum(["text", "context", "divider", "buttons", "select"]),
|
||||
text: Type.Optional(Type.String()),
|
||||
buttons: Type.Optional(Type.Array(interactiveButtonSchema)),
|
||||
buttons: Type.Optional(Type.Array(presentationButtonSchema)),
|
||||
placeholder: Type.Optional(Type.String()),
|
||||
options: Type.Optional(Type.Array(interactiveOptionSchema)),
|
||||
options: Type.Optional(Type.Array(presentationOptionSchema)),
|
||||
});
|
||||
|
||||
const interactiveMessageSchema = Type.Object(
|
||||
const presentationMessageSchema = Type.Object(
|
||||
{
|
||||
blocks: Type.Array(interactiveBlockSchema),
|
||||
title: Type.Optional(Type.String()),
|
||||
tone: Type.Optional(stringEnum(["info", "success", "warning", "danger", "neutral"])),
|
||||
blocks: Type.Array(presentationBlockSchema),
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Shared interactive message payload for buttons and selects. Channels render this into their native components when supported.",
|
||||
"Shared presentation payload for rich text, buttons, selects, and context. Core degrades unsupported blocks to text.",
|
||||
},
|
||||
);
|
||||
|
||||
function buildSendSchema(options: { includeInteractive: boolean }) {
|
||||
function buildSendSchema(options: { includePresentation: boolean; includeDeliveryPin: boolean }) {
|
||||
const props: Record<string, TSchema> = {
|
||||
message: Type.Optional(Type.String()),
|
||||
effectId: Type.Optional(
|
||||
@@ -130,10 +133,31 @@ function buildSendSchema(options: { includeInteractive: boolean }) {
|
||||
"Send image/GIF as document to avoid Telegram compression. Alias for forceDocument (Telegram only).",
|
||||
}),
|
||||
),
|
||||
interactive: Type.Optional(interactiveMessageSchema),
|
||||
};
|
||||
if (!options.includeInteractive) {
|
||||
delete props.interactive;
|
||||
if (options.includePresentation) {
|
||||
props.presentation = Type.Optional(presentationMessageSchema);
|
||||
}
|
||||
if (options.includeDeliveryPin) {
|
||||
props.delivery = Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
pin: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Boolean(),
|
||||
Type.Object({
|
||||
enabled: Type.Boolean(),
|
||||
notify: Type.Optional(Type.Boolean()),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Shared delivery preferences. pin requests that the sent message be pinned when the channel supports it.",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return props;
|
||||
}
|
||||
@@ -353,7 +377,8 @@ function buildChannelManagementSchema() {
|
||||
}
|
||||
|
||||
function buildMessageToolSchemaProps(options: {
|
||||
includeInteractive: boolean;
|
||||
includePresentation: boolean;
|
||||
includeDeliveryPin: boolean;
|
||||
extraProperties?: Record<string, TSchema>;
|
||||
}) {
|
||||
return {
|
||||
@@ -377,7 +402,8 @@ function buildMessageToolSchemaProps(options: {
|
||||
function buildMessageToolSchemaFromActions(
|
||||
actions: readonly string[],
|
||||
options: {
|
||||
includeInteractive: boolean;
|
||||
includePresentation: boolean;
|
||||
includeDeliveryPin: boolean;
|
||||
extraProperties?: Record<string, TSchema>;
|
||||
},
|
||||
) {
|
||||
@@ -389,7 +415,8 @@ function buildMessageToolSchemaFromActions(
|
||||
}
|
||||
|
||||
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||
includeInteractive: true,
|
||||
includePresentation: true,
|
||||
includeDeliveryPin: true,
|
||||
});
|
||||
|
||||
type MessageToolOptions = {
|
||||
@@ -494,13 +521,18 @@ function resolveIncludeCapability(
|
||||
return channelSupportsMessageCapability(params.cfg, capability);
|
||||
}
|
||||
|
||||
function resolveIncludeInteractive(params: MessageToolDiscoveryParams): boolean {
|
||||
return resolveIncludeCapability(params, "interactive");
|
||||
function resolveIncludePresentation(params: MessageToolDiscoveryParams): boolean {
|
||||
return resolveIncludeCapability(params, "presentation");
|
||||
}
|
||||
|
||||
function resolveIncludeDeliveryPin(params: MessageToolDiscoveryParams): boolean {
|
||||
return resolveIncludeCapability(params, "delivery-pin");
|
||||
}
|
||||
|
||||
function buildMessageToolSchema(params: MessageToolDiscoveryParams) {
|
||||
const actions = resolveMessageToolSchemaActions(params);
|
||||
const includeInteractive = resolveIncludeInteractive(params);
|
||||
const includePresentation = resolveIncludePresentation(params);
|
||||
const includeDeliveryPin = resolveIncludeDeliveryPin(params);
|
||||
const extraProperties = resolveChannelMessageToolSchemaProperties(
|
||||
buildMessageActionDiscoveryInput(
|
||||
params,
|
||||
@@ -508,7 +540,8 @@ function buildMessageToolSchema(params: MessageToolDiscoveryParams) {
|
||||
),
|
||||
);
|
||||
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
|
||||
includeInteractive,
|
||||
includePresentation,
|
||||
includeDeliveryPin,
|
||||
extraProperties,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type SessionBindingService,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
|
||||
import type { ReplyPayload } from "../../types.js";
|
||||
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
|
||||
import {
|
||||
resolveAcpCommandAccountId,
|
||||
@@ -77,20 +78,19 @@ function resolveAcpBindingLabelNoun(params: {
|
||||
return params.conversationId === params.threadId ? "thread" : "conversation";
|
||||
}
|
||||
|
||||
async function resolveBoundReplyChannelData(params: {
|
||||
async function resolveBoundReplyPayload(params: {
|
||||
binding: SessionBindingRecord;
|
||||
placement: "current" | "child";
|
||||
}): Promise<Record<string, unknown> | undefined> {
|
||||
}): Promise<Pick<ReplyPayload, "channelData" | "delivery" | "presentation"> | undefined> {
|
||||
const channelId = normalizeChannelId(params.binding.conversation.channel);
|
||||
if (!channelId) {
|
||||
return undefined;
|
||||
}
|
||||
const buildChannelData =
|
||||
getChannelPlugin(channelId)?.conversationBindings?.buildBoundReplyChannelData;
|
||||
if (!buildChannelData) {
|
||||
const buildPayload = getChannelPlugin(channelId)?.conversationBindings?.buildBoundReplyPayload;
|
||||
if (!buildPayload) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = await buildChannelData({
|
||||
const resolved = await buildPayload({
|
||||
operation: "acp-spawn",
|
||||
placement: params.placement,
|
||||
conversation: params.binding.conversation,
|
||||
@@ -621,16 +621,16 @@ export async function handleAcpSpawnAction(
|
||||
} else {
|
||||
parts.push(`Created ${placementLabel} ${boundConversationId} and bound it to ${sessionKey}.`);
|
||||
}
|
||||
const channelData = await resolveBoundReplyChannelData({
|
||||
const boundReplyPayload = await resolveBoundReplyPayload({
|
||||
binding,
|
||||
placement: bindingPlacement,
|
||||
});
|
||||
if (channelData) {
|
||||
if (boundReplyPayload) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: parts.join(" "),
|
||||
channelData,
|
||||
...boundReplyPayload,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -32,17 +32,17 @@ const slackPlugin: Pick<ChannelPlugin, "actions"> = {
|
||||
account.appToken.trim() !== "";
|
||||
const capabilities = new Set<string>();
|
||||
if (enabled) {
|
||||
capabilities.add("blocks");
|
||||
capabilities.add("presentation");
|
||||
}
|
||||
if (
|
||||
account?.capabilities &&
|
||||
(account.capabilities as { interactiveReplies?: unknown }).interactiveReplies === true
|
||||
) {
|
||||
capabilities.add("interactive");
|
||||
capabilities.add("presentation");
|
||||
}
|
||||
return {
|
||||
actions: enabled ? ["send"] : [],
|
||||
capabilities: Array.from(capabilities) as Array<"blocks" | "interactive">,
|
||||
capabilities: Array.from(capabilities) as Array<"presentation">,
|
||||
};
|
||||
},
|
||||
supportsAction: () => true,
|
||||
@@ -61,7 +61,7 @@ const mattermostPlugin: Pick<ChannelPlugin, "actions"> = {
|
||||
account.baseUrl.trim() !== "";
|
||||
return {
|
||||
actions: enabled ? ["send"] : [],
|
||||
capabilities: enabled ? (["buttons"] as const) : [],
|
||||
capabilities: enabled ? (["presentation"] as const) : [],
|
||||
};
|
||||
},
|
||||
supportsAction: () => true,
|
||||
@@ -80,7 +80,7 @@ const feishuPlugin: Pick<ChannelPlugin, "actions"> = {
|
||||
account.appSecret.trim() !== "";
|
||||
return {
|
||||
actions: enabled ? ["send"] : [],
|
||||
capabilities: enabled ? (["cards"] as const) : [],
|
||||
capabilities: enabled ? (["presentation"] as const) : [],
|
||||
};
|
||||
},
|
||||
supportsAction: () => true,
|
||||
@@ -101,7 +101,7 @@ const msteamsPlugin: Pick<ChannelPlugin, "actions"> = {
|
||||
account.appPassword.trim() !== "";
|
||||
return {
|
||||
actions: enabled ? ["poll"] : [],
|
||||
capabilities: enabled ? (["cards"] as const) : [],
|
||||
capabilities: enabled ? (["presentation"] as const) : [],
|
||||
};
|
||||
},
|
||||
supportsAction: () => true,
|
||||
@@ -127,7 +127,7 @@ describe("channel action capability matrix", () => {
|
||||
return [...(describeMessageTool?.({ cfg })?.capabilities ?? [])];
|
||||
}
|
||||
|
||||
it("exposes Slack blocks by default and interactive when enabled", () => {
|
||||
it("exposes Slack presentation when configured", () => {
|
||||
const baseCfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -146,26 +146,26 @@ describe("channel action capability matrix", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(getCapabilities(slackPlugin, baseCfg)).toEqual(["blocks"]);
|
||||
expect(getCapabilities(slackPlugin, interactiveCfg)).toEqual(["blocks", "interactive"]);
|
||||
expect(getCapabilities(slackPlugin, baseCfg)).toEqual(["presentation"]);
|
||||
expect(getCapabilities(slackPlugin, interactiveCfg)).toEqual(["presentation"]);
|
||||
});
|
||||
|
||||
it("forwards Telegram action capabilities through the channel wrapper", () => {
|
||||
telegramDescribeMessageToolMock.mockReturnValue({
|
||||
capabilities: ["interactive", "buttons"],
|
||||
capabilities: ["presentation"],
|
||||
});
|
||||
|
||||
const result = getCapabilities(telegramPlugin, {} as OpenClawConfig);
|
||||
|
||||
expect(result).toEqual(["interactive", "buttons"]);
|
||||
expect(result).toEqual(["presentation"]);
|
||||
expect(telegramDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} });
|
||||
discordDescribeMessageToolMock.mockReturnValue({
|
||||
capabilities: ["interactive", "components"],
|
||||
capabilities: ["presentation"],
|
||||
});
|
||||
|
||||
const discordResult = getCapabilities(discordPlugin, {} as OpenClawConfig);
|
||||
|
||||
expect(discordResult).toEqual(["interactive", "components"]);
|
||||
expect(discordResult).toEqual(["presentation"]);
|
||||
expect(discordDescribeMessageToolMock).toHaveBeenCalledWith({ cfg: {} });
|
||||
});
|
||||
|
||||
@@ -225,11 +225,11 @@ describe("channel action capability matrix", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(getCapabilities(mattermostPlugin, configuredCfg)).toEqual(["buttons"]);
|
||||
expect(getCapabilities(mattermostPlugin, configuredCfg)).toEqual(["presentation"]);
|
||||
expect(getCapabilities(mattermostPlugin, unconfiguredCfg)).toEqual([]);
|
||||
expect(getCapabilities(feishuPlugin, configuredFeishuCfg)).toEqual(["cards"]);
|
||||
expect(getCapabilities(feishuPlugin, configuredFeishuCfg)).toEqual(["presentation"]);
|
||||
expect(getCapabilities(feishuPlugin, disabledFeishuCfg)).toEqual([]);
|
||||
expect(getCapabilities(msteamsPlugin, configuredMsteamsCfg)).toEqual(["cards"]);
|
||||
expect(getCapabilities(msteamsPlugin, configuredMsteamsCfg)).toEqual(["presentation"]);
|
||||
expect(getCapabilities(msteamsPlugin, disabledMsteamsCfg)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver-types.js";
|
||||
import type { OutboundIdentity } from "../../infra/outbound/identity-types.js";
|
||||
import type { OutboundSendDeps } from "../../infra/outbound/send-deps.js";
|
||||
import type { MessagePresentation, ReplyPayloadDeliveryPin } from "../../interactive/payload.js";
|
||||
import type { OutboundMediaAccess } from "../../media/load-options.js";
|
||||
import type {
|
||||
ChannelOutboundTargetMode,
|
||||
@@ -35,6 +36,18 @@ export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
|
||||
payload: ReplyPayload;
|
||||
};
|
||||
|
||||
export type ChannelPresentationCapabilities = {
|
||||
supported?: boolean;
|
||||
buttons?: boolean;
|
||||
selects?: boolean;
|
||||
context?: boolean;
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelDeliveryCapabilities = {
|
||||
pin?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelOutboundPayloadHint =
|
||||
| { kind: "approval-pending"; approvalKind: "exec" | "plugin" }
|
||||
| { kind: "approval-resolved"; approvalKind: "exec" | "plugin" };
|
||||
@@ -78,6 +91,19 @@ export type ChannelOutboundAdapter = {
|
||||
payload: ReplyPayload;
|
||||
hint?: ChannelOutboundPayloadHint;
|
||||
}) => Promise<void> | void;
|
||||
presentationCapabilities?: ChannelPresentationCapabilities;
|
||||
deliveryCapabilities?: ChannelDeliveryCapabilities;
|
||||
renderPresentation?: (params: {
|
||||
payload: ReplyPayload;
|
||||
presentation: MessagePresentation;
|
||||
ctx: ChannelOutboundPayloadContext;
|
||||
}) => Promise<ReplyPayload | null> | ReplyPayload | null;
|
||||
pinDeliveredMessage?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
target: ChannelOutboundTargetRef;
|
||||
messageId: string;
|
||||
pin: ReplyPayloadDeliveryPin;
|
||||
}) => Promise<void> | void;
|
||||
/**
|
||||
* @deprecated Use shouldTreatDeliveredTextAsVisible instead.
|
||||
*/
|
||||
|
||||
@@ -762,7 +762,7 @@ export type ChannelConversationBindingSupport = {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null;
|
||||
buildBoundReplyChannelData?: (params: {
|
||||
buildBoundReplyPayload?: (params: {
|
||||
operation: "acp-spawn";
|
||||
placement: "current" | "child";
|
||||
conversation: {
|
||||
@@ -771,7 +771,10 @@ export type ChannelConversationBindingSupport = {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
}) => ReplyPayload["channelData"] | null | Promise<ReplyPayload["channelData"] | null>;
|
||||
}) =>
|
||||
| Pick<ReplyPayload, "channelData" | "delivery" | "presentation">
|
||||
| null
|
||||
| Promise<Pick<ReplyPayload, "channelData" | "delivery" | "presentation"> | null>;
|
||||
buildModelOverrideParentCandidates?: (params: {
|
||||
parentConversationId?: string | null;
|
||||
}) => string[] | null | undefined;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,6 +79,7 @@ export type {
|
||||
ChannelStatusIssue,
|
||||
ChannelStreamingAdapter,
|
||||
ChannelStructuredComponents,
|
||||
ChannelCrossContextPresentationFactory,
|
||||
ChannelThreadingAdapter,
|
||||
ChannelThreadingContext,
|
||||
ChannelThreadingToolContext,
|
||||
|
||||
@@ -428,23 +428,34 @@ describe("runDaemonInstall", () => {
|
||||
},
|
||||
} as never);
|
||||
|
||||
await runDaemonInstall({ json: true, force: true });
|
||||
const previous = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
try {
|
||||
await runDaemonInstall({ json: true, force: true });
|
||||
|
||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENAI_API_KEY: "service-openai-key",
|
||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENAI_API_KEY: "service-openai-key",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [firstArg] =
|
||||
(buildGatewayInstallPlanMock.mock.calls.at(0) as [Record<string, unknown>] | undefined) ?? [];
|
||||
const env = firstArg?.env as Record<string, string | undefined>;
|
||||
expect(env.OPENCLAW_STATE_DIR).toBeUndefined();
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined();
|
||||
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
|
||||
expect(env.NODE_OPTIONS).toBeUndefined();
|
||||
expect(env.PATH).not.toContain("/tmp/doctor-bin");
|
||||
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
|
||||
);
|
||||
const [firstArg] =
|
||||
(buildGatewayInstallPlanMock.mock.calls.at(0) as [Record<string, unknown>] | undefined) ??
|
||||
[];
|
||||
const env = firstArg?.env as Record<string, string | undefined>;
|
||||
expect(env.OPENCLAW_STATE_DIR).toBeUndefined();
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined();
|
||||
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
|
||||
expect(env.NODE_OPTIONS).toBeUndefined();
|
||||
expect(env.PATH).not.toContain("/tmp/doctor-bin");
|
||||
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,15 +16,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
|
||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
||||
)
|
||||
.option(
|
||||
"--interactive <json>",
|
||||
"Shared interactive payload as JSON (buttons/selects rendered natively by supported channels)",
|
||||
"--presentation <json>",
|
||||
"Shared presentation payload as JSON (text, context, dividers, buttons, selects)",
|
||||
)
|
||||
.option(
|
||||
"--buttons <json>",
|
||||
"Telegram inline keyboard buttons as JSON (array of button rows)",
|
||||
)
|
||||
.option("--components <json>", "Discord components payload as JSON")
|
||||
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
|
||||
.option("--delivery <json>", "Shared delivery preferences as JSON")
|
||||
.option("--pin", "Request that the delivered message be pinned when supported", false)
|
||||
.option("--reply-to <id>", "Reply-to message id")
|
||||
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
||||
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { getChannelMessageAdapter } from "./channel-adapters.js";
|
||||
|
||||
class TestTextDisplay {
|
||||
constructor(readonly content: string) {}
|
||||
}
|
||||
|
||||
class TestSeparator {
|
||||
constructor(readonly options: { divider: boolean; spacing: string }) {}
|
||||
}
|
||||
|
||||
class TestRichUiContainer {
|
||||
constructor(readonly components: Array<TestTextDisplay | TestSeparator>) {}
|
||||
}
|
||||
|
||||
const richCrossContextPlugin: Pick<
|
||||
ChannelPlugin,
|
||||
"id" | "meta" | "capabilities" | "config" | "messaging"
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "rich-chat" }),
|
||||
messaging: {
|
||||
buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => {
|
||||
const trimmed = message.trim();
|
||||
const components: Array<TestTextDisplay | TestSeparator> = [];
|
||||
if (trimmed) {
|
||||
components.push(new TestTextDisplay(message));
|
||||
components.push(new TestSeparator({ divider: true, spacing: "small" }));
|
||||
}
|
||||
components.push(new TestTextDisplay(`*From ${originLabel}*`));
|
||||
void cfg;
|
||||
void accountId;
|
||||
return [new TestRichUiContainer(components)];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("getChannelMessageAdapter", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "rich-chat", plugin: richCrossContextPlugin, source: "test" },
|
||||
{
|
||||
pluginId: "plain-chat",
|
||||
plugin: createChannelTestPluginBase({ id: "plain-chat" }),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the default adapter for channels without structured component support", () => {
|
||||
expect(getChannelMessageAdapter("plain-chat")).toEqual({
|
||||
supportsComponentsV2: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an adapter with a cross-context component builder", () => {
|
||||
const adapter = getChannelMessageAdapter("rich-chat");
|
||||
|
||||
expect(adapter.supportsComponentsV2).toBe(true);
|
||||
expect(adapter.buildCrossContextComponents).toBeTypeOf("function");
|
||||
|
||||
const components = adapter.buildCrossContextComponents?.({
|
||||
originLabel: "Forum",
|
||||
message: "Hello from chat",
|
||||
cfg: {} as never,
|
||||
accountId: "primary",
|
||||
});
|
||||
const container = components?.[0] as TestRichUiContainer | undefined;
|
||||
|
||||
expect(components).toHaveLength(1);
|
||||
expect(container).toBeInstanceOf(TestRichUiContainer);
|
||||
expect(container?.components).toEqual([
|
||||
expect.any(TestTextDisplay),
|
||||
expect.any(TestSeparator),
|
||||
expect.any(TestTextDisplay),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
message: "Hello from chat",
|
||||
originLabel: "Forum",
|
||||
accountId: "primary",
|
||||
expectedComponents: [
|
||||
expect.any(TestTextDisplay),
|
||||
expect.any(TestSeparator),
|
||||
expect.any(TestTextDisplay),
|
||||
],
|
||||
},
|
||||
{
|
||||
message: " ",
|
||||
originLabel: "Pager",
|
||||
expectedComponents: [expect.any(TestTextDisplay)],
|
||||
},
|
||||
])(
|
||||
"builds cross-context components for %j",
|
||||
({ message, originLabel, accountId, expectedComponents }) => {
|
||||
const adapter = getChannelMessageAdapter("rich-chat");
|
||||
const components = adapter.buildCrossContextComponents?.({
|
||||
originLabel,
|
||||
message,
|
||||
cfg: {} as never,
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
const container = components?.[0] as TestRichUiContainer | undefined;
|
||||
|
||||
expect(components).toHaveLength(1);
|
||||
expect(container?.components).toEqual(expectedComponents);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -10,6 +10,8 @@ import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load
|
||||
import type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
ChannelOutboundPayloadContext,
|
||||
ChannelOutboundTargetRef,
|
||||
} from "../../channels/plugins/types.adapters.js";
|
||||
import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -21,7 +23,12 @@ import {
|
||||
toPluginMessageContext,
|
||||
toPluginMessageSentEvent,
|
||||
} from "../../hooks/message-hook-mappers.js";
|
||||
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import {
|
||||
hasReplyPayloadContent,
|
||||
normalizeMessagePresentation,
|
||||
renderMessagePresentationFallbackText,
|
||||
type ReplyPayloadDeliveryPin,
|
||||
} from "../../interactive/payload.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { OutboundMediaAccess } from "../../media/load-options.js";
|
||||
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
|
||||
@@ -76,6 +83,13 @@ type ChannelHandler = {
|
||||
supportsMedia: boolean;
|
||||
sanitizeText?: (payload: ReplyPayload) => string;
|
||||
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
renderPresentation?: (payload: ReplyPayload) => Promise<ReplyPayload | null>;
|
||||
pinDeliveredMessage?: (params: {
|
||||
target: ChannelOutboundTargetRef;
|
||||
messageId: string;
|
||||
pin: ReplyPayloadDeliveryPin;
|
||||
}) => Promise<void>;
|
||||
buildTargetRef: (overrides?: { threadId?: string | number | null }) => ChannelOutboundTargetRef;
|
||||
shouldSkipPlainTextSanitization?: (payload: ReplyPayload) => boolean;
|
||||
resolveEffectiveTextChunkLimit?: (fallbackLimit?: number) => number | undefined;
|
||||
sendPayload?: (
|
||||
@@ -178,6 +192,14 @@ function createPluginHandler(
|
||||
threadId: overrides?.threadId ?? baseCtx.threadId,
|
||||
audioAsVoice: overrides?.audioAsVoice,
|
||||
});
|
||||
const buildTargetRef = (overrides?: {
|
||||
threadId?: string | number | null;
|
||||
}): ChannelOutboundTargetRef => ({
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
accountId: params.accountId ?? undefined,
|
||||
threadId: overrides?.threadId ?? baseCtx.threadId,
|
||||
});
|
||||
return {
|
||||
chunker,
|
||||
chunkerMode,
|
||||
@@ -189,6 +211,34 @@ function createPluginHandler(
|
||||
normalizePayload: outbound.normalizePayload
|
||||
? (payload) => outbound.normalizePayload!({ payload })
|
||||
: undefined,
|
||||
renderPresentation: outbound.renderPresentation
|
||||
? async (payload) => {
|
||||
const presentation = normalizeMessagePresentation(payload.presentation);
|
||||
if (!presentation) {
|
||||
return payload;
|
||||
}
|
||||
const ctx: ChannelOutboundPayloadContext = {
|
||||
...resolveCtx({
|
||||
replyToId: payload.replyToId ?? baseCtx.replyToId,
|
||||
threadId: baseCtx.threadId,
|
||||
audioAsVoice: payload.audioAsVoice,
|
||||
}),
|
||||
text: payload.text ?? "",
|
||||
mediaUrl: payload.mediaUrl,
|
||||
payload,
|
||||
};
|
||||
return await outbound.renderPresentation!({ payload, presentation, ctx });
|
||||
}
|
||||
: undefined,
|
||||
pinDeliveredMessage: outbound.pinDeliveredMessage
|
||||
? async ({ target, messageId, pin }) =>
|
||||
outbound.pinDeliveredMessage!({
|
||||
cfg: params.cfg,
|
||||
target,
|
||||
messageId,
|
||||
pin,
|
||||
})
|
||||
: undefined,
|
||||
shouldSkipPlainTextSanitization: outbound.shouldSkipPlainTextSanitization
|
||||
? (payload) => outbound.shouldSkipPlainTextSanitization!({ payload })
|
||||
: undefined,
|
||||
@@ -229,6 +279,7 @@ function createPluginHandler(
|
||||
...resolveCtx(overrides),
|
||||
text,
|
||||
}),
|
||||
buildTargetRef,
|
||||
sendMedia: async (caption, mediaUrl, overrides) => {
|
||||
if (sendMedia) {
|
||||
return sendMedia({
|
||||
@@ -355,6 +406,99 @@ function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
||||
return summarizeOutboundPayloadForTransport(payload);
|
||||
}
|
||||
|
||||
function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined {
|
||||
const pin = payload.delivery?.pin;
|
||||
if (pin === true) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (!pin || typeof pin !== "object" || Array.isArray(pin)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!pin.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: ReplyPayloadDeliveryPin = { enabled: true };
|
||||
if (pin.notify === true) {
|
||||
normalized.notify = true;
|
||||
}
|
||||
if (pin.required === true) {
|
||||
normalized.required = true;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function maybePinDeliveredMessage(params: {
|
||||
handler: ChannelHandler;
|
||||
payload: ReplyPayload;
|
||||
target: ChannelOutboundTargetRef;
|
||||
messageId?: string;
|
||||
}): Promise<void> {
|
||||
const pin = normalizeDeliveryPin(params.payload);
|
||||
if (!pin) {
|
||||
return;
|
||||
}
|
||||
if (!params.messageId) {
|
||||
if (pin.required) {
|
||||
throw new Error("Delivery pin requested, but no delivered message id was returned.");
|
||||
}
|
||||
log.warn("Delivery pin requested, but no delivered message id was returned.", {
|
||||
channel: params.target.channel,
|
||||
to: params.target.to,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!params.handler.pinDeliveredMessage) {
|
||||
if (pin.required) {
|
||||
throw new Error(`Delivery pin is not supported by channel: ${params.target.channel}`);
|
||||
}
|
||||
log.warn("Delivery pin requested, but channel does not support pinning delivered messages.", {
|
||||
channel: params.target.channel,
|
||||
to: params.target.to,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.handler.pinDeliveredMessage({
|
||||
target: params.target,
|
||||
messageId: params.messageId,
|
||||
pin,
|
||||
});
|
||||
} catch (err) {
|
||||
if (pin.required) {
|
||||
throw err;
|
||||
}
|
||||
log.warn("Delivery pin requested, but channel failed to pin delivered message.", {
|
||||
channel: params.target.channel,
|
||||
to: params.target.to,
|
||||
messageId: params.messageId,
|
||||
error: formatErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPresentationForDelivery(
|
||||
handler: ChannelHandler,
|
||||
payload: ReplyPayload,
|
||||
): Promise<ReplyPayload> {
|
||||
const presentation = normalizeMessagePresentation(payload.presentation);
|
||||
if (!presentation) {
|
||||
return payload;
|
||||
}
|
||||
const rendered = handler.renderPresentation ? await handler.renderPresentation(payload) : null;
|
||||
if (rendered) {
|
||||
const { presentation: _presentation, ...withoutPresentation } = rendered;
|
||||
return withoutPresentation;
|
||||
}
|
||||
const { presentation: _presentation, ...withoutPresentation } = payload;
|
||||
return {
|
||||
...withoutPresentation,
|
||||
text: renderMessagePresentationFallbackText({
|
||||
text: payload.text,
|
||||
presentation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageSentEmitter(params: {
|
||||
hookRunner: ReturnType<typeof getGlobalHookRunner>;
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
@@ -687,8 +831,8 @@ async function deliverOutboundPayloadsCore(
|
||||
if (hookResult.cancelled) {
|
||||
continue;
|
||||
}
|
||||
const effectivePayload = hookResult.payload;
|
||||
payloadSummary = hookResult.payloadSummary;
|
||||
const effectivePayload = await renderPresentationForDelivery(handler, hookResult.payload);
|
||||
payloadSummary = buildPayloadSummary(effectivePayload);
|
||||
|
||||
params.onPayload?.(payloadSummary);
|
||||
const sendOverrides = {
|
||||
@@ -697,15 +841,23 @@ async function deliverOutboundPayloadsCore(
|
||||
audioAsVoice: effectivePayload.audioAsVoice === true ? true : undefined,
|
||||
forceDocument: params.forceDocument,
|
||||
};
|
||||
const deliveryTarget = handler.buildTargetRef({ threadId: sendOverrides.threadId });
|
||||
if (
|
||||
handler.sendPayload &&
|
||||
hasReplyPayloadContent({
|
||||
presentation: effectivePayload.presentation,
|
||||
interactive: effectivePayload.interactive,
|
||||
channelData: effectivePayload.channelData,
|
||||
})
|
||||
) {
|
||||
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);
|
||||
results.push(delivery);
|
||||
await maybePinDeliveredMessage({
|
||||
handler,
|
||||
payload: effectivePayload,
|
||||
target: deliveryTarget,
|
||||
messageId: delivery.messageId,
|
||||
});
|
||||
emitMessageSent({
|
||||
success: true,
|
||||
content: payloadSummary.text,
|
||||
@@ -720,7 +872,15 @@ async function deliverOutboundPayloadsCore(
|
||||
} else {
|
||||
await sendTextChunks(payloadSummary.text, sendOverrides);
|
||||
}
|
||||
const deliveredResults = results.slice(beforeCount);
|
||||
const messageId = results.at(-1)?.messageId;
|
||||
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
|
||||
await maybePinDeliveredMessage({
|
||||
handler,
|
||||
payload: effectivePayload,
|
||||
target: deliveryTarget,
|
||||
messageId: pinMessageId,
|
||||
});
|
||||
emitMessageSent({
|
||||
success: results.length > beforeCount,
|
||||
content: payloadSummary.text,
|
||||
@@ -746,7 +906,15 @@ async function deliverOutboundPayloadsCore(
|
||||
}
|
||||
const beforeCount = results.length;
|
||||
await sendTextChunks(fallbackText, sendOverrides);
|
||||
const deliveredResults = results.slice(beforeCount);
|
||||
const messageId = results.at(-1)?.messageId;
|
||||
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
|
||||
await maybePinDeliveredMessage({
|
||||
handler,
|
||||
payload: effectivePayload,
|
||||
target: deliveryTarget,
|
||||
messageId: pinMessageId,
|
||||
});
|
||||
emitMessageSent({
|
||||
success: results.length > beforeCount,
|
||||
content: payloadSummary.text,
|
||||
@@ -755,6 +923,7 @@ async function deliverOutboundPayloadsCore(
|
||||
continue;
|
||||
}
|
||||
|
||||
let firstMessageId: string | undefined;
|
||||
let lastMessageId: string | undefined;
|
||||
await sendMediaWithLeadingCaption({
|
||||
mediaUrls: payloadSummary.mediaUrls,
|
||||
@@ -768,14 +937,22 @@ async function deliverOutboundPayloadsCore(
|
||||
sendOverrides,
|
||||
);
|
||||
results.push(delivery);
|
||||
firstMessageId ??= delivery.messageId;
|
||||
lastMessageId = delivery.messageId;
|
||||
return;
|
||||
}
|
||||
const delivery = await handler.sendMedia(caption ?? "", mediaUrl, sendOverrides);
|
||||
results.push(delivery);
|
||||
firstMessageId ??= delivery.messageId;
|
||||
lastMessageId = delivery.messageId;
|
||||
},
|
||||
});
|
||||
await maybePinDeliveredMessage({
|
||||
handler,
|
||||
payload: effectivePayload,
|
||||
target: deliveryTarget,
|
||||
messageId: firstMessageId,
|
||||
});
|
||||
emitMessageSent({
|
||||
success: true,
|
||||
content: payloadSummary.text,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -399,54 +399,20 @@ export async function hydrateAttachmentParamsForAction(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function parseButtonsParam(params: Record<string, unknown>): void {
|
||||
const raw = params.buttons;
|
||||
export function parseJsonMessageParam(params: Record<string, unknown>, key: string): void {
|
||||
const raw = params[key];
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
delete params.buttons;
|
||||
delete params[key];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
params.buttons = JSON.parse(trimmed) as unknown;
|
||||
params[key] = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error("--buttons must be valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCardParam(params: Record<string, unknown>): void {
|
||||
const raw = params.card;
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
delete params.card;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
params.card = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error("--card must be valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseComponentsParam(params: Record<string, unknown>): void {
|
||||
const raw = params.components;
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
delete params.components;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
params.components = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error("--components must be valid JSON");
|
||||
throw new Error(`--${key} must be valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -743,11 +743,11 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("card-only send behavior", () => {
|
||||
describe("presentation-only send behavior", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
ok: true,
|
||||
card: params.card ?? null,
|
||||
presentation: params.presentation ?? null,
|
||||
message: params.message ?? null,
|
||||
}),
|
||||
);
|
||||
@@ -764,7 +764,7 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: createAlwaysConfiguredPluginConfig(),
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
describeMessageTool: () => ({ actions: ["send"], capabilities: ["presentation"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction,
|
||||
},
|
||||
@@ -788,7 +788,7 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("allows card-only sends without text or media", async () => {
|
||||
it("allows presentation-only sends without text or media", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
cardchat: {
|
||||
@@ -797,10 +797,8 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const card = {
|
||||
type: "AdaptiveCard",
|
||||
version: "1.4",
|
||||
body: [{ type: "TextBlock", text: "Card-only payload" }],
|
||||
const presentation = {
|
||||
blocks: [{ type: "text", text: "Presentation-only payload" }],
|
||||
};
|
||||
|
||||
const result = await runMessageAction({
|
||||
@@ -809,7 +807,7 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
params: {
|
||||
channel: "cardchat",
|
||||
target: "channel:test-card",
|
||||
card,
|
||||
presentation,
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
@@ -819,7 +817,7 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
expect(handleAction).toHaveBeenCalled();
|
||||
expect(result.payload).toMatchObject({
|
||||
ok: true,
|
||||
card,
|
||||
presentation,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -994,11 +992,11 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("components parsing", () => {
|
||||
describe("presentation parsing", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
ok: true,
|
||||
components: params.components ?? null,
|
||||
presentation: params.presentation ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1014,7 +1012,7 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: createAlwaysConfiguredPluginConfig({}),
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
describeMessageTool: () => ({ actions: ["send"], capabilities: ["presentation"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction,
|
||||
},
|
||||
@@ -1038,10 +1036,9 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parses components JSON strings before plugin dispatch", async () => {
|
||||
const components = {
|
||||
text: "hello",
|
||||
buttons: [{ label: "A", customId: "a" }],
|
||||
it("parses presentation JSON strings before plugin dispatch", async () => {
|
||||
const presentation = {
|
||||
blocks: [{ type: "buttons", buttons: [{ label: "A", value: "a" }] }],
|
||||
};
|
||||
const result = await runMessageAction({
|
||||
cfg: {} as OpenClawConfig,
|
||||
@@ -1050,17 +1047,17 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
channel: "componentchat",
|
||||
target: "channel:123",
|
||||
message: "hi",
|
||||
components: JSON.stringify(components),
|
||||
presentation: JSON.stringify(presentation),
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(handleAction).toHaveBeenCalled();
|
||||
expect(result.payload).toMatchObject({ ok: true, components });
|
||||
expect(result.payload).toMatchObject({ ok: true, presentation });
|
||||
});
|
||||
|
||||
it("throws on invalid components JSON strings", async () => {
|
||||
it("throws on invalid presentation JSON strings", async () => {
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg: {} as OpenClawConfig,
|
||||
@@ -1069,11 +1066,11 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
channel: "componentchat",
|
||||
target: "channel:123",
|
||||
message: "hi",
|
||||
components: "{not-json}",
|
||||
presentation: "{not-json}",
|
||||
},
|
||||
dryRun: false,
|
||||
}),
|
||||
).rejects.toThrow(/--components must be valid JSON/);
|
||||
).rejects.toThrow(/--presentation must be valid JSON/);
|
||||
|
||||
expect(handleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
ChannelThreadingToolContext,
|
||||
} from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { hasInteractiveReplyBlocks, hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import {
|
||||
hasInteractiveReplyBlocks,
|
||||
hasMessagePresentationBlocks,
|
||||
hasReplyPayloadContent,
|
||||
normalizeMessagePresentation,
|
||||
} from "../../interactive/payload.js";
|
||||
import type { OutboundMediaAccess } from "../../media/load-options.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
|
||||
@@ -46,10 +51,8 @@ import {
|
||||
hydrateAttachmentParamsForAction,
|
||||
normalizeSandboxMediaList,
|
||||
normalizeSandboxMediaParams,
|
||||
parseButtonsParam,
|
||||
parseCardParam,
|
||||
parseComponentsParam,
|
||||
parseInteractiveParam,
|
||||
parseJsonMessageParam,
|
||||
readBooleanParam,
|
||||
resolveAttachmentMediaPolicy,
|
||||
resolveExtraActionMediaSourceParamKeys,
|
||||
@@ -209,21 +212,27 @@ function applyCrossContextMessageDecoration({
|
||||
params,
|
||||
message,
|
||||
decoration,
|
||||
preferComponents,
|
||||
preferPresentation,
|
||||
}: {
|
||||
params: Record<string, unknown>;
|
||||
message: string;
|
||||
decoration: CrossContextDecoration;
|
||||
preferComponents: boolean;
|
||||
preferPresentation: boolean;
|
||||
}): string {
|
||||
const applied = applyCrossContextDecoration({
|
||||
message,
|
||||
decoration,
|
||||
preferComponents,
|
||||
preferPresentation,
|
||||
});
|
||||
params.message = applied.message;
|
||||
if (applied.componentsBuilder) {
|
||||
params.components = applied.componentsBuilder;
|
||||
if (applied.presentation) {
|
||||
const existing = normalizeMessagePresentation(params.presentation);
|
||||
params.presentation = existing
|
||||
? {
|
||||
...existing,
|
||||
blocks: [...applied.presentation.blocks, ...existing.blocks],
|
||||
}
|
||||
: applied.presentation;
|
||||
}
|
||||
return applied.message;
|
||||
}
|
||||
@@ -237,7 +246,7 @@ async function maybeApplyCrossContextMarker(params: {
|
||||
accountId?: string | null;
|
||||
args: Record<string, unknown>;
|
||||
message: string;
|
||||
preferComponents: boolean;
|
||||
preferPresentation: boolean;
|
||||
}): Promise<string> {
|
||||
if (!shouldApplyCrossContextMarker(params.action) || !params.toolContext) {
|
||||
return params.message;
|
||||
@@ -256,7 +265,7 @@ async function maybeApplyCrossContextMarker(params: {
|
||||
params: params.args,
|
||||
message: params.message,
|
||||
decoration,
|
||||
preferComponents: params.preferComponents,
|
||||
preferPresentation: params.preferPresentation,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -465,6 +474,9 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
throwIfAborted(abortSignal);
|
||||
const action: ChannelMessageActionName = "send";
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
if (params.pin === true && params.delivery == null) {
|
||||
params.delivery = { pin: { enabled: true } };
|
||||
}
|
||||
// Support media, path, and filePath parameters for attachments
|
||||
const mediaHint =
|
||||
readStringParam(params, "media", { trim: false }) ??
|
||||
@@ -472,18 +484,12 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
readStringParam(params, "path", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false }) ??
|
||||
readStringParam(params, "fileUrl", { trim: false });
|
||||
const hasButtons = Array.isArray(params.buttons) && params.buttons.length > 0;
|
||||
const hasCard = params.card != null && typeof params.card === "object";
|
||||
const hasComponents = params.components != null && typeof params.components === "object";
|
||||
const hasPresentation = hasMessagePresentationBlocks(params.presentation);
|
||||
const hasInteractive = hasInteractiveReplyBlocks(params.interactive);
|
||||
const hasBlocks =
|
||||
(Array.isArray(params.blocks) && params.blocks.length > 0) ||
|
||||
(typeof params.blocks === "string" && params.blocks.trim().length > 0);
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
||||
let message =
|
||||
readStringParam(params, "message", {
|
||||
required:
|
||||
!mediaHint && !hasButtons && !hasCard && !hasComponents && !hasInteractive && !hasBlocks,
|
||||
required: !mediaHint && !hasPresentation && !hasInteractive,
|
||||
allowEmpty: true,
|
||||
}) ?? "";
|
||||
if (message.includes("\\n")) {
|
||||
@@ -539,22 +545,18 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
accountId,
|
||||
args: params,
|
||||
message,
|
||||
preferComponents: true,
|
||||
preferPresentation: true,
|
||||
});
|
||||
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
if (
|
||||
!hasReplyPayloadContent(
|
||||
{
|
||||
text: message,
|
||||
mediaUrl,
|
||||
mediaUrls: mergedMediaUrls,
|
||||
interactive: params.interactive,
|
||||
},
|
||||
{
|
||||
extraContent: hasButtons || hasCard || hasComponents || hasBlocks,
|
||||
},
|
||||
)
|
||||
!hasReplyPayloadContent({
|
||||
text: message,
|
||||
mediaUrl,
|
||||
mediaUrls: mergedMediaUrls,
|
||||
presentation: params.presentation,
|
||||
interactive: params.interactive,
|
||||
})
|
||||
) {
|
||||
throw new Error("send requires text or media");
|
||||
}
|
||||
@@ -665,7 +667,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
accountId,
|
||||
args: params,
|
||||
message: base,
|
||||
preferComponents: false,
|
||||
preferPresentation: false,
|
||||
});
|
||||
|
||||
const poll = await executePollAction({
|
||||
@@ -825,9 +827,8 @@ export async function runMessageAction(
|
||||
(input.sessionKey
|
||||
? resolveSessionAgentId({ sessionKey: input.sessionKey, config: cfg })
|
||||
: undefined);
|
||||
parseButtonsParam(params);
|
||||
parseCardParam(params);
|
||||
parseComponentsParam(params);
|
||||
parseJsonMessageParam(params, "presentation");
|
||||
parseJsonMessageParam(params, "delivery");
|
||||
parseInteractiveParam(params);
|
||||
|
||||
const action = input.action;
|
||||
|
||||
@@ -8,41 +8,29 @@ let buildCrossContextDecoration: typeof import("./outbound-policy.js").buildCros
|
||||
let enforceCrossContextPolicy: typeof import("./outbound-policy.js").enforceCrossContextPolicy;
|
||||
let shouldApplyCrossContextMarker: typeof import("./outbound-policy.js").shouldApplyCrossContextMarker;
|
||||
|
||||
class TestTextDisplay {
|
||||
constructor(readonly content: string) {}
|
||||
}
|
||||
|
||||
class TestSeparator {
|
||||
constructor(readonly options: { divider: boolean; spacing: string }) {}
|
||||
}
|
||||
|
||||
class TestRichUiContainer {
|
||||
constructor(readonly components: Array<TestTextDisplay | TestSeparator>) {}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getChannelMessageAdapter: vi.fn((channel: string) =>
|
||||
getChannelPlugin: vi.fn((channel: string) =>
|
||||
channel === "richchat"
|
||||
? {
|
||||
supportsComponentsV2: true,
|
||||
buildCrossContextComponents: ({
|
||||
originLabel,
|
||||
message,
|
||||
}: {
|
||||
originLabel: string;
|
||||
message: string;
|
||||
}) => {
|
||||
const trimmed = message.trim();
|
||||
const components: Array<TestTextDisplay | TestSeparator> = [];
|
||||
if (trimmed) {
|
||||
components.push(new TestTextDisplay(message));
|
||||
components.push(new TestSeparator({ divider: true, spacing: "small" }));
|
||||
}
|
||||
components.push(new TestTextDisplay(`*From ${originLabel}*`));
|
||||
return [new TestRichUiContainer(components)];
|
||||
messaging: {
|
||||
buildCrossContextPresentation: ({
|
||||
originLabel,
|
||||
message,
|
||||
}: {
|
||||
originLabel: string;
|
||||
message: string;
|
||||
}) => {
|
||||
const trimmed = message.trim();
|
||||
return {
|
||||
blocks: [
|
||||
...(trimmed ? [{ type: "text" as const, text: message }] : []),
|
||||
{ type: "context" as const, text: `From ${originLabel}` },
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
: { supportsComponentsV2: false },
|
||||
: undefined,
|
||||
),
|
||||
normalizeTargetForProvider: vi.fn((channel: string, raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
@@ -62,8 +50,8 @@ const mocks = vi.hoisted(() => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./channel-adapters.js", () => ({
|
||||
getChannelMessageAdapter: mocks.getChannelMessageAdapter,
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: mocks.getChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("./target-normalization.js", () => ({
|
||||
@@ -187,7 +175,7 @@ describe("outbound policy helpers", () => {
|
||||
expectCrossContextPolicyResult(params);
|
||||
});
|
||||
|
||||
it("uses components when available and preferred", async () => {
|
||||
it("uses presentation when available and preferred", async () => {
|
||||
const decoration = await buildCrossContextDecoration({
|
||||
cfg: richChatConfig,
|
||||
channel: "richchat",
|
||||
@@ -199,12 +187,11 @@ describe("outbound policy helpers", () => {
|
||||
const applied = applyCrossContextDecoration({
|
||||
message: "hello",
|
||||
decoration: decoration!,
|
||||
preferComponents: true,
|
||||
preferPresentation: true,
|
||||
});
|
||||
|
||||
expect(applied.usedComponents).toBe(true);
|
||||
expect(applied.componentsBuilder).toBeDefined();
|
||||
expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0);
|
||||
expect(applied.usedPresentation).toBe(true);
|
||||
expect(applied.presentation?.blocks.length).toBeGreaterThan(0);
|
||||
expect(applied.message).toBe("hello");
|
||||
});
|
||||
|
||||
@@ -225,11 +212,11 @@ describe("outbound policy helpers", () => {
|
||||
const applied = applyCrossContextDecoration({
|
||||
message: "hello",
|
||||
decoration: { prefix: "[from ops] ", suffix: " [cc]" },
|
||||
preferComponents: true,
|
||||
preferPresentation: true,
|
||||
});
|
||||
expect(applied).toEqual({
|
||||
message: "[from ops] hello [cc]",
|
||||
usedComponents: false,
|
||||
usedPresentation: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelMessageActionName,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
getChannelMessageAdapter,
|
||||
type CrossContextComponentsBuilder,
|
||||
} from "./channel-adapters.js";
|
||||
import type { MessagePresentation } from "../../interactive/payload.js";
|
||||
import { normalizeTargetForProvider } from "./target-normalization.js";
|
||||
import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
|
||||
|
||||
export type CrossContextPresentationBuilder = (message: string) => MessagePresentation;
|
||||
|
||||
export type CrossContextDecoration = {
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
componentsBuilder?: CrossContextComponentsBuilder;
|
||||
presentationBuilder?: CrossContextPresentationBuilder;
|
||||
};
|
||||
|
||||
const CONTEXT_GUARDED_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
@@ -181,20 +181,19 @@ export async function buildCrossContextDecoration(params: {
|
||||
const prefix = prefixTemplate.replaceAll("{channel}", originLabel);
|
||||
const suffix = suffixTemplate.replaceAll("{channel}", originLabel);
|
||||
|
||||
const adapter = getChannelMessageAdapter(params.channel);
|
||||
const componentsBuilder = adapter.supportsComponentsV2
|
||||
? adapter.buildCrossContextComponents
|
||||
? (message: string) =>
|
||||
adapter.buildCrossContextComponents!({
|
||||
originLabel,
|
||||
message,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? undefined,
|
||||
})
|
||||
: undefined
|
||||
const buildPresentation = getChannelPlugin(params.channel)?.messaging
|
||||
?.buildCrossContextPresentation;
|
||||
const presentationBuilder = buildPresentation
|
||||
? (message: string) =>
|
||||
buildPresentation({
|
||||
originLabel,
|
||||
message,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? undefined,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return { prefix, suffix, componentsBuilder };
|
||||
return { prefix, suffix, presentationBuilder };
|
||||
}
|
||||
|
||||
export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean {
|
||||
@@ -204,20 +203,20 @@ export function shouldApplyCrossContextMarker(action: ChannelMessageActionName):
|
||||
export function applyCrossContextDecoration(params: {
|
||||
message: string;
|
||||
decoration: CrossContextDecoration;
|
||||
preferComponents: boolean;
|
||||
preferPresentation: boolean;
|
||||
}): {
|
||||
message: string;
|
||||
componentsBuilder?: CrossContextComponentsBuilder;
|
||||
usedComponents: boolean;
|
||||
presentation?: MessagePresentation;
|
||||
usedPresentation: boolean;
|
||||
} {
|
||||
const useComponents = params.preferComponents && params.decoration.componentsBuilder;
|
||||
if (useComponents) {
|
||||
const usePresentation = params.preferPresentation && params.decoration.presentationBuilder;
|
||||
if (usePresentation) {
|
||||
return {
|
||||
message: params.message,
|
||||
componentsBuilder: params.decoration.componentsBuilder,
|
||||
usedComponents: true,
|
||||
presentation: params.decoration.presentationBuilder?.(params.message),
|
||||
usedPresentation: true,
|
||||
};
|
||||
}
|
||||
const message = `${params.decoration.prefix}${params.message}${params.decoration.suffix}`;
|
||||
return { message, usedComponents: false };
|
||||
return { message, usedPresentation: false };
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import { resolveSilentReplySettings } from "../../config/silent-reply.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
hasInteractiveReplyBlocks,
|
||||
hasMessagePresentationBlocks,
|
||||
hasReplyChannelData,
|
||||
hasReplyPayloadContent,
|
||||
type InteractiveReply,
|
||||
type MessagePresentation,
|
||||
type ReplyPayloadDelivery,
|
||||
} from "../../interactive/payload.js";
|
||||
import {
|
||||
resolveSilentReplyRewriteText,
|
||||
@@ -23,6 +26,8 @@ export type NormalizedOutboundPayload = {
|
||||
text: string;
|
||||
mediaUrls: string[];
|
||||
audioAsVoice?: boolean;
|
||||
presentation?: MessagePresentation;
|
||||
delivery?: ReplyPayloadDelivery;
|
||||
interactive?: InteractiveReply;
|
||||
channelData?: Record<string, unknown>;
|
||||
};
|
||||
@@ -32,6 +37,8 @@ export type OutboundPayloadJson = {
|
||||
mediaUrl: string | null;
|
||||
mediaUrls?: string[];
|
||||
audioAsVoice?: boolean;
|
||||
presentation?: MessagePresentation;
|
||||
delivery?: ReplyPayloadDelivery;
|
||||
interactive?: InteractiveReply;
|
||||
channelData?: Record<string, unknown>;
|
||||
};
|
||||
@@ -39,6 +46,7 @@ export type OutboundPayloadJson = {
|
||||
export type OutboundPayloadPlan = {
|
||||
payload: ReplyPayload;
|
||||
parts: ReturnType<typeof resolveSendableOutboundReplyParts>;
|
||||
hasPresentation: boolean;
|
||||
hasInteractive: boolean;
|
||||
hasChannelData: boolean;
|
||||
};
|
||||
@@ -104,6 +112,7 @@ function mergeMediaUrls(...lists: Array<ReadonlyArray<string | undefined> | unde
|
||||
|
||||
type PreparedOutboundPayloadPlanEntry = {
|
||||
payload: ReplyPayload;
|
||||
hasPresentation: boolean;
|
||||
hasInteractive: boolean;
|
||||
hasChannelData: boolean;
|
||||
isSilent: boolean;
|
||||
@@ -149,6 +158,7 @@ function createOutboundPayloadPlanEntry(
|
||||
const hasChannelData = hasReplyChannelData(normalizedPayload.channelData);
|
||||
return {
|
||||
payload: normalizedPayload,
|
||||
hasPresentation: hasMessagePresentationBlocks(normalizedPayload.presentation),
|
||||
hasInteractive: hasInteractiveReplyBlocks(normalizedPayload.interactive),
|
||||
hasChannelData,
|
||||
isSilent,
|
||||
@@ -192,6 +202,7 @@ export function createOutboundPayloadPlan(
|
||||
plan.push({
|
||||
payload: entry.payload,
|
||||
parts: resolveSendableOutboundReplyParts(entry.payload),
|
||||
hasPresentation: entry.hasPresentation,
|
||||
hasInteractive: entry.hasInteractive,
|
||||
hasChannelData: entry.hasChannelData,
|
||||
});
|
||||
@@ -211,6 +222,7 @@ export function createOutboundPayloadPlan(
|
||||
plan.push({
|
||||
payload: visibleSilentPayload,
|
||||
parts: resolveSendableOutboundReplyParts(visibleSilentPayload),
|
||||
hasPresentation: entry.hasPresentation,
|
||||
hasInteractive: entry.hasInteractive,
|
||||
hasChannelData: entry.hasChannelData,
|
||||
});
|
||||
@@ -228,6 +240,7 @@ export function createOutboundPayloadPlan(
|
||||
plan.push({
|
||||
payload: rewrittenPayload,
|
||||
parts: resolveSendableOutboundReplyParts(rewrittenPayload),
|
||||
hasPresentation: entry.hasPresentation,
|
||||
hasInteractive: entry.hasInteractive,
|
||||
hasChannelData: entry.hasChannelData,
|
||||
});
|
||||
@@ -260,6 +273,8 @@ export function projectOutboundPayloadPlanForOutbound(
|
||||
text,
|
||||
mediaUrls: entry.parts.mediaUrls,
|
||||
audioAsVoice: payload.audioAsVoice === true ? true : undefined,
|
||||
...(entry.hasPresentation ? { presentation: payload.presentation } : {}),
|
||||
...(payload.delivery ? { delivery: payload.delivery } : {}),
|
||||
...(entry.hasInteractive ? { interactive: payload.interactive } : {}),
|
||||
...(entry.hasChannelData ? { channelData: payload.channelData } : {}),
|
||||
});
|
||||
@@ -278,6 +293,8 @@ export function projectOutboundPayloadPlanForJson(
|
||||
mediaUrl: payload.mediaUrl ?? null,
|
||||
mediaUrls: entry.parts.mediaUrls.length ? entry.parts.mediaUrls : undefined,
|
||||
audioAsVoice: payload.audioAsVoice === true ? true : undefined,
|
||||
presentation: payload.presentation,
|
||||
delivery: payload.delivery,
|
||||
interactive: payload.interactive,
|
||||
channelData: payload.channelData,
|
||||
});
|
||||
@@ -305,6 +322,8 @@ export function summarizeOutboundPayloadForTransport(
|
||||
text: parts.text,
|
||||
mediaUrls: parts.mediaUrls,
|
||||
audioAsVoice: payload.audioAsVoice === true ? true : undefined,
|
||||
presentation: payload.presentation,
|
||||
delivery: payload.delivery,
|
||||
interactive: payload.interactive,
|
||||
channelData: payload.channelData,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@ export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "dang
|
||||
|
||||
export type InteractiveReplyButton = {
|
||||
label: string;
|
||||
value: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
style?: InteractiveButtonStyle;
|
||||
};
|
||||
|
||||
@@ -41,6 +42,70 @@ export type InteractiveReply = {
|
||||
blocks: InteractiveReplyBlock[];
|
||||
};
|
||||
|
||||
export type MessagePresentationTone = "info" | "success" | "warning" | "danger" | "neutral";
|
||||
|
||||
export type MessagePresentationButtonStyle = InteractiveButtonStyle;
|
||||
|
||||
export type MessagePresentationButton = {
|
||||
label: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
style?: MessagePresentationButtonStyle;
|
||||
};
|
||||
|
||||
export type MessagePresentationOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type MessagePresentationTextBlock = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type MessagePresentationContextBlock = {
|
||||
type: "context";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type MessagePresentationDividerBlock = {
|
||||
type: "divider";
|
||||
};
|
||||
|
||||
export type MessagePresentationButtonsBlock = {
|
||||
type: "buttons";
|
||||
buttons: MessagePresentationButton[];
|
||||
};
|
||||
|
||||
export type MessagePresentationSelectBlock = {
|
||||
type: "select";
|
||||
placeholder?: string;
|
||||
options: MessagePresentationOption[];
|
||||
};
|
||||
|
||||
export type MessagePresentationBlock =
|
||||
| MessagePresentationTextBlock
|
||||
| MessagePresentationContextBlock
|
||||
| MessagePresentationDividerBlock
|
||||
| MessagePresentationButtonsBlock
|
||||
| MessagePresentationSelectBlock;
|
||||
|
||||
export type MessagePresentation = {
|
||||
title?: string;
|
||||
tone?: MessagePresentationTone;
|
||||
blocks: MessagePresentationBlock[];
|
||||
};
|
||||
|
||||
export type ReplyPayloadDeliveryPin = {
|
||||
enabled: boolean;
|
||||
notify?: boolean;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type ReplyPayloadDelivery = {
|
||||
pin?: boolean | ReplyPayloadDeliveryPin;
|
||||
};
|
||||
|
||||
function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined {
|
||||
const style = normalizeOptionalLowercaseString(value);
|
||||
return style === "primary" || style === "secondary" || style === "success" || style === "danger"
|
||||
@@ -48,6 +113,17 @@ function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefine
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizePresentationTone(value: unknown): MessagePresentationTone | undefined {
|
||||
const tone = normalizeOptionalLowercaseString(value);
|
||||
return tone === "info" ||
|
||||
tone === "success" ||
|
||||
tone === "warning" ||
|
||||
tone === "danger" ||
|
||||
tone === "neutral"
|
||||
? tone
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
@@ -58,12 +134,14 @@ function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | unde
|
||||
normalizeOptionalString(record.value) ??
|
||||
normalizeOptionalString(record.callbackData) ??
|
||||
normalizeOptionalString(record.callback_data);
|
||||
if (!label || !value) {
|
||||
const url = normalizeOptionalString(record.url);
|
||||
if (!label || (!value && !url)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
...(value ? { value } : {}),
|
||||
...(url ? { url } : {}),
|
||||
style: normalizeButtonStyle(record.style),
|
||||
};
|
||||
}
|
||||
@@ -129,10 +207,204 @@ export function normalizeInteractiveReply(raw: unknown): InteractiveReply | unde
|
||||
return blocks.length > 0 ? { blocks } : undefined;
|
||||
}
|
||||
|
||||
function normalizePresentationButton(raw: unknown): MessagePresentationButton | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const label = normalizeOptionalString(record.label) ?? normalizeOptionalString(record.text);
|
||||
const value =
|
||||
normalizeOptionalString(record.value) ??
|
||||
normalizeOptionalString(record.callbackData) ??
|
||||
normalizeOptionalString(record.callback_data);
|
||||
const url = normalizeOptionalString(record.url);
|
||||
if (!label || (!value && !url)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
...(value ? { value } : {}),
|
||||
...(url ? { url } : {}),
|
||||
style: normalizeButtonStyle(record.style),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePresentationOption(raw: unknown): MessagePresentationOption | undefined {
|
||||
const option = normalizeInteractiveOption(raw);
|
||||
return option ? { label: option.label, value: option.value } : undefined;
|
||||
}
|
||||
|
||||
function normalizePresentationBlock(raw: unknown): MessagePresentationBlock | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const type = normalizeOptionalLowercaseString(record.type);
|
||||
if (type === "text" || type === "context") {
|
||||
const text = normalizeOptionalString(record.text);
|
||||
return text ? { type, text } : undefined;
|
||||
}
|
||||
if (type === "divider") {
|
||||
return { type: "divider" };
|
||||
}
|
||||
if (type === "buttons") {
|
||||
const buttons = Array.isArray(record.buttons)
|
||||
? record.buttons
|
||||
.map((entry) => normalizePresentationButton(entry))
|
||||
.filter((entry): entry is MessagePresentationButton => Boolean(entry))
|
||||
: [];
|
||||
return buttons.length > 0 ? { type: "buttons", buttons } : undefined;
|
||||
}
|
||||
if (type === "select") {
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options
|
||||
.map((entry) => normalizePresentationOption(entry))
|
||||
.filter((entry): entry is MessagePresentationOption => Boolean(entry))
|
||||
: [];
|
||||
return options.length > 0
|
||||
? {
|
||||
type: "select",
|
||||
placeholder: normalizeOptionalString(record.placeholder),
|
||||
options,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeMessagePresentation(raw: unknown): MessagePresentation | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const blocks = Array.isArray(record.blocks)
|
||||
? record.blocks
|
||||
.map((entry) => normalizePresentationBlock(entry))
|
||||
.filter((entry): entry is MessagePresentationBlock => Boolean(entry))
|
||||
: [];
|
||||
const title = normalizeOptionalString(record.title);
|
||||
if (!title && blocks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
tone: normalizePresentationTone(record.tone),
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply {
|
||||
return Boolean(normalizeInteractiveReply(value));
|
||||
}
|
||||
|
||||
export function hasMessagePresentationBlocks(value: unknown): value is MessagePresentation {
|
||||
return Boolean(normalizeMessagePresentation(value));
|
||||
}
|
||||
|
||||
export function presentationToInteractiveReply(
|
||||
presentation: MessagePresentation,
|
||||
): InteractiveReply | undefined {
|
||||
const blocks: InteractiveReplyBlock[] = [];
|
||||
if (presentation.title) {
|
||||
blocks.push({ type: "text", text: presentation.title });
|
||||
}
|
||||
for (const block of presentation.blocks) {
|
||||
if (block.type === "text" || block.type === "context") {
|
||||
blocks.push({ type: "text", text: block.text });
|
||||
continue;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
const buttons = block.buttons
|
||||
.filter((button) => button.value || button.url)
|
||||
.map((button) => {
|
||||
const interactiveButton: InteractiveReplyButton = {
|
||||
label: button.label,
|
||||
style: button.style,
|
||||
};
|
||||
if (button.value) {
|
||||
interactiveButton.value = button.value;
|
||||
}
|
||||
if (button.url) {
|
||||
interactiveButton.url = button.url;
|
||||
}
|
||||
return interactiveButton;
|
||||
});
|
||||
if (buttons.length > 0) {
|
||||
blocks.push({ type: "buttons", buttons });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "select") {
|
||||
blocks.push({
|
||||
type: "select",
|
||||
placeholder: block.placeholder,
|
||||
options: block.options,
|
||||
});
|
||||
}
|
||||
}
|
||||
return blocks.length > 0 ? { blocks } : undefined;
|
||||
}
|
||||
|
||||
export function interactiveReplyToPresentation(
|
||||
interactive: InteractiveReply,
|
||||
): MessagePresentation | undefined {
|
||||
const blocks = interactive.blocks.map((block): MessagePresentationBlock => {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
return { type: "buttons", buttons: block.buttons };
|
||||
}
|
||||
return {
|
||||
type: "select",
|
||||
placeholder: block.placeholder,
|
||||
options: block.options,
|
||||
};
|
||||
});
|
||||
return blocks.length > 0 ? { blocks } : undefined;
|
||||
}
|
||||
|
||||
export function renderMessagePresentationFallbackText(params: {
|
||||
presentation?: MessagePresentation;
|
||||
text?: string | null;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
const text = normalizeOptionalString(params.text);
|
||||
if (text) {
|
||||
lines.push(text);
|
||||
}
|
||||
const presentation = params.presentation;
|
||||
if (!presentation) {
|
||||
return lines.join("\n\n");
|
||||
}
|
||||
if (presentation.title) {
|
||||
lines.push(presentation.title);
|
||||
}
|
||||
for (const block of presentation.blocks) {
|
||||
if (block.type === "text" || block.type === "context") {
|
||||
lines.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
const labels = block.buttons
|
||||
.map((button) => (button.url ? `${button.label}: ${button.url}` : button.label))
|
||||
.filter(Boolean);
|
||||
if (labels.length > 0) {
|
||||
lines.push(labels.map((label) => `- ${label}`).join("\n"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "select") {
|
||||
const labels = block.options.map((option) => option.label).filter(Boolean);
|
||||
if (labels.length > 0) {
|
||||
const heading = block.placeholder ? `${block.placeholder}:` : "Options:";
|
||||
lines.push(`${heading}\n${labels.map((label) => `- ${label}`).join("\n")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n\n");
|
||||
}
|
||||
|
||||
export function hasReplyChannelData(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0,
|
||||
@@ -144,6 +416,7 @@ export function hasReplyContent(params: {
|
||||
mediaUrl?: string | null;
|
||||
mediaUrls?: ReadonlyArray<string | null | undefined>;
|
||||
interactive?: unknown;
|
||||
presentation?: unknown;
|
||||
hasChannelData?: boolean;
|
||||
extraContent?: boolean;
|
||||
}): boolean {
|
||||
@@ -153,6 +426,7 @@ export function hasReplyContent(params: {
|
||||
text ||
|
||||
mediaUrl ||
|
||||
params.mediaUrls?.some((entry) => Boolean(normalizeOptionalString(entry))) ||
|
||||
hasMessagePresentationBlocks(params.presentation) ||
|
||||
hasInteractiveReplyBlocks(params.interactive) ||
|
||||
params.hasChannelData ||
|
||||
params.extraContent,
|
||||
@@ -165,6 +439,7 @@ export function hasReplyPayloadContent(
|
||||
mediaUrl?: string | null;
|
||||
mediaUrls?: ReadonlyArray<string | null | undefined>;
|
||||
interactive?: unknown;
|
||||
presentation?: unknown;
|
||||
channelData?: unknown;
|
||||
},
|
||||
options?: {
|
||||
@@ -178,6 +453,7 @@ export function hasReplyPayloadContent(
|
||||
mediaUrl: payload.mediaUrl,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
interactive: payload.interactive,
|
||||
presentation: payload.presentation,
|
||||
hasChannelData: options?.hasChannelData ?? hasReplyChannelData(payload.channelData),
|
||||
extraContent: options?.extraContent,
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ export function installChannelSurfaceContractSuite(params: {
|
||||
messaging?.normalizeTarget,
|
||||
messaging?.parseExplicitTarget,
|
||||
messaging?.inferTargetChatType,
|
||||
messaging?.buildCrossContextComponents,
|
||||
messaging?.buildCrossContextPresentation,
|
||||
messaging?.enableInteractiveReplies,
|
||||
messaging?.hasStructuredReplyPayload,
|
||||
messaging?.formatTargetDisplay,
|
||||
|
||||
Reference in New Issue
Block a user