mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 14:14:45 +00:00
feat: add presentation capability limits
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
533
src/channels/plugins/outbound/presentation-limits.ts
Normal file
533
src/channels/plugins/outbound/presentation-limits.ts
Normal 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));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user