feat: add presentation capability limits

This commit is contained in:
Peter Steinberger
2026-05-17 10:28:27 +01:00
parent 868315aef0
commit ad861d4c9d
23 changed files with 1662 additions and 29 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. (#80323) Thanks @100yenadmin.
- QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.

View File

@@ -1,2 +1,2 @@
701ff598b929acfe779b728fe5660aac13e317526117baad80eef6bd1c5663c3 plugin-sdk-api-baseline.json
4bb4bf89bb568ece9c8adbb0126f77cabc33698003190f272d23a2266d6bff84 plugin-sdk-api-baseline.jsonl
ec9fa02c3af9c210f7dbf6157d2f18e5c7171c29e6ae13b4f539aefcb25d178a plugin-sdk-api-baseline.json
2ee394b924edb9843987710a65f9d45523efadd7e7940e01c88ea39dcdcdad7c plugin-sdk-api-baseline.jsonl

View File

@@ -1199,6 +1199,9 @@ Slash sessions use isolated keys like `agent:<agentId>:slack:slash:<userId>` and
## Interactive replies
Slack can render agent-authored interactive reply controls, but this feature is disabled by default.
For new agent, CLI, and plugin output, prefer the shared
`presentation` buttons or select blocks. They use the same Slack interaction
path while also degrading on other channels.
Enable it globally:
@@ -1232,16 +1235,20 @@ Or enable it for one Slack account only:
}
```
When enabled, agents can emit Slack-only reply directives:
When enabled, agents can still emit deprecated Slack-only reply directives:
- `[[slack_buttons: Approve:approve, Reject:reject]]`
- `[[slack_select: Choose a target | Canary:canary, Production:production]]`
These directives compile into Slack Block Kit and route clicks or selections back through the existing Slack interaction event path.
These directives compile into Slack Block Kit and route clicks or selections
back through the existing Slack interaction event path. Keep them for old
prompts and Slack-specific escape hatches; use shared presentation for new
portable controls.
Notes:
- This is Slack-specific UI. Other channels do not translate Slack Block Kit directives into their own button systems.
- This is Slack-specific legacy UI. Other channels do not translate Slack Block
Kit directives into their own button systems.
- The interactive callback values are OpenClaw-generated opaque tokens, not raw agent-authored values.
- If generated interactive blocks would exceed Slack Block Kit limits, OpenClaw falls back to the original text reply instead of sending an invalid blocks payload.

View File

@@ -288,11 +288,12 @@ Send a Telegram Mini App button through generic presentation:
```
openclaw message send --channel telegram --target 123456789 --message "Open app:" \
--presentation '{"blocks":[{"type":"buttons","buttons":[{"label":"Launch","web_app":{"url":"https://example.com/app"}}]}]}'
--presentation '{"blocks":[{"type":"buttons","buttons":[{"label":"Launch","webApp":{"url":"https://example.com/app"}}]}]}'
```
Telegram `web_app` buttons are supported only in private chats between a user
and the bot.
Telegram web app buttons are supported only in private chats between a user and
the bot. Older JSON payloads using `web_app` still parse, but `webApp` is the
canonical presentation field.
Send a Teams card through generic presentation:

View File

@@ -90,6 +90,9 @@ type MessagePresentationOption = {
- `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.
The public producer-facing API treats `interactive` as deprecated. Runtime
support remains so existing approval helpers and older plugins continue to
work while new code emits `presentation`.
## Delivery metadata
@@ -128,6 +131,29 @@ type ChannelPresentationCapabilities = {
context?: boolean;
divider?: boolean;
tones?: MessagePresentationTone[];
limits?: {
actions?: {
maxActions?: number;
maxActionsPerRow?: number;
maxRows?: number;
maxLabelLength?: number;
maxValueBytes?: number;
supportsStyles?: boolean;
supportsDisabled?: boolean;
supportsLayoutHints?: boolean;
};
selects?: {
maxOptions?: number;
maxLabelLength?: number;
maxValueBytes?: number;
};
text?: {
maxLength?: number;
encoding?: "characters" | "utf8-bytes" | "utf16-units";
markdownDialect?: "plain" | "markdown" | "html" | "slack-mrkdwn" | "discord-markdown";
supportsEdit?: boolean;
};
};
};
type ChannelDeliveryCapabilities = {
@@ -160,7 +186,8 @@ Core behavior:
- Resolve target channel and runtime adapter.
- Ask for presentation capabilities.
- Degrade unsupported blocks before rendering.
- Degrade unsupported blocks and apply generic capability limits 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.

View File

@@ -57,7 +57,10 @@ type MessagePresentationButton = {
value?: string;
url?: string;
webApp?: { url: string };
/** @deprecated Use webApp. Accepted for legacy JSON payloads only. */
web_app?: { url: string };
priority?: number;
disabled?: boolean;
style?: "primary" | "secondary" | "success" | "danger";
};
@@ -82,11 +85,19 @@ Button semantics:
- `value` is an application action value routed back through the channel's
existing interaction path when the channel supports clickable controls.
- `url` is a link button. It can exist without `value`.
- `webApp` and `web_app` describe a channel-native web app button. Telegram
renders this as `web_app` and only supports it in private chats.
- `webApp` describes a channel-native web app button. Telegram renders this
as `web_app` and only supports it in private chats. `web_app` is still
accepted in loose JSON payloads for compatibility, but TypeScript producers
should use `webApp`.
- `label` is required and is also used in text fallback.
- `style` is advisory. Renderers should map unsupported styles to a safe
default, not fail the send.
- `priority` is optional. When a channel advertises action limits and controls
must be dropped, core keeps higher-priority buttons first and preserves
original order among equal priority buttons. When all controls fit, authored
order is preserved.
- `disabled` is optional. Channels must opt in with `supportsDisabled`; otherwise
core degrades the disabled control to non-interactive fallback text.
Select semantics:
@@ -205,6 +216,27 @@ const adapter: ChannelOutboundAdapter = {
selects: true,
context: true,
divider: true,
limits: {
actions: {
maxActions: 25,
maxActionsPerRow: 5,
maxRows: 5,
maxLabelLength: 80,
maxValueBytes: 100,
supportsStyles: true,
supportsDisabled: false,
},
selects: {
maxOptions: 25,
maxLabelLength: 100,
maxValueBytes: 100,
},
text: {
maxLength: 2000,
encoding: "characters",
markdownDialect: "discord-markdown",
},
},
},
deliveryCapabilities: {
pin: true,
@@ -218,10 +250,49 @@ const adapter: ChannelOutboundAdapter = {
};
```
Capability fields are intentionally simple booleans. They describe what the
renderer can make interactive, not every native platform limit. Renderers still
own platform-specific limits such as maximum button count, block count, and
card size.
Capability booleans describe what the renderer can make interactive. Optional
`limits` describe the generic envelope core can adapt before calling the
renderer:
```ts
type ChannelPresentationCapabilities = {
supported?: boolean;
buttons?: boolean;
selects?: boolean;
context?: boolean;
divider?: boolean;
limits?: {
actions?: {
maxActions?: number;
maxActionsPerRow?: number;
maxRows?: number;
maxLabelLength?: number;
maxValueBytes?: number;
supportsStyles?: boolean;
supportsDisabled?: boolean;
supportsLayoutHints?: boolean;
};
selects?: {
maxOptions?: number;
maxLabelLength?: number;
maxValueBytes?: number;
};
text?: {
maxLength?: number;
encoding?: "characters" | "utf8-bytes" | "utf16-units";
markdownDialect?: "plain" | "markdown" | "html" | "slack-mrkdwn" | "discord-markdown";
supportsEdit?: boolean;
};
};
};
```
Core applies generic limits to semantic controls before rendering. Renderers
still own final provider-specific validation and clipping for native block
count, card size, URL limits, and provider quirks that cannot be expressed in
the generic contract. If limits remove every control from a block, core keeps
the labels as non-interactive context text so the delivered message still has a
visible fallback.
## Core render flow
@@ -230,10 +301,12 @@ When a `ReplyPayload` or message action includes `presentation`, core:
1. Normalizes the presentation payload.
2. Resolves the target channel's outbound adapter.
3. Reads `presentationCapabilities`.
4. Calls `renderPresentation` when the adapter can render the payload.
5. Falls back to conservative text when the adapter is absent or cannot render.
6. Sends the resulting payload through the normal channel delivery path.
7. Applies delivery metadata such as `delivery.pin` after the first successful
4. Applies generic capability limits such as action count, label length, and
select option count when the adapter advertises them.
5. Calls `renderPresentation` when the adapter can render the payload.
6. Falls back to conservative text when the adapter is absent or cannot render.
7. Sends the resulting payload through the normal channel delivery path.
8. Applies delivery metadata such as `delivery.pin` after the first successful
sent message.
Core owns fallback behavior so producers can stay channel-agnostic. Channel
@@ -303,15 +376,20 @@ code:
```ts
import {
adaptMessagePresentationForChannel,
applyPresentationActionLimits,
interactiveReplyToPresentation,
normalizeMessagePresentation,
presentationPageSize,
presentationToInteractiveControlsReply,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
```
New code should accept or produce `MessagePresentation` directly.
New code should accept or produce `MessagePresentation` directly. Existing
`interactive` payloads are a deprecated subset of `presentation`; runtime
support remains for older producers.
`presentationToInteractiveReply(...)` preserves visible presentation text by
mapping the title, text, context, buttons, and selects into the older
@@ -351,7 +429,9 @@ messages where the provider supports those operations.
- Implement `renderPresentation` in runtime code, not control-plane plugin
setup code.
- Keep native UI libraries out of hot setup/catalog paths.
- Preserve platform limits in the renderer and tests.
- Declare generic capability limits on `presentationCapabilities.limits` when
they are known.
- Preserve final platform limits in the renderer and tests.
- Add fallback tests for unsupported buttons, selects, URL buttons, title/text
duplication, and mixed `message` plus `presentation` sends.
- Add delivery pin support through `deliveryCapabilities.pin` and

View File

@@ -120,6 +120,24 @@ export const discordOutbound: ChannelOutboundAdapter = {
selects: true,
context: true,
divider: true,
limits: {
actions: {
maxActions: 25,
maxActionsPerRow: 5,
maxRows: 5,
maxLabelLength: 80,
},
selects: {
maxOptions: 25,
maxLabelLength: 100,
maxValueBytes: 100,
},
text: {
maxLength: DISCORD_TEXT_CHUNK_LIMIT,
encoding: "characters",
markdownDialect: "discord-markdown",
},
},
},
deliveryCapabilities: {
durableFinal: {

View File

@@ -1387,6 +1387,19 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
selects: false,
context: true,
divider: true,
limits: {
actions: {
maxActions: 20,
maxActionsPerRow: 5,
maxLabelLength: 40,
maxValueBytes: 1024,
},
text: {
maxLength: 4000,
encoding: "characters",
markdownDialect: "markdown",
},
},
},
renderPresentation: async (ctx) => {
const runtime = await loadFeishuChannelRuntime();

View File

@@ -514,6 +514,19 @@ export const feishuOutbound: ChannelOutboundAdapter = {
selects: false,
context: true,
divider: true,
limits: {
actions: {
maxActions: 20,
maxActionsPerRow: 5,
maxLabelLength: 40,
maxValueBytes: 1024,
},
text: {
maxLength: 4000,
encoding: "characters",
markdownDialect: "markdown",
},
},
},
renderPresentation: renderFeishuPresentationPayload,
sendPayload: async (ctx) => {

View File

@@ -341,6 +341,14 @@ const matrixChannelOutbound: ChannelOutboundAdapter = {
selects: true,
context: true,
divider: true,
limits: {
text: {
maxLength: 4000,
encoding: "characters",
markdownDialect: "markdown",
supportsEdit: true,
},
},
},
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({

View File

@@ -104,6 +104,14 @@ export const matrixOutbound: ChannelOutboundAdapter = {
selects: true,
context: true,
divider: true,
limits: {
text: {
maxLength: 4000,
encoding: "characters",
markdownDialect: "markdown",
supportsEdit: true,
},
},
},
renderPresentation: ({ payload, presentation }) =>
renderMatrixPresentationPayload({ payload, presentation }),

View File

@@ -150,6 +150,25 @@ export const slackOutbound: ChannelOutboundAdapter = {
selects: true,
context: true,
divider: true,
limits: {
actions: {
maxActionsPerRow: 25,
maxLabelLength: 75,
maxValueBytes: 2000,
supportsStyles: true,
},
selects: {
maxOptions: 100,
maxLabelLength: 75,
maxValueBytes: 150,
},
text: {
maxLength: SLACK_TEXT_LIMIT,
encoding: "characters",
markdownDialect: "slack-mrkdwn",
supportsEdit: true,
},
},
},
renderPresentation: ({ payload, presentation }) => {
const slackData = payload.channelData?.slack as Record<string, unknown> | undefined;

View File

@@ -1,4 +1,5 @@
import { verifyDurableFinalCapabilityProofs } from "openclaw/plugin-sdk/channel-message";
import { adaptMessagePresentationForChannel } from "openclaw/plugin-sdk/interactive-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageTelegramMock = vi.fn();
@@ -215,6 +216,67 @@ describe("telegramOutbound", () => {
]);
});
it("lets allow-always approval callbacks reach Telegram's callback rewrite", async () => {
sendMessageTelegramMock.mockResolvedValueOnce({
messageId: "tg-approval",
chatId: "12345",
});
const approvalId = "plugin:123e4567-e89b-12d3-a456-426614174000";
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{
label: "Allow Always",
value: `/approve ${approvalId} allow-always`,
},
],
},
],
},
capabilities: telegramOutbound.presentationCapabilities,
});
const rendered = await telegramOutbound.renderPresentation?.({
payload: { text: "Approve?" },
presentation,
ctx: {} as never,
});
if (!rendered) {
throw new Error("expected rendered Telegram approval presentation");
}
await telegramOutbound.sendPayload!({
cfg: {} as never,
to: "12345",
text: "",
payload: rendered,
deps: { sendTelegram: sendMessageTelegramMock },
});
const options = callOptionsAt(
sendMessageTelegramMock,
0,
"12345",
"Approve?\n\n- Allow Always",
);
expect(options.buttons).toEqual([
[{ text: "Allow Always", callback_data: `/approve ${approvalId} always` }],
]);
});
it("counts presentation text limits in characters", () => {
const text = "👍".repeat(3000);
const presentation = adaptMessagePresentationForChannel({
presentation: { blocks: [{ type: "text", text }] },
capabilities: telegramOutbound.presentationCapabilities,
});
expect(presentation.blocks).toEqual([{ type: "text", text }]);
});
it("forwards silent delivery options to Telegram sends", async () => {
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-silent", chatId: "12345" });

View File

@@ -181,6 +181,23 @@ export function createTelegramOutboundAdapter(
selects: true,
context: true,
divider: false,
limits: {
actions: {
maxActions: 100,
maxActionsPerRow: 3,
maxLabelLength: 64,
supportsStyles: false,
},
selects: {
maxOptions: 100,
maxLabelLength: 64,
},
text: {
maxLength: TELEGRAM_TEXT_CHUNK_LIMIT,
encoding: "characters",
markdownDialect: "html",
},
},
},
deliveryCapabilities: {
pin: true,

View File

@@ -47,6 +47,29 @@ export type ChannelPresentationCapabilities = {
selects?: boolean;
context?: boolean;
divider?: boolean;
limits?: {
actions?: {
maxActions?: number;
maxActionsPerRow?: number;
maxRows?: number;
maxLabelLength?: number;
maxValueBytes?: number;
supportsStyles?: boolean;
supportsDisabled?: boolean;
supportsLayoutHints?: boolean;
};
selects?: {
maxOptions?: number;
maxLabelLength?: number;
maxValueBytes?: number;
};
text?: {
maxLength?: number;
encoding?: "characters" | "utf8-bytes" | "utf16-units";
markdownDialect?: "plain" | "markdown" | "html" | "slack-mrkdwn" | "discord-markdown";
supportsEdit?: boolean;
};
};
};
export type ChannelDeliveryCapabilities = {

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { reduceInteractiveReply } from "./interactive.js";
import {
adaptMessagePresentationForChannel,
applyPresentationActionLimits,
presentationPageSize,
reduceInteractiveReply,
} from "./interactive.js";
describe("reduceInteractiveReply", () => {
it("walks authored blocks in order", () => {
@@ -25,3 +30,676 @@ describe("reduceInteractiveReply", () => {
expect(reduceInteractiveReply(undefined, 3, (value) => value + 1)).toBe(3);
});
});
describe("presentation capability limits", () => {
it("keeps highest-priority buttons inside action capacity", () => {
const buttons = applyPresentationActionLimits(
[
{ label: "Low", value: "low", priority: -1 },
{ label: "Default", value: "default" },
{ label: "High", value: "high", priority: 10 },
{ label: "Next", value: "next", priority: 5 },
],
{
limits: {
actions: {
maxActions: 2,
maxLabelLength: 4,
supportsStyles: false,
},
},
},
);
expect(buttons).toEqual([
{ label: "High", value: "high", priority: 10 },
{ label: "Next", value: "next", priority: 5 },
]);
});
it("keeps authored button order when nothing is dropped", () => {
const buttons = applyPresentationActionLimits(
[
{ label: "First", value: "first", priority: 1 },
{ label: "Second", value: "second", priority: 100 },
{ label: "Third", value: "third" },
],
{
limits: {
actions: {
maxActionsPerRow: 5,
},
},
},
);
expect(buttons).toEqual([
{ label: "First", value: "first", priority: 1 },
{ label: "Second", value: "second", priority: 100 },
{ label: "Third", value: "third" },
]);
});
it("adapts button and select blocks without touching text blocks", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
title: "Deploy",
blocks: [
{ type: "text", text: "Ready" },
{
type: "buttons",
buttons: [
{
label: "Approve deployment",
value: "approve",
style: "success",
},
{ label: "Reject", value: "x".repeat(12), priority: 10 },
],
},
{
type: "select",
placeholder: "Environment target",
options: [
{ label: "Canary cluster", value: "canary" },
{ label: "Production cluster", value: "production" },
],
},
],
},
capabilities: {
limits: {
actions: {
maxActions: 2,
maxLabelLength: 7,
maxValueBytes: 8,
supportsStyles: false,
supportsDisabled: false,
},
selects: {
maxOptions: 1,
maxLabelLength: 6,
maxValueBytes: 20,
},
},
},
});
expect(presentation).toEqual({
title: "Deploy",
blocks: [
{ type: "text", text: "Ready" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
{ type: "context", text: "Actions:\n- Reject" },
{
type: "select",
placeholder: "Enviro",
options: [{ label: "Canary", value: "canary" }],
},
{ type: "context", text: "Environment target:\n- Produc" },
],
});
});
it("keeps visible fallback labels when controls exceed channel value limits", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve deployment", value: "approve-prod" },
{ label: "Rollback deployment", value: "rollback-prod" },
],
},
{
type: "select",
placeholder: "Environment",
options: [
{ label: "Canary cluster", value: "canary-target" },
{ label: "Production cluster", value: "production-target" },
],
},
],
},
capabilities: {
limits: {
actions: {
maxValueBytes: 4,
maxLabelLength: 8,
},
selects: {
maxValueBytes: 4,
maxLabelLength: 7,
},
},
},
});
expect(presentation.blocks).toEqual([
{ type: "context", text: "Actions:\n- Approve\n- Rollback" },
{ type: "context", text: "Environment:\n- Canary\n- Product" },
]);
});
it("keeps fallback labels for invalid buttons in mixed button blocks", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "ok" },
{ label: "Audit trail", value: "x".repeat(20) },
{ label: "Docs", value: "x".repeat(20), url: "https://docs.example.test" },
],
},
],
},
capabilities: {
limits: {
actions: {
maxValueBytes: 4,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "buttons",
buttons: [
{ label: "Approve", value: "ok" },
{ label: "Docs", url: "https://docs.example.test" },
],
},
{ type: "context", text: "Actions:\n- Audit trail" },
]);
});
it("degrades disabled buttons unless the channel supports disabled controls", () => {
const unsupported = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Wait", value: "wait", disabled: true }],
},
],
},
capabilities: {
limits: {
actions: {},
},
},
});
const supported = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Wait", value: "wait", disabled: true }],
},
],
},
capabilities: {
limits: {
actions: {
supportsDisabled: true,
},
},
},
});
expect(unsupported.blocks).toEqual([{ type: "context", text: "Actions:\n- Wait" }]);
expect(supported.blocks).toEqual([
{
type: "buttons",
buttons: [{ label: "Wait", value: "wait", disabled: true }],
},
]);
});
it("degrades unsupported controls before channel rendering", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
{
type: "select",
placeholder: "Target",
options: [{ label: "Canary", value: "canary" }],
},
{ type: "divider" },
{ type: "context", text: "Muted details" },
],
},
capabilities: {
buttons: false,
selects: false,
context: false,
divider: false,
limits: {
actions: { maxLabelLength: 4 },
selects: { maxLabelLength: 6 },
},
},
});
expect(presentation.blocks).toEqual([
{ type: "text", text: "Actions:\n- Appr" },
{ type: "text", text: "Target:\n- Canary" },
{ type: "text", text: "Muted details" },
]);
});
it("keeps fallback labels for invalid or overflowed select options", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "select",
placeholder: "Target",
options: [
{ label: "Canary", value: "canary" },
{ label: "Production", value: "prod" },
{ label: "Long callback", value: "x".repeat(20) },
],
},
],
},
capabilities: {
limits: {
selects: {
maxOptions: 1,
maxValueBytes: 8,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "select",
placeholder: "Target",
options: [{ label: "Canary", value: "canary" }],
},
{ type: "context", text: "Target:\n- Production\n- Long callback" },
]);
});
it("applies advertised text limits to titles, text, context, and generated fallback", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
title: "abcdef",
blocks: [
{ type: "text", text: "hello world" },
{ type: "context", text: "abcdef" },
{
type: "buttons",
buttons: [{ label: "Deploy", value: "toolong" }],
},
],
},
capabilities: {
limits: {
actions: {
maxValueBytes: 2,
},
text: {
maxLength: 5,
encoding: "characters",
},
},
},
});
expect(presentation).toEqual({
title: "abcde",
blocks: [
{ type: "text", text: "hello" },
{ type: "context", text: "abcde" },
{ type: "context", text: "Actio" },
],
});
});
it("does not split code points when applying utf8 byte text limits", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [{ type: "text", text: "abc😀def" }],
},
capabilities: {
limits: {
text: {
maxLength: 6,
encoding: "utf8-bytes",
},
},
},
});
expect(presentation.blocks).toEqual([{ type: "text", text: "abc" }]);
});
it("does not split code points when applying label limits", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "😀😀😀", value: "ok" }],
},
{
type: "select",
placeholder: "🚀🚀🚀",
options: [{ label: "👍👍👍", value: "yes" }],
},
],
},
capabilities: {
limits: {
actions: {
maxLabelLength: 2,
},
selects: {
maxLabelLength: 2,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "buttons",
buttons: [{ label: "😀😀", value: "ok" }],
},
{
type: "select",
placeholder: "🚀🚀",
options: [{ label: "👍👍", value: "yes" }],
},
]);
});
it("preserves link buttons by dropping only over-limit callback values", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Open report", value: "x".repeat(20), url: "https://example.test" }],
},
],
},
capabilities: {
limits: {
actions: {
maxValueBytes: 4,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "buttons",
buttons: [{ label: "Open report", url: "https://example.test" }],
},
]);
});
it("applies button priority across the shared action budget", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Low", value: "low" }],
},
{
type: "buttons",
buttons: [{ label: "High", value: "high", priority: 10 }],
},
],
},
capabilities: {
limits: {
actions: {
maxActions: 1,
},
},
},
});
expect(presentation.blocks).toEqual([
{ type: "context", text: "Actions:\n- Low" },
{
type: "buttons",
buttons: [{ label: "High", value: "high", priority: 10 }],
},
]);
});
it("keeps link targets when overflowed buttons become fallback text", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "One", value: "one" }],
},
{
type: "buttons",
buttons: [{ label: "Docs", url: "https://docs.example.test" }],
},
],
},
capabilities: {
limits: {
actions: {
maxActions: 1,
maxLabelLength: 4,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "buttons",
buttons: [{ label: "One", value: "one" }],
},
{ type: "context", text: "Actions:\n- Docs: https://docs.example.test" },
]);
});
it("preserves callback button values when actions do not declare a value limit", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "x".repeat(180) }],
},
],
},
capabilities: {
limits: {
actions: {
maxActions: 5,
maxActionsPerRow: 5,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "buttons",
buttons: [{ label: "Approve", value: "x".repeat(180) }],
},
]);
});
it("reserves action row capacity for select blocks", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{ label: "One", value: "one" },
{ label: "Two", value: "two" },
{ label: "Three", value: "three" },
],
},
{
type: "select",
placeholder: "Extra",
options: [{ label: "Four", value: "four" }],
},
],
},
capabilities: {
limits: {
actions: {
maxActionsPerRow: 2,
maxRows: 2,
},
selects: {
maxOptions: 25,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "buttons",
buttons: [
{ label: "One", value: "one" },
{ label: "Two", value: "two" },
],
},
{ type: "context", text: "Actions:\n- Three" },
{
type: "select",
placeholder: "Extra",
options: [{ label: "Four", value: "four" }],
},
]);
});
it("splits button blocks by per-row limits even when rows are unlimited", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{ label: "One", value: "one" },
{ label: "Two", value: "two" },
{ label: "Three", value: "three" },
{ label: "Four", value: "four" },
{ label: "Five", value: "five" },
{ label: "Six", value: "six" },
],
},
],
},
capabilities: {
limits: {
actions: {
maxActions: 20,
maxActionsPerRow: 5,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "buttons",
buttons: [
{ label: "One", value: "one" },
{ label: "Two", value: "two" },
{ label: "Three", value: "three" },
{ label: "Four", value: "four" },
{ label: "Five", value: "five" },
],
},
{
type: "buttons",
buttons: [{ label: "Six", value: "six" }],
},
]);
});
it("counts selects against the shared action capacity", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "select",
placeholder: "Target",
options: [{ label: "Canary", value: "canary" }],
},
{
type: "buttons",
buttons: [
{ label: "One", value: "one" },
{ label: "Two", value: "two" },
{ label: "Three", value: "three" },
],
},
],
},
capabilities: {
limits: {
actions: {
maxActions: 3,
maxActionsPerRow: 5,
maxRows: 5,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "select",
placeholder: "Target",
options: [{ label: "Canary", value: "canary" }],
},
{
type: "buttons",
buttons: [
{ label: "One", value: "one" },
{ label: "Two", value: "two" },
],
},
{ type: "context", text: "Actions:\n- Three" },
]);
});
it("resolves page size from available action capacity", () => {
expect(
presentationPageSize(
{
limits: {
actions: { maxActionsPerRow: 5, maxRows: 2 },
},
},
1,
20,
),
).toBe(9);
});
});

View File

@@ -1,4 +1,9 @@
import type { InteractiveReply, InteractiveReplyBlock } from "../../../interactive/payload.js";
export {
adaptMessagePresentationForChannel,
applyPresentationActionLimits,
presentationPageSize,
} from "./presentation-limits.js";
export function reduceInteractiveReply<TState>(
interactive: InteractiveReply | undefined,

View File

@@ -0,0 +1,533 @@
import type {
MessagePresentation,
MessagePresentationBlock,
MessagePresentationButton,
MessagePresentationOption,
} from "../../../interactive/payload.js";
import type { ChannelPresentationCapabilities } from "../outbound.types.js";
type ActionLimits = NonNullable<NonNullable<ChannelPresentationCapabilities["limits"]>["actions"]>;
type SelectLimits = NonNullable<NonNullable<ChannelPresentationCapabilities["limits"]>["selects"]>;
type TextLimits = NonNullable<NonNullable<ChannelPresentationCapabilities["limits"]>["text"]>;
type ActionBudget = {
remainingActions?: number;
remainingRows?: number;
maxActionsPerRow?: number;
};
type ButtonCandidate = {
original: MessagePresentationButton;
adapted?: MessagePresentationButton;
};
type SelectCandidate = {
original: MessagePresentationOption;
adapted?: MessagePresentationOption;
};
type ButtonSelection = ReadonlySet<MessagePresentationButton> | undefined;
function positiveInteger(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function truncateText(value: string, maxLength: number | undefined): string {
const limit = positiveInteger(maxLength);
if (!limit) {
return value;
}
const chars = Array.from(value);
return chars.length > limit ? chars.slice(0, limit).join("") : value;
}
function truncateUtf8Bytes(value: string, limit: number): string {
let bytes = 0;
let result = "";
for (const char of value) {
const nextBytes = utf8ByteLength(char);
if (bytes + nextBytes > limit) {
break;
}
bytes += nextBytes;
result += char;
}
return result;
}
function truncatePresentationText(value: string, limits: TextLimits | undefined): string {
const limit = positiveInteger(limits?.maxLength);
if (!limit) {
return value;
}
if (limits?.encoding === "utf8-bytes") {
return truncateUtf8Bytes(value, limit);
}
if (limits?.encoding === "utf16-units") {
return value.length > limit ? value.slice(0, limit) : value;
}
const chars = Array.from(value);
return chars.length > limit ? chars.slice(0, limit).join("") : value;
}
function utf8ByteLength(value: string): number {
return Buffer.byteLength(value, "utf8");
}
function fitsByteLimit(value: string | undefined, maxBytes: number | undefined): boolean {
const limit = positiveInteger(maxBytes);
return !value || !limit || utf8ByteLength(value) <= limit;
}
function fallbackListBlock(params: {
blockType: "context" | "text";
heading: string;
labels: readonly string[];
maxLabelLength?: number;
}): MessagePresentationBlock | undefined {
const labels = params.labels
.map((label) => truncateText(label, params.maxLabelLength).trim())
.filter(Boolean);
return labels.length > 0
? {
type: params.blockType,
text: `${params.heading}:\n${labels.map((label) => `- ${label}`).join("\n")}`,
}
: undefined;
}
function buttonFallbackLabel(
button: MessagePresentationButton,
maxLabelLength: number | undefined,
): string {
const label = truncateText(button.label, maxLabelLength);
const target = button.url ?? button.webApp?.url ?? button.web_app?.url;
return target ? `${label}: ${target}` : label;
}
function actionCapacity(limits: ActionLimits | undefined): number | undefined {
const maxActions = positiveInteger(limits?.maxActions);
const maxRows = positiveInteger(limits?.maxRows);
const maxActionsPerRow = positiveInteger(limits?.maxActionsPerRow);
const rowCapacity = maxRows && maxActionsPerRow ? maxRows * maxActionsPerRow : undefined;
if (maxActions && rowCapacity) {
return Math.min(maxActions, rowCapacity);
}
return maxActions ?? rowCapacity;
}
function buttonCapacityAfterReservedSelects(
limits: ActionLimits | undefined,
reservedSelects: number,
): number | undefined {
const maxActions = positiveInteger(limits?.maxActions);
const maxRows = positiveInteger(limits?.maxRows);
const maxActionsPerRow = positiveInteger(limits?.maxActionsPerRow);
const remainingActions =
maxActions === undefined ? undefined : Math.max(0, maxActions - reservedSelects);
const remainingRows = maxRows === undefined ? undefined : Math.max(0, maxRows - reservedSelects);
const rowCapacity =
remainingRows !== undefined && maxActionsPerRow !== undefined
? remainingRows * maxActionsPerRow
: undefined;
if (remainingActions !== undefined && rowCapacity !== undefined) {
return Math.min(remainingActions, rowCapacity);
}
return remainingActions ?? rowCapacity;
}
function createActionBudget(limits: ActionLimits | undefined): ActionBudget {
return {
remainingActions: positiveInteger(limits?.maxActions),
remainingRows: positiveInteger(limits?.maxRows),
maxActionsPerRow: positiveInteger(limits?.maxActionsPerRow),
};
}
function buttonCapacity(budget: ActionBudget): number | undefined {
if (budget.remainingActions === 0 || budget.remainingRows === 0) {
return 0;
}
const rowCapacity =
budget.remainingRows && budget.maxActionsPerRow
? budget.remainingRows * budget.maxActionsPerRow
: undefined;
if (budget.remainingActions !== undefined && rowCapacity !== undefined) {
return Math.min(budget.remainingActions, rowCapacity);
}
return budget.remainingActions ?? rowCapacity;
}
function consumeButtonBudget(budget: ActionBudget, count: number): void {
if (count <= 0) {
return;
}
if (budget.remainingActions !== undefined) {
budget.remainingActions = Math.max(0, budget.remainingActions - count);
}
if (budget.remainingRows !== undefined) {
const perRow = budget.maxActionsPerRow ?? count;
budget.remainingRows = Math.max(0, budget.remainingRows - Math.ceil(count / perRow));
}
}
function chunkButtons(
buttons: readonly MessagePresentationButton[],
maxActionsPerRow: number | undefined,
): MessagePresentationButton[][] {
const rowSize = positiveInteger(maxActionsPerRow);
if (!rowSize) {
return buttons.length > 0 ? [[...buttons]] : [];
}
const rows: MessagePresentationButton[][] = [];
for (let index = 0; index < buttons.length; index += rowSize) {
rows.push(buttons.slice(index, index + rowSize));
}
return rows;
}
function hasActionSlotBudget(budget: ActionBudget): boolean {
return budget.remainingActions !== 0 && budget.remainingRows !== 0;
}
function consumeSelectBudget(budget: ActionBudget): void {
if (budget.remainingActions !== undefined) {
budget.remainingActions = Math.max(0, budget.remainingActions - 1);
}
if (budget.remainingRows !== undefined) {
budget.remainingRows = Math.max(0, budget.remainingRows - 1);
}
}
function adaptButton(
button: MessagePresentationButton,
limits: ActionLimits | undefined,
): MessagePresentationButton | undefined {
const hasLinkTarget = Boolean(button.url || button.webApp || button.web_app);
const valueFits = fitsByteLimit(button.value, limits?.maxValueBytes);
if (
(!valueFits && !hasLinkTarget) ||
(button.disabled === true && limits?.supportsDisabled !== true)
) {
return undefined;
}
const adapted: MessagePresentationButton = {
...button,
label: truncateText(button.label, limits?.maxLabelLength),
};
if (!valueFits) {
delete adapted.value;
}
if (limits?.supportsStyles === false) {
delete adapted.style;
}
return adapted;
}
function adaptButtonsBlock(
block: Extract<MessagePresentationBlock, { type: "buttons" }>,
limits: ActionLimits | undefined,
budget: ActionBudget,
fallbackBlockType: "context" | "text",
buttonSelection: ButtonSelection,
): MessagePresentationBlock[] {
const capacity = buttonCapacity(budget);
const candidates: ButtonCandidate[] = block.buttons.map((button) => ({
original: button,
adapted: adaptButton(button, limits),
}));
const renderableCandidates = candidates.filter(
(candidate): candidate is ButtonCandidate & { adapted: MessagePresentationButton } =>
Boolean(candidate.adapted),
);
const eligibleCandidates = buttonSelection
? renderableCandidates.filter((candidate) => buttonSelection.has(candidate.original))
: renderableCandidates;
const selectedCandidates =
capacity !== undefined && eligibleCandidates.length > capacity
? eligibleCandidates
.map((candidate, index) => ({ candidate, index }))
.toSorted((left, right) => {
const priorityDelta =
(right.candidate.adapted.priority ?? 0) - (left.candidate.adapted.priority ?? 0);
return priorityDelta || left.index - right.index;
})
.slice(0, capacity)
.map((entry) => entry.candidate)
: eligibleCandidates;
const selected = new Set<ButtonCandidate>(selectedCandidates);
const buttons = selectedCandidates.map((candidate) => candidate.adapted);
const droppedLabels = candidates
.filter((candidate) => !candidate.adapted || !selected.has(candidate))
.map((candidate) => buttonFallbackLabel(candidate.original, limits?.maxLabelLength));
consumeButtonBudget(budget, buttons.length);
const fallback = fallbackListBlock({
blockType: fallbackBlockType,
heading: "Actions",
labels: droppedLabels,
});
if (buttons.length === 0) {
return fallback ? [fallback] : [];
}
const blocks: MessagePresentationBlock[] = chunkButtons(buttons, limits?.maxActionsPerRow).map(
(row) => ({
type: "buttons",
buttons: row,
}),
);
if (fallback) {
blocks.push(fallback);
}
return blocks;
}
function appendAdaptedButtonsBlock(
blocks: MessagePresentationBlock[],
block: Extract<MessagePresentationBlock, { type: "buttons" }>,
limits: ActionLimits | undefined,
budget: ActionBudget,
fallbackBlockType: "context" | "text",
buttonSelection: ButtonSelection,
): void {
blocks.push(...adaptButtonsBlock(block, limits, budget, fallbackBlockType, buttonSelection));
}
function adaptOption(
option: MessagePresentationOption,
limits: SelectLimits | undefined,
): MessagePresentationOption | undefined {
if (!fitsByteLimit(option.value, limits?.maxValueBytes)) {
return undefined;
}
return {
...option,
label: truncateText(option.label, limits?.maxLabelLength),
};
}
function adaptSelectBlock(
block: Extract<MessagePresentationBlock, { type: "select" }>,
limits: SelectLimits | undefined,
budget: ActionBudget,
fallbackBlockType: "context" | "text",
): MessagePresentationBlock[] {
const candidates: SelectCandidate[] = block.options.map((option) => ({
original: option,
adapted: adaptOption(option, limits),
}));
const renderableCandidates = candidates.filter(
(candidate): candidate is SelectCandidate & { adapted: MessagePresentationOption } =>
Boolean(candidate.adapted),
);
const maxOptions = positiveInteger(limits?.maxOptions);
const selectedCandidates = maxOptions
? renderableCandidates.slice(0, maxOptions)
: renderableCandidates;
const selected = new Set<SelectCandidate>(selectedCandidates);
const options = selectedCandidates.map((candidate) => candidate.adapted);
const canRenderSelect = options.length > 0 && hasActionSlotBudget(budget);
const fallback = fallbackListBlock({
blockType: fallbackBlockType,
heading: block.placeholder ?? "Options",
labels: (canRenderSelect
? candidates.filter((candidate) => !candidate.adapted || !selected.has(candidate))
: candidates
).map((candidate) => candidate.original.label),
maxLabelLength: limits?.maxLabelLength,
});
if (!canRenderSelect) {
return fallback ? [fallback] : [];
}
consumeSelectBudget(budget);
const blocks: MessagePresentationBlock[] = [
{
type: "select",
placeholder: truncateText(block.placeholder ?? "", limits?.maxLabelLength) || undefined,
options,
},
];
if (fallback) {
blocks.push(fallback);
}
return blocks;
}
function countRenderableSelectBlocks(
blocks: readonly MessagePresentationBlock[],
capabilities: ChannelPresentationCapabilities | undefined,
limits: SelectLimits | undefined,
): number {
if (capabilities?.selects === false) {
return 0;
}
return blocks.filter((block) => {
if (block.type !== "select") {
return false;
}
const maxOptions = positiveInteger(limits?.maxOptions);
const renderableOptions = block.options
.map((option) => adaptOption(option, limits))
.filter(Boolean)
.slice(0, maxOptions ?? undefined);
return renderableOptions.length > 0;
}).length;
}
function createGlobalButtonSelection(params: {
presentation: MessagePresentation;
capabilities: ChannelPresentationCapabilities | undefined;
limits: ActionLimits | undefined;
selectLimits: SelectLimits | undefined;
}): ButtonSelection {
if (params.capabilities?.buttons === false) {
return undefined;
}
const reservedSelectSlots = countRenderableSelectBlocks(
params.presentation.blocks,
params.capabilities,
params.selectLimits,
);
const capacity = buttonCapacityAfterReservedSelects(params.limits, reservedSelectSlots);
if (capacity === undefined) {
return undefined;
}
const candidates = params.presentation.blocks.flatMap((block) => {
if (block.type !== "buttons") {
return [];
}
return block.buttons
.map((button) => ({
original: button,
adapted: adaptButton(button, params.limits),
}))
.filter(
(
candidate,
): candidate is {
original: MessagePresentationButton;
adapted: MessagePresentationButton;
} => Boolean(candidate.adapted),
);
});
if (candidates.length <= capacity) {
return undefined;
}
return new Set(
candidates
.map((candidate, index) => ({ candidate, index }))
.toSorted((left, right) => {
const priorityDelta =
(right.candidate.adapted.priority ?? 0) - (left.candidate.adapted.priority ?? 0);
return priorityDelta || left.index - right.index;
})
.slice(0, capacity)
.map((entry) => entry.candidate.original),
);
}
function adaptTextBlock(
block: MessagePresentationBlock,
limits: TextLimits | undefined,
): MessagePresentationBlock {
if (block.type === "text" || block.type === "context") {
return {
...block,
text: truncatePresentationText(block.text, limits),
};
}
return block;
}
export function adaptMessagePresentationForChannel(params: {
presentation: MessagePresentation;
capabilities?: ChannelPresentationCapabilities;
}): MessagePresentation {
const capabilities = params.capabilities;
const limits = params.capabilities?.limits;
const actionBudget = createActionBudget(limits?.actions);
const fallbackBlockType = capabilities?.context === false ? "text" : "context";
const buttonSelection = createGlobalButtonSelection({
presentation: params.presentation,
capabilities,
limits: limits?.actions,
selectLimits: limits?.selects,
});
const blocks: MessagePresentationBlock[] = [];
for (const block of params.presentation.blocks) {
if (block.type === "buttons") {
if (capabilities?.buttons === false) {
const fallback = fallbackListBlock({
blockType: fallbackBlockType,
heading: "Actions",
labels: block.buttons.map((button) =>
buttonFallbackLabel(button, limits?.actions?.maxLabelLength),
),
});
if (fallback) {
blocks.push(fallback);
}
continue;
}
appendAdaptedButtonsBlock(
blocks,
block,
limits?.actions,
actionBudget,
fallbackBlockType,
buttonSelection,
);
continue;
}
if (block.type === "select") {
if (capabilities?.selects === false) {
const fallback = fallbackListBlock({
blockType: fallbackBlockType,
heading: block.placeholder ?? "Options",
labels: block.options.map((option) => option.label),
maxLabelLength: limits?.selects?.maxLabelLength,
});
if (fallback) {
blocks.push(fallback);
}
continue;
}
blocks.push(...adaptSelectBlock(block, limits?.selects, actionBudget, fallbackBlockType));
continue;
}
if (block.type === "context" && capabilities?.context === false) {
blocks.push({ type: "text", text: block.text });
continue;
}
if (block.type === "divider" && capabilities?.divider === false) {
continue;
}
blocks.push(block);
}
return {
...params.presentation,
...(params.presentation.title
? { title: truncatePresentationText(params.presentation.title, limits?.text) }
: {}),
blocks: blocks.map((block) => adaptTextBlock(block, limits?.text)),
};
}
export function applyPresentationActionLimits(
buttons: readonly MessagePresentationButton[],
capabilities?: ChannelPresentationCapabilities,
): MessagePresentationButton[] {
const block = adaptButtonsBlock(
{ type: "buttons", buttons: [...buttons] },
capabilities?.limits?.actions,
createActionBudget(capabilities?.limits?.actions),
capabilities?.context === false ? "text" : "context",
undefined,
);
return block.flatMap((entry) => (entry.type === "buttons" ? entry.buttons : []));
}
export function presentationPageSize(
capabilities?: ChannelPresentationCapabilities,
reservedActions = 0,
maxPageSize = Number.POSITIVE_INFINITY,
): number {
const capacity = actionCapacity(capabilities?.limits?.actions);
const remaining = Math.max(0, (capacity ?? maxPageSize) - Math.max(0, reservedActions));
return Math.max(1, Math.min(remaining || 1, maxPageSize));
}

View File

@@ -1681,6 +1681,87 @@ describe("deliverOutboundPayloads", () => {
});
});
it("adapts presentation buttons to channel limits before rendering", async () => {
const renderPresentation = vi.fn(({ payload }) => ({
...payload,
channelData: { rendered: true },
}));
const sendPayload = vi.fn().mockResolvedValue({
channel: "matrix" as const,
messageId: "adapted",
roomId: "!room",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
presentationCapabilities: {
supported: true,
buttons: true,
limits: {
actions: {
maxActions: 1,
maxLabelLength: 4,
maxValueBytes: 8,
supportsStyles: false,
},
},
},
renderPresentation,
sendText: vi.fn(),
sendMedia: vi.fn(),
sendPayload,
},
}),
},
]),
);
await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room",
payloads: [
{
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{ label: "Reject", value: "reject", priority: 1, style: "danger" },
{ label: "Approve", value: "approve", priority: 10, style: "success" },
{ label: "Too long", value: "x".repeat(12), priority: 20 },
],
},
],
},
},
],
});
const renderArg = requireMockCallArg(renderPresentation, "renderPresentation") as {
presentation?: unknown;
};
expect(renderArg.presentation).toEqual({
tone: undefined,
blocks: [
{
type: "buttons",
buttons: [{ label: "Appr", value: "approve", priority: 10, style: undefined }],
},
{
type: "context",
text: "Actions:\n- Reje\n- Too",
},
],
});
});
it("runs adapter after-delivery hooks with the payload delivery results", async () => {
const afterDeliverPayload = vi.fn();
setActivePluginRegistry(

View File

@@ -8,6 +8,7 @@ import type {
ChannelMessageSendLifecycleAdapter,
ChannelMessageSendResult,
} from "../../channels/message/types.js";
import { adaptMessagePresentationForChannel } from "../../channels/plugins/outbound/interactive.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import type {
ChannelDeliveryCapabilities,
@@ -143,6 +144,7 @@ type ChannelHandler = {
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
sendTextOnlyErrorPayloads?: boolean;
renderPresentation?: (payload: ReplyPayload) => Promise<ReplyPayload | null>;
presentationCapabilities?: ChannelOutboundAdapter["presentationCapabilities"];
pinDeliveredMessage?: (params: {
target: ChannelOutboundTargetRef;
messageId: string;
@@ -387,6 +389,7 @@ function createPluginHandler(
})
: undefined,
sendTextOnlyErrorPayloads: outbound?.sendTextOnlyErrorPayloads === true,
presentationCapabilities: outbound?.presentationCapabilities,
renderPresentation: outbound?.renderPresentation
? async (payload) => {
const presentation = normalizeMessagePresentation(payload.presentation);
@@ -950,7 +953,14 @@ async function renderPresentationForDelivery(
if (!presentation) {
return payload;
}
const rendered = handler.renderPresentation ? await handler.renderPresentation(payload) : null;
const adaptedPresentation = adaptMessagePresentationForChannel({
presentation,
capabilities: handler.presentationCapabilities,
});
const adaptedPayload = { ...payload, presentation: adaptedPresentation };
const rendered = handler.renderPresentation
? await handler.renderPresentation(adaptedPayload)
: null;
if (rendered) {
const { presentation: _presentation, ...withoutPresentation } = rendered;
return withoutPresentation;
@@ -960,7 +970,7 @@ async function renderPresentationForDelivery(
...withoutPresentation,
text: renderMessagePresentationFallbackText({
text: payload.text,
presentation,
presentation: adaptedPresentation,
}),
};
}

View File

@@ -12,6 +12,14 @@ export type InteractiveReplyButton = {
webApp?: {
url: string;
};
/**
* @deprecated Use webApp. The snake_case alias is accepted for legacy JSON payloads only.
*/
web_app?: {
url: string;
};
priority?: number;
disabled?: boolean;
style?: InteractiveButtonStyle;
};
@@ -146,11 +154,17 @@ function normalizeButton(raw: unknown): InteractiveReplyButton | undefined {
if (!label || (!value && !url && !webAppUrl)) {
return undefined;
}
const priority =
typeof record.priority === "number" && Number.isFinite(record.priority)
? record.priority
: undefined;
return {
label,
...(value ? { value } : {}),
...(url ? { url } : {}),
...(webAppUrl ? { webApp: { url: webAppUrl } } : {}),
...(priority !== undefined ? { priority } : {}),
...(record.disabled === true ? { disabled: true } : {}),
style: normalizeButtonStyle(record.style),
};
}
@@ -279,7 +293,7 @@ export function presentationToInteractiveReply(
}
if (block.type === "buttons") {
const buttons = block.buttons
.filter((button) => button.value || button.url || button.webApp)
.filter((button) => button.value || button.url || button.webApp || button.web_app)
.map((button) => {
const interactiveButton: InteractiveReplyButton = {
label: button.label,
@@ -291,8 +305,15 @@ export function presentationToInteractiveReply(
if (button.url) {
interactiveButton.url = button.url;
}
if (button.webApp) {
interactiveButton.webApp = button.webApp;
const webApp = button.webApp ?? button.web_app;
if (webApp) {
interactiveButton.webApp = webApp;
}
if (button.priority !== undefined) {
interactiveButton.priority = button.priority;
}
if (button.disabled === true) {
interactiveButton.disabled = true;
}
return interactiveButton;
});
@@ -370,7 +391,7 @@ export function renderMessagePresentationFallbackText(params: {
if (block.type === "buttons") {
const labels = block.buttons
.map((button) => {
const targetUrl = button.url ?? button.webApp?.url;
const targetUrl = button.url ?? button.webApp?.url ?? button.web_app?.url;
return targetUrl ? `${button.label}: ${targetUrl}` : button.label;
})
.filter(Boolean);

View File

@@ -1,4 +1,9 @@
export { reduceInteractiveReply } from "../channels/plugins/outbound/interactive.js";
export {
adaptMessagePresentationForChannel,
applyPresentationActionLimits,
presentationPageSize,
reduceInteractiveReply,
} from "../channels/plugins/outbound/interactive.js";
export type {
InteractiveButtonStyle,
InteractiveReply,

View File

@@ -20,6 +20,9 @@ export type OutboundReplyPayload = {
mediaUrls?: string[];
mediaUrl?: string;
presentation?: InternalReplyPayload["presentation"];
/**
* @deprecated Use presentation. Runtime support remains for legacy producers.
*/
interactive?: InternalReplyPayload["interactive"];
channelData?: InternalReplyPayload["channelData"];
sensitiveMedia?: boolean;