From ad861d4c9df81e5596bc683d24a327da35e020b0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 10:28:27 +0100 Subject: [PATCH] feat: add presentation capability limits --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/slack.md | 13 +- docs/cli/message.md | 7 +- docs/plan/ui-channels.md | 29 +- docs/plugins/message-presentation.md | 104 ++- extensions/discord/src/outbound-adapter.ts | 18 + extensions/feishu/src/channel.ts | 13 + extensions/feishu/src/outbound.ts | 13 + extensions/matrix/src/channel.ts | 8 + extensions/matrix/src/outbound.ts | 8 + extensions/slack/src/outbound-adapter.ts | 19 + .../telegram/src/outbound-adapter.test.ts | 62 ++ extensions/telegram/src/outbound-adapter.ts | 17 + src/channels/plugins/outbound.types.ts | 23 + .../plugins/outbound/interactive.test.ts | 680 +++++++++++++++++- src/channels/plugins/outbound/interactive.ts | 5 + .../plugins/outbound/presentation-limits.ts | 533 ++++++++++++++ src/infra/outbound/deliver.test.ts | 81 +++ src/infra/outbound/deliver.ts | 14 +- src/interactive/payload.ts | 29 +- src/plugin-sdk/interactive-runtime.ts | 7 +- src/plugin-sdk/reply-payload.ts | 3 + 23 files changed, 1662 insertions(+), 29 deletions(-) create mode 100644 src/channels/plugins/outbound/presentation-limits.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b96570627..73cf40ed89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index b48e1851e69..f2cbab8abeb 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 6255d809218..a0aa739e896 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1199,6 +1199,9 @@ Slash sessions use isolated keys like `agent::slack:slash:` 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. diff --git a/docs/cli/message.md b/docs/cli/message.md index 477b1a58b72..fefb5081287 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -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: diff --git a/docs/plan/ui-channels.md b/docs/plan/ui-channels.md index a12e778c191..cea9719cfb7 100644 --- a/docs/plan/ui-channels.md +++ b/docs/plan/ui-channels.md @@ -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. diff --git a/docs/plugins/message-presentation.md b/docs/plugins/message-presentation.md index 4817c1aac37..96eada473d6 100644 --- a/docs/plugins/message-presentation.md +++ b/docs/plugins/message-presentation.md @@ -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 diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index c84b313f20d..c1f82f2916c 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -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: { diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 2f10fa91c25..1bca4e0bad5 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1387,6 +1387,19 @@ export const feishuPlugin: ChannelPlugin { const runtime = await loadFeishuChannelRuntime(); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index ceb67d9a216..60c54e32235 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -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) => { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 63ed0e54e3c..517960fc94b 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -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({ diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 644202bbba4..85dae1e7565 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -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 }), diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index fd45f68c816..ed6c51724fe 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -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 | undefined; diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 6cb200c82e2..c63dafda687 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -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" }); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index c89237be014..a1fe41f0e70 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -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, diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index e09afaa9003..b2c97a02133 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -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 = { diff --git a/src/channels/plugins/outbound/interactive.test.ts b/src/channels/plugins/outbound/interactive.test.ts index 8cc2e44c1ec..6ef28524ef7 100644 --- a/src/channels/plugins/outbound/interactive.test.ts +++ b/src/channels/plugins/outbound/interactive.test.ts @@ -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); + }); +}); diff --git a/src/channels/plugins/outbound/interactive.ts b/src/channels/plugins/outbound/interactive.ts index 6a5cdacb0ce..4594af1650e 100644 --- a/src/channels/plugins/outbound/interactive.ts +++ b/src/channels/plugins/outbound/interactive.ts @@ -1,4 +1,9 @@ import type { InteractiveReply, InteractiveReplyBlock } from "../../../interactive/payload.js"; +export { + adaptMessagePresentationForChannel, + applyPresentationActionLimits, + presentationPageSize, +} from "./presentation-limits.js"; export function reduceInteractiveReply( interactive: InteractiveReply | undefined, diff --git a/src/channels/plugins/outbound/presentation-limits.ts b/src/channels/plugins/outbound/presentation-limits.ts new file mode 100644 index 00000000000..d71d11da6d0 --- /dev/null +++ b/src/channels/plugins/outbound/presentation-limits.ts @@ -0,0 +1,533 @@ +import type { + MessagePresentation, + MessagePresentationBlock, + MessagePresentationButton, + MessagePresentationOption, +} from "../../../interactive/payload.js"; +import type { ChannelPresentationCapabilities } from "../outbound.types.js"; + +type ActionLimits = NonNullable["actions"]>; +type SelectLimits = NonNullable["selects"]>; +type TextLimits = NonNullable["text"]>; +type ActionBudget = { + remainingActions?: number; + remainingRows?: number; + maxActionsPerRow?: number; +}; +type ButtonCandidate = { + original: MessagePresentationButton; + adapted?: MessagePresentationButton; +}; +type SelectCandidate = { + original: MessagePresentationOption; + adapted?: MessagePresentationOption; +}; +type ButtonSelection = ReadonlySet | 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, + 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(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, + 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, + 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(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)); +} diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 738433d831f..cfa79eb439a 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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( diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 410147e0565..b788bd84ceb 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -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; + 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, }), }; } diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 17e4439131e..030b2088288 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -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); diff --git a/src/plugin-sdk/interactive-runtime.ts b/src/plugin-sdk/interactive-runtime.ts index 8d1008ecea2..3f1f2ec5ce1 100644 --- a/src/plugin-sdk/interactive-runtime.ts +++ b/src/plugin-sdk/interactive-runtime.ts @@ -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, diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index 889375ede8c..28fcadb5dd2 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -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;