From 81a201df267be0b7e16e49c407c093622dcbd622 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 22:29:13 -0700 Subject: [PATCH] feat: add portable table presentation blocks (#103583) * feat(presentation): add portable table blocks * chore(presentation): refresh generated contracts * fix(slack): preserve table fallback payloads * docs(changelog): note portable message tables * fix(presentation): preserve cross-channel fallback delivery * chore(plugin-sdk): refresh rebased contracts * test(slack): align accessibility expectations * fix(presentation): harden cross-channel fallback delivery * chore(plugin-sdk): refresh rebased contracts * docs(changelog): defer portable table release note * fix(presentation): satisfy extension lint * chore(plugin-sdk): refresh surface budgets * fix(telegram): preserve reactions after progress replies * fix(slack): preserve rendered preview fallback text * fix(feishu): preserve oversized presentation fallbacks * docs(changelog): note portable message tables * docs(changelog): defer portable table release note * chore(plugin-sdk): refresh rebased contracts --------- Co-authored-by: Peter Steinberger --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/slack.md | 72 +- docs/cli/message.md | 12 +- docs/docs_map.md | 1 + docs/plugins/architecture-internals.md | 3 +- docs/plugins/message-presentation.md | 83 +- .../discord/src/actions/handle-action.test.ts | 31 + .../discord/src/actions/handle-action.ts | 50 +- .../discord/src/outbound-adapter.test.ts | 84 +- extensions/discord/src/outbound-components.ts | 66 ++ extensions/feishu/src/channel.test.ts | 63 ++ extensions/feishu/src/channel.ts | 48 +- .../feishu/src/outbound-delivery.test.ts | 104 ++ extensions/feishu/src/outbound.test.ts | 465 ++++++++- extensions/feishu/src/outbound.ts | 302 ++++-- .../feishu/src/presentation-card.test.ts | 52 + extensions/feishu/src/presentation-card.ts | 55 ++ extensions/signal/src/channel.ts | 4 +- extensions/signal/src/core.test.ts | 62 ++ .../monitor.approval-reply-delivery.test.ts | 112 ++- extensions/signal/src/monitor.ts | 6 +- .../signal/src/presentation-fallback.ts | 41 + extensions/slack/src/action-runtime.test.ts | 83 ++ extensions/slack/src/action-runtime.ts | 47 +- extensions/slack/src/actions.blocks.test.ts | 242 ++++- extensions/slack/src/actions.ts | 42 +- extensions/slack/src/blocks-fallback.ts | 395 ++++++-- extensions/slack/src/blocks-render.ts | 123 ++- extensions/slack/src/blocks.test.ts | 21 + .../slack/src/channel.message-adapter.test.ts | 4 +- extensions/slack/src/channel.test.ts | 8 +- extensions/slack/src/client-delivery.ts | 49 +- extensions/slack/src/data-table.test.ts | 145 +++ extensions/slack/src/data-table.ts | 322 +++++++ .../slack/src/data-visualization.test.ts | 44 - extensions/slack/src/data-visualization.ts | 43 +- extensions/slack/src/edit-text.ts | 17 +- extensions/slack/src/format.ts | 142 +++ extensions/slack/src/limits.ts | 1 + .../slack/src/message-action-dispatch.test.ts | 293 +++++- .../slack/src/message-action-dispatch.ts | 67 +- .../slack/src/monitor/block-text.test.ts | 27 +- extensions/slack/src/monitor/block-text.ts | 15 +- .../dispatch.preview-fallback.test.ts | 190 +++- .../src/monitor/message-handler/dispatch.ts | 47 +- .../message-handler/preview-finalize.test.ts | 40 +- .../message-handler/preview-finalize.ts | 18 +- extensions/slack/src/monitor/replies.test.ts | 732 +++++++++++++- extensions/slack/src/monitor/replies.ts | 339 +++++-- extensions/slack/src/monitor/slash.ts | 14 +- .../slack/src/native-data-blocks.test.ts | 98 ++ extensions/slack/src/native-data-blocks.ts | 129 +++ extensions/slack/src/outbound-adapter.test.ts | 35 +- extensions/slack/src/outbound-adapter.ts | 287 +++--- extensions/slack/src/outbound-payload.test.ts | 395 +++++++- extensions/slack/src/presentation-fallback.ts | 143 +++ extensions/slack/src/presentation.ts | 1 + extensions/slack/src/reply-blocks.test.ts | 809 ++++++++++++++++ extensions/slack/src/reply-blocks.ts | 320 ++++++- extensions/slack/src/send.blocks.test.ts | 905 +++++++++++++++++- extensions/slack/src/send.ts | 273 +++++- .../slack/src/shared-interactive.test.ts | 88 +- extensions/slack/src/shared.ts | 1 + .../telegram/src/action-runtime.test.ts | 33 + extensions/telegram/src/action-runtime.ts | 9 +- .../telegram/src/bot-message-dispatch.test.ts | 134 ++- .../telegram/src/bot-message-dispatch.ts | 4 +- .../telegram/src/bot-native-commands.test.ts | 86 ++ .../telegram/src/bot-native-commands.ts | 31 +- .../telegram/src/bot/delivery.replies.ts | 7 +- extensions/telegram/src/bot/delivery.test.ts | 64 +- .../telegram/src/interactive-fallback.test.ts | 163 ++++ .../telegram/src/interactive-fallback.ts | 171 +++- .../telegram/src/outbound-adapter.test.ts | 25 +- extensions/telegram/src/outbound-adapter.ts | 71 +- scripts/plugin-sdk-surface-report.mjs | 4 +- src/agents/runtime-plan/types.ts | 13 +- src/agents/tools/message-tool.test.ts | 51 + src/agents/tools/message-tool.ts | 46 +- src/channels/plugins/outbound.types.ts | 2 + .../plugins/outbound/interactive.test.ts | 81 ++ .../plugins/outbound/presentation-limits.ts | 56 +- src/cli/program/message/register.send.ts | 2 +- src/infra/outbound/payloads.test.ts | 53 + src/infra/outbound/payloads.ts | 13 +- src/interactive/payload.test.ts | 98 ++ src/interactive/payload.ts | 102 +- src/plugin-sdk/interactive-runtime.ts | 3 + 88 files changed, 9134 insertions(+), 902 deletions(-) create mode 100644 extensions/feishu/src/outbound-delivery.test.ts create mode 100644 extensions/feishu/src/presentation-card.test.ts create mode 100644 extensions/signal/src/presentation-fallback.ts create mode 100644 extensions/slack/src/data-table.test.ts create mode 100644 extensions/slack/src/data-table.ts create mode 100644 extensions/slack/src/native-data-blocks.test.ts create mode 100644 extensions/slack/src/native-data-blocks.ts create mode 100644 extensions/slack/src/presentation-fallback.ts create mode 100644 extensions/slack/src/reply-blocks.test.ts create mode 100644 extensions/telegram/src/interactive-fallback.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index bf0d14ee7028..5f133fdbd544 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -b482ccb7d9cc8c9d44e85c534d8ac41f5175d2d872c48bb6b7a5273453fc0eb6 plugin-sdk-api-baseline.json -3fc939035d0a04a7ddb3aded363286415fac094b14741a4eeb39a3a88ee7bae6 plugin-sdk-api-baseline.jsonl +4159cf7a45b5af031a57f23c7a5ab357bb433dfe5221a4116ec50ce6c877678c plugin-sdk-api-baseline.json +84f72e8fc201c7c269b7f687b0182ba48774a34b4e70c99253688a2b5f440b53 plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 2f3d5674a604..97688a67f9d2 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1546,7 +1546,8 @@ readers, notifications, session mirroring, and clients that cannot render the block. Standard presentation sends to other OpenClaw channels receive that same deterministic chart data as text unless they advertise native chart support. If Slack rejects the chart with `invalid_blocks` during a phased rollout, OpenClaw -retries once with the text representation and no blocks. +retries once with the native chart replaced by visible mrkdwn fallback sections; +valid sibling blocks remain intact. Slack currently accepts up to two `data_visualization` blocks per message. When a presentation contains more than two valid charts, OpenClaw renders the first @@ -1560,6 +1561,75 @@ Slackbot's automatic AI chart generation, which is separate from an app sending an already-structured Block Kit chart. Charts are message-only blocks, not App Home, modal, or Canvas content. +## Native tables + +Slack's current [`data_table` Block Kit block](https://docs.slack.dev/reference/block-kit/blocks/data-table-block/) +renders structured rows and columns in messages. OpenClaw maps an explicit +portable `presentation` `table` block to `data_table`; it does not use Slack's +legacy [`table` block](https://docs.slack.dev/reference/block-kit/blocks/table-block/). +No additional OAuth scope or Slack configuration is required beyond normal +`chat:write` message access. + +```json +{ + "blocks": [ + { + "type": "table", + "caption": "Open pipeline", + "headers": ["Account", "Stage", "ARR"], + "rows": [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000] + ], + "rowHeaderColumnIndex": 0 + } + ] +} +``` + +OpenClaw maps header and string cells to Slack `raw_text` cells. Numeric cells +map to `raw_number`, with the finite numeric value preserved for native sorting +and filtering. `rowHeaderColumnIndex`, when present, marks that zero-based +column as Slack row headers; when omitted, Slack defaults to column `0`. + +Slack's published `data_table` limits are enforced before native rendering: + +- 1-20 columns +- 1-100 data rows, plus the header row +- the same number of cells in every row +- at most 10,000 aggregate characters across all table cells in one message + +Multiple valid table blocks can render natively while the message remains +within the aggregate character limit. A table that cannot render within the +native envelope becomes complete deterministic text instead of losing rows or +cells. If that text exceeds one OpenClaw Slack text chunk, sends and slash +responses use ordered chunks. Table edits fail with an explicit size error +instead of silently truncating rows from an existing message. + +Slack permits at most [five messages from one interaction `response_url`](https://docs.slack.dev/interactivity/handling-user-interaction/#message_responses). +OpenClaw shares that budget across streamed slash payloads, accounts for a +possible native-block retry, and returns size guidance before publishing a +partial table. Send exceptionally large results as a regular channel message +instead. + +Every native table produced from portable presentation also carries a top-level +text representation for screen readers, notifications, session mirroring, and +clients that cannot render the block. Raw chart and table values stay literal +in that mrkdwn fallback, so cell data such as `<@U123>` does not become a Slack +mention. If Slack rejects native chart or table blocks with `invalid_blocks`, +OpenClaw retries once with those native blocks replaced by visible mrkdwn +fallback sections. A genuinely invalid sibling block still fails closed. Table +and chart sends preserve the complete representation through ordered preflight +chunks whenever the top-level accessibility text exceeds one Slack post. + +Only explicit `presentation` table blocks are promoted to native tables. +Markdown pipe tables remain authored text; OpenClaw does not guess at table +structure or cell types. Existing trusted Slack-native producers can continue +to pass raw blocks through `channelData.slack.blocks`; OpenClaw derives fallback +text from valid raw `data_table` cells, while malformed custom blocks may +degrade to their caption or general Block Kit fallback. Portable agent, CLI, +and plugin output should use `presentation`. + ## Interactive replies Slack can render agent-authored interactive reply controls, but this feature is disabled by default. diff --git a/docs/cli/message.md b/docs/cli/message.md index d6c471c0e87b..77dc8025ee46 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -91,8 +91,8 @@ openclaw message send --channel discord \ - `--media `: attach image/audio/video/document (local path or URL). - `--presentation `: shared payload with `text`, `context`, `divider`, - `chart`, `buttons`, and `select` blocks, rendered per channel capability. See - [Message Presentation](/plugins/message-presentation). + `chart`, `table`, `buttons`, and `select` blocks, rendered per channel + capability. See [Message Presentation](/plugins/message-presentation). - `--delivery `: generic delivery preferences, for example `{"pin": true}`. `--pin` is shorthand for pinned delivery when the channel supports it. @@ -122,6 +122,14 @@ openclaw message send --channel slack --target channel:C123 \ --presentation '{"blocks":[{"type":"chart","chartType":"bar","title":"Quarterly revenue","categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[120,145]}],"xLabel":"Quarter"}]}' ``` +Slack also renders explicit table blocks natively. Other channels receive the +caption and every row as deterministic text: + +```bash +openclaw message send --channel slack --target channel:C123 \ + --presentation '{"title":"Pipeline report","blocks":[{"type":"table","caption":"Open pipeline","headers":["Account","Stage","ARR"],"rows":[["Acme","Won",125000],["Globex","Review",82000]],"rowHeaderColumnIndex":0}]}' +``` + Telegram Mini App buttons use `webApp` (`web_app` still parses for legacy JSON) and only render in private chats between a user and the bot: diff --git a/docs/docs_map.md b/docs/docs_map.md index 8a88ed08533b..14fb56264157 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -862,6 +862,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Media, chunking, and delivery - H2: Commands and slash behavior - H2: Native charts + - H2: Native tables - H2: Interactive replies - H3: Plugin-owned modal submissions - H2: Native approvals in Slack diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index cd4399f6e373..9e1fd8afb621 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -738,7 +738,8 @@ fallback rules, provider mapping, and plugin author checklist. Send-capable plugins declare what they can render through message capabilities: -- `presentation` for semantic presentation blocks (`text`, `context`, `divider`, `chart`, `buttons`, `select`) +- `presentation` for semantic presentation blocks (`text`, `context`, + `divider`, `chart`, `table`, `buttons`, `select`) - `delivery-pin` for pinned-delivery requests Core decides whether to render the presentation natively or degrade it to text. diff --git a/docs/plugins/message-presentation.md b/docs/plugins/message-presentation.md index 0e6e0b79b8ce..173febcc2f33 100644 --- a/docs/plugins/message-presentation.md +++ b/docs/plugins/message-presentation.md @@ -1,8 +1,8 @@ --- -summary: "Semantic message cards, charts, controls, fallback text, and delivery hints for channel plugins" +summary: "Semantic message cards, charts, tables, controls, fallback text, and delivery hints for channel plugins" title: "Message presentation" read_when: - - Adding or modifying message card, chart, button, or select rendering + - Adding or modifying message card, chart, table, button, or select rendering - Building a channel plugin that supports rich outbound messages - Changing message tool presentation or delivery capabilities - Debugging provider-specific card/block/component rendering regressions @@ -13,7 +13,7 @@ It lets agents, CLI commands, approval flows, and plugins describe the message intent once, while each channel plugin renders the best native shape it can. Use presentation for portable message UI: text sections, small context/footer -text, dividers, charts, buttons, select menus, and card title/tone. +text, dividers, charts, tables, buttons, select menus, and card title/tone. Do not add new provider-native fields such as Discord `components`, Slack `blocks`, Telegram `buttons`, Teams `card`, or Feishu `card` to the shared @@ -59,6 +59,13 @@ type MessagePresentationBlock = series: Array<{ name: string; values: number[] }>; xLabel?: string; yLabel?: string; + } + | { + type: "table"; + caption: string; + headers: string[]; + rows: Array>; + rowHeaderColumnIndex?: number; }; type MessagePresentationAction = @@ -147,6 +154,32 @@ Chart semantics: Other channels receive the chart title, axes, categories, series, and values as deterministic text. This is also the accessibility fallback. +Table semantics: + +- `caption` is a required short heading. `headers` must contain at least one + unique, non-empty column label. +- `rows` must contain at least one row. Every row must have exactly one cell per + header, and every cell must be a non-empty string or a finite number. +- `rowHeaderColumnIndex` is an optional zero-based index identifying the column + whose cells should be exposed as row headers by native renderers. +- Table normalization is atomic. An invalid caption, header, row width, cell, + or row-header index drops the table block instead of truncating or repairing + its data. +- Native table rendering is opt-in through `presentationCapabilities.tables`. + Other channels receive the caption and every row as deterministic linear + text, with internal whitespace collapsed: + + ```text + Open pipeline (table) + - Account: Acme; Stage: Won; ARR: 125000 + - Account: Globex; Stage: Review; ARR: 82000 + ``` + +There is no separate `report` discriminator. Compose a report from `title`, +`tone`, `text`, `context`, `chart`, `table`, and action blocks. This keeps each +block independently renderable and gives the complete report the same +deterministic text fallback. + ## Producer examples Simple card: @@ -235,6 +268,29 @@ Chart: } ``` +Table report: + +```json +{ + "title": "Pipeline report", + "tone": "info", + "blocks": [ + { "type": "text", "text": "Current opportunities by stage." }, + { + "type": "table", + "caption": "Open pipeline", + "headers": ["Account", "Stage", "ARR"], + "rows": [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000] + ], + "rowHeaderColumnIndex": 0 + }, + { "type": "context", "text": "Updated from the CRM snapshot." } + ] +} +``` + CLI send: ```bash @@ -279,6 +335,7 @@ const adapter: ChannelOutboundAdapter = { context: true, divider: true, charts: false, + tables: false, limits: { actions: { maxActions: 25, @@ -325,6 +382,7 @@ type ChannelPresentationCapabilities = { context?: boolean; divider?: boolean; charts?: boolean; + tables?: boolean; limits?: { actions?: { maxActions?: number; @@ -366,8 +424,9 @@ On the canonical outbound path used by CLI and standard message actions, core: 2. Resolves the target channel's outbound adapter. 3. Reads `presentationCapabilities`. 4. Applies generic capability limits such as action count, label length, and - select option count when the adapter advertises them. Chart blocks become - deterministic text unless the adapter explicitly advertises `charts: true`. + select option count when the adapter advertises them. Chart and table blocks + become deterministic text unless the adapter explicitly advertises + `charts: true` or `tables: true`, respectively. 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. @@ -394,6 +453,7 @@ Fallback text includes: - button labels, including URLs for link buttons - select option labels - chart title, type, axes, categories, series, and values +- table caption, headers, and every row value ### Button value fallback visibility @@ -421,6 +481,7 @@ Examples: - Telegram with inline buttons disabled sends text fallback. - A channel without select support lists select options as text. - A channel without native chart support lists the chart data as text. +- A channel without native table support lists every table row as text. - A URL-only button becomes either a native link button or a fallback URL line. - Optional pin failures do not fail the delivered message. @@ -438,7 +499,7 @@ Current bundled renderers: | Matrix | Text fallback plus structured event field | Buttons/selects advertise as supported, but every block currently renders as `renderMessagePresentationFallbackText` output carried in a `com.openclaw.presentation` event field, not native interactive widgets. | | Mattermost | Text plus interactive props | Selects and dividers are not supported; those blocks degrade to text. | | Microsoft Teams | Adaptive Cards | Plain `message` text is included with the card when both are provided. Selects, styles, and disabled state are not supported. | -| Slack | Block Kit | Renders `chart` as native `data_visualization`; preserves legacy `channelData.slack.blocks`, but new shared sends should use `presentation`. | +| Slack | Block Kit | Renders `chart` as native `data_visualization` and `table` as native `data_table`; preserves legacy `channelData.slack.blocks`, but new shared sends should use `presentation`. | | Telegram | Text plus inline keyboards | Buttons/selects require inline button capability for the target surface; otherwise text fallback is used. | | Plain channels | Text fallback | Channels without a renderer still get readable output. | @@ -461,6 +522,7 @@ helpers. It supports: - context - divider - chart +- table - URL-only buttons - generic delivery metadata through `ReplyPayload.delivery` @@ -480,6 +542,7 @@ import { presentationToInteractiveReply, renderMessagePresentationChartFallbackText, renderMessagePresentationFallbackText, + renderMessagePresentationTableFallbackText, resolveMessagePresentationActionValue, resolveMessagePresentationControlValue, } from "openclaw/plugin-sdk/interactive-runtime"; @@ -500,6 +563,9 @@ Non-deprecated helpers worth knowing: `resolveMessagePresentationControlValue(control)` read the effective command/callback value off an `action`, falling back to the legacy `value` field for `resolveMessagePresentationControlValue`. +- `renderMessagePresentationChartFallbackText(block)` / + `renderMessagePresentationTableFallbackText(block)` render one structured + data block as deterministic text for channel-specific fallback paths. The legacy `InteractiveReply*` types and conversion helpers are marked `@deprecated` in the SDK: @@ -563,8 +629,9 @@ messages where the provider supports those operations. - 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 charts, buttons, selects, URL buttons, - title/text duplication, and mixed `message` plus `presentation` sends. +- Add fallback tests for unsupported charts, tables, buttons, selects, URL + buttons, title/text duplication, and mixed `message` plus `presentation` + sends. - Add delivery pin support through `deliveryCapabilities.pin` and `pinDeliveredMessage` only when the provider can pin the sent message id. - Do not expose new provider-native card/block/component/button fields through diff --git a/extensions/discord/src/actions/handle-action.test.ts b/extensions/discord/src/actions/handle-action.test.ts index 9e6f91442e8a..b15f445da4ea 100644 --- a/extensions/discord/src/actions/handle-action.test.ts +++ b/extensions/discord/src/actions/handle-action.test.ts @@ -688,6 +688,37 @@ describe("handleDiscordMessageAction", () => { }); }); + it("downgrades oversized table presentations to complete text", async () => { + const cfg = discordConfig(); + + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + presentation: { + blocks: [ + { + type: "table", + caption: "Large pipeline", + headers: ["Account", "Stage"], + rows: Array.from({ length: 900 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(80)}`, + "Review", + ]), + }, + ], + }, + }, + cfg, + }); + + const [call] = handleDiscordActionMock.mock.calls; + const payload = call?.[0] as Record | undefined; + expect(payload?.components).toBeUndefined(); + expect(payload?.content).toEqual(expect.stringContaining("account-0-")); + expect(payload?.content).toEqual(expect.stringContaining("account-899-")); + }); + it("does not use another provider's current target for Discord sends", async () => { await expect( handleDiscordMessageAction({ diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 646389c49401..3f908fa17883 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -12,11 +12,15 @@ import { adaptMessagePresentationForChannel, normalizeInteractiveReply, normalizeMessagePresentation, + renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime"; import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime"; import { handleDiscordAction } from "../../action-runtime-api.js"; import { notifyDiscordInboundEventOutboundSuccess } from "../inbound-event-delivery.js"; -import { DISCORD_PRESENTATION_CAPABILITIES } from "../outbound-components.js"; +import { + DISCORD_PRESENTATION_CAPABILITIES, + isDiscordComponentSpecWithinMessageLimit, +} from "../outbound-components.js"; import { buildDiscordInteractiveComponents, buildDiscordPresentationComponents, @@ -117,6 +121,12 @@ export async function handleDiscordMessageAction( if (action === "send") { const to = readSendTarget(); const asVoice = readBooleanParam(params, "asVoice") === true; + // Support media, path, and filePath for media URL + const mediaUrl = + readStringParam(params, "media", { trim: false }) ?? + readStringParam(params, "path", { trim: false }) ?? + readStringParam(params, "filePath", { trim: false }); + const requestedContent = readStringParam(params, "message", { allowEmpty: true }); const presentation = params.components == null ? normalizeMessagePresentation(params.presentation) : undefined; const adaptedPresentation = presentation @@ -125,23 +135,39 @@ export async function handleDiscordMessageAction( capabilities: DISCORD_PRESENTATION_CAPABILITIES, }) : undefined; - const rawComponents = - params.components ?? - buildDiscordPresentationComponents(adaptedPresentation) ?? - buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive)); + const generatedPresentationComponents = buildDiscordPresentationComponents(adaptedPresentation); + const presentationComponents = + generatedPresentationComponents && + isDiscordComponentSpecWithinMessageLimit({ + spec: generatedPresentationComponents, + fallbackText: requestedContent, + includesMedia: Boolean(mediaUrl), + }) + ? generatedPresentationComponents + : undefined; + const presentationFellBack = Boolean( + generatedPresentationComponents && !presentationComponents, + ); + const rawComponents = presentationFellBack + ? undefined + : (params.components ?? + presentationComponents ?? + buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive))); const hasComponents = Boolean(rawComponents) && (typeof rawComponents === "function" || typeof rawComponents === "object"); const components = hasComponents ? rawComponents : undefined; - // Support media, path, and filePath for media URL - const mediaUrl = - readStringParam(params, "media", { trim: false }) ?? - readStringParam(params, "path", { trim: false }) ?? - readStringParam(params, "filePath", { trim: false }); const content = readStringParam(params, "message", { - required: !asVoice && !hasComponents && !mediaUrl, + required: !asVoice && !hasComponents && !mediaUrl && !presentationFellBack, allowEmpty: true, }); + const deliveryContent = + presentationFellBack && adaptedPresentation + ? renderMessagePresentationFallbackText({ + text: content, + presentation: adaptedPresentation, + }) + : content; const filename = readStringParam(params, "filename"); const replyTo = readStringParam(params, "replyTo"); const rawEmbeds = params.embeds; @@ -156,7 +182,7 @@ export async function handleDiscordMessageAction( action: "sendMessage", accountId: accountId ?? undefined, to, - content: content ?? "", + content: deliveryContent ?? "", ...(threadName ? { threadName } : {}), mediaUrl: mediaUrl ?? undefined, filename: filename ?? undefined, diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 7756820a59e4..f2cca625f29e 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -1,5 +1,8 @@ // Discord tests cover outbound adapter plugin behavior. -import { adaptMessagePresentationForChannel } from "openclaw/plugin-sdk/interactive-runtime"; +import { + adaptMessagePresentationForChannel, + renderMessagePresentationFallbackText, +} from "openclaw/plugin-sdk/interactive-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createDiscordOutboundHoisted, @@ -866,6 +869,85 @@ describe("discordOutbound", () => { }); }); + it("falls back to chunked text when a table exceeds the Discord component envelope", async () => { + const table = { + type: "table" as const, + caption: "Large pipeline", + headers: ["Account", "Stage"], + rows: Array.from({ length: 900 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(80)}`, + "Review", + ]), + }; + const presentation = adaptMessagePresentationForChannel({ + presentation: { + blocks: [ + table, + { + type: "buttons", + buttons: [{ label: "Continue", action: { type: "command", command: "/continue" } }], + }, + ], + }, + capabilities: discordOutbound.presentationCapabilities, + }); + + const rendered = await discordOutbound.renderPresentation?.({ + payload: {}, + presentation, + ctx: { cfg: {}, to: "channel:123456" }, + } as never); + const fallbackText = renderMessagePresentationFallbackText({ presentation }); + await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: fallbackText, + payload: { text: fallbackText }, + accountId: "default", + }); + const textChunks = hoisted.sendMessageDiscordMock.mock.calls.map((call) => String(call[1])); + const deliveredText = textChunks.join("\n"); + + expect(presentation.blocks.length).toBeGreaterThan(40); + expect(rendered).toBeNull(); + expect(hoisted.sendDiscordComponentMessageMock).not.toHaveBeenCalled(); + expect(textChunks.length).toBeGreaterThan(1); + expect(deliveredText).toContain("account-0-"); + expect(deliveredText).toContain("account-899-"); + expect(deliveredText).toContain("Continue: `/continue`"); + }); + + it("counts nested Discord components against the 40-component limit", async () => { + const buttons = Array.from({ length: 25 }, (_entry, index) => ({ + label: `Action ${String(index)}`, + value: `action-${String(index)}`, + })); + const buildPresentation = (textBlockCount: number) => ({ + title: "At limit", + blocks: [ + ...Array.from({ length: textBlockCount }, (_entry, index) => ({ + type: "text" as const, + text: `Detail ${String(index)}`, + })), + { type: "buttons" as const, buttons }, + ], + }); + + const atLimit = await discordOutbound.renderPresentation?.({ + payload: {}, + presentation: buildPresentation(8), + ctx: { cfg: {}, to: "channel:123456" }, + } as never); + const overLimit = await discordOutbound.renderPresentation?.({ + payload: {}, + presentation: buildPresentation(9), + ctx: { cfg: {}, to: "channel:123456" }, + } as never); + + expect(atLimit).not.toBeNull(); + expect(overLimit).toBeNull(); + }); + it("keeps replyToId on every internal component media send when replyToMode is all", async () => { const payload = await discordOutbound.renderPresentation?.({ payload: { diff --git a/extensions/discord/src/outbound-components.ts b/extensions/discord/src/outbound-components.ts index 0daeed21f861..8b95929f0d68 100644 --- a/extensions/discord/src/outbound-components.ts +++ b/extensions/discord/src/outbound-components.ts @@ -10,6 +10,7 @@ type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordC type OutboundPayload = Parameters>[0]["payload"]; export const DISCORD_PRESENTATION_TEXT_LIMIT = 2000; +const DISCORD_MESSAGE_COMPONENT_LIMIT = 40; export const DISCORD_PRESENTATION_CAPABILITIES = { supported: true, @@ -68,6 +69,57 @@ function addPayloadTextFallback( }; } +function countDiscordComponentBlock( + block: NonNullable[number], +) { + if (block.type === "section") { + const textCount = block.texts?.length ? block.texts.length : block.text ? 1 : 0; + return 1 + textCount + (block.accessory ? 1 : 0); + } + if (block.type === "actions") { + return 1 + (block.buttons?.length ?? (block.select ? 1 : 0)); + } + return 1; +} + +function countDiscordMessageComponents(params: { + spec: DiscordComponentMessageSpec; + includesMedia: boolean; +}): number { + const blocks = params.spec.blocks ?? []; + let count = 1 + (params.spec.text ? 1 : 0); + for (const block of blocks) { + count += countDiscordComponentBlock(block); + } + + if (params.spec.modal) { + const lastBlock = blocks.at(-1); + const triggerFitsLastRow = + lastBlock?.type === "actions" && !lastBlock.select && (lastBlock.buttons?.length ?? 0) < 5; + count += triggerFitsLastRow ? 1 : 2; + } + + const hasFileBlock = blocks.some((block) => block.type === "file"); + if (params.includesMedia && !hasFileBlock) { + count += 1; + } + return count; +} + +export function isDiscordComponentSpecWithinMessageLimit(params: { + spec: DiscordComponentMessageSpec; + fallbackText?: string; + includesMedia?: boolean; +}): boolean { + const countedSpec = addPayloadTextFallback(params.spec, { text: params.fallbackText }); + return ( + countDiscordMessageComponents({ + spec: countedSpec, + includesMedia: params.includesMedia === true, + }) <= DISCORD_MESSAGE_COMPONENT_LIMIT + ); +} + export async function buildDiscordPresentationPayload(params: { payload: Parameters>[0]["payload"]; presentation: Parameters< @@ -80,6 +132,20 @@ export async function buildDiscordPresentationPayload(params: { if (!componentSpec) { return null; } + const includesMedia = Boolean( + params.payload.mediaUrl || params.payload.mediaUrls?.some((mediaUrl) => mediaUrl), + ); + // Discord counts the container and every nested child toward the 40-component + // envelope. Let core use normal text chunking rather than submit an invalid V2 tree. + if ( + !isDiscordComponentSpecWithinMessageLimit({ + spec: componentSpec, + fallbackText: params.payload.text, + includesMedia, + }) + ) { + return null; + } return { ...params.payload, channelData: { diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 7c721a90d85c..535ea310cbb0 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -40,6 +40,7 @@ const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn()); const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn()); const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn()); const feishuOutboundSendMediaMock = vi.hoisted(() => vi.fn()); +const feishuOutboundSendPayloadMock = vi.hoisted(() => vi.fn()); vi.mock("./probe.js", () => ({ probeFeishu: probeFeishuMock, @@ -72,6 +73,7 @@ vi.mock("./channel.runtime.js", () => ({ feishuOutbound: { sendText: vi.fn(), sendMedia: feishuOutboundSendMediaMock, + sendPayload: feishuOutboundSendPayloadMock, }, }, })); @@ -641,6 +643,67 @@ describe("feishuPlugin actions", () => { expect(details.chatId).toBe("oc_group_1"); }); + it("hides prefixed native-card JSON in oversized presentation fallbacks", async () => { + feishuOutboundSendPayloadMock.mockResolvedValueOnce({ + channel: "feishu", + messageId: "om_fallback", + chatId: "oc_group_1", + }); + const presentation = { + blocks: [ + { + type: "table" as const, + caption: "Large pipeline", + headers: ["Account", "Stage"], + rows: Array.from({ length: 400 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(80)}`, + "Review", + ]), + }, + ], + }; + const rawCardText = JSON.stringify({ + schema: "2.0", + body: { elements: [{ tag: "markdown", content: "Raw card JSON must stay hidden" }] }, + }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { + to: "chat:oc_group_1", + message: `[Nexus] ${rawCardText}`, + presentation, + media: "/tmp/pipeline.png", + }, + cfg: { + ...cfg, + messages: { responsePrefix: "[Nexus]" }, + }, + accountId: undefined, + toolContext: {}, + mediaLocalRoots: ["/tmp"], + } as never); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(feishuOutboundSendPayloadMock).toHaveBeenCalledTimes(1); + const fallbackArgs = requireRecord( + mockCallArg(feishuOutboundSendPayloadMock, 0, 0, "feishuOutbound.sendPayload"), + "fallback args", + ); + const fallbackPayload = requireRecord(fallbackArgs.payload, "fallback payload"); + const fallbackPresentation = requireRecord( + fallbackPayload.presentation, + "fallback presentation", + ); + const fallbackBlocks = requireArray(fallbackPresentation.blocks, "fallback blocks"); + const table = requireRecord(fallbackBlocks[0], "fallback table"); + const rows = requireArray(table.rows, "fallback rows"); + expect(requireArray(rows.at(-1), "last fallback row")[0]).toContain("account-399-"); + expect(fallbackPayload.mediaUrl).toBe("/tmp/pipeline.png"); + expect(fallbackPayload.text).toBeUndefined(); + expect(fallbackArgs.text).toBe(""); + }); + it("prefers structured presentation over raw card JSON text", async () => { sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 8bdc03013269..13f682ad3465 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -89,7 +89,11 @@ import { feishuDoctor } from "./doctor.js"; import { messageActionTargetAliases } from "./message-action-contract.js"; import { readNativeFeishuCardJson } from "./native-card.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; -import { buildFeishuPresentationCard } from "./presentation-card.js"; +import { + assertFeishuCardWithinEnvelope, + buildFeishuPresentationCard, + isFeishuCardWithinEnvelope, +} from "./presentation-card.js"; import { assertFeishuChatReadAllowed, authorizeFeishuChatMemberRead, @@ -1077,18 +1081,27 @@ export const feishuPlugin: ChannelPlugin vi.fn()); +const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./media.js", () => ({ + sendMediaFeishu: sendMediaFeishuMock, + shouldSuppressFeishuTextForVoiceMedia: () => false, +})); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, + sendMarkdownCardFeishu: vi.fn(), + sendMessageFeishu: sendMessageFeishuMock, + sendStructuredCardFeishu: vi.fn(), +})); + +import { feishuOutbound } from "./outbound.js"; + +describe("Feishu outbound shared delivery", () => { + beforeEach(() => { + let textMessageIndex = 0; + sendMediaFeishuMock.mockReset().mockResolvedValue({ + messageId: "media-1", + chatId: "chat_1", + }); + sendMessageFeishuMock.mockReset().mockImplementation(async () => ({ + messageId: `text-${String(++textMessageIndex)}`, + chatId: "chat_1", + })); + sendCardFeishuMock.mockReset().mockResolvedValue({ + messageId: "card-1", + chatId: "chat_1", + }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "feishu", + plugin: createOutboundTestPlugin({ id: "feishu", outbound: feishuOutbound }), + source: "test", + }, + ]), + ); + resetGlobalHookRunner(); + }); + + afterEach(() => { + resetGlobalHookRunner(); + releasePinnedPluginChannelRegistry(); + }); + + it("routes oversized presentation media through one media send and chunked fallback text", async () => { + await deliverOutboundPayloads({ + cfg: {}, + channel: "feishu", + to: "chat_1", + skipQueue: true, + payloads: [ + { + mediaUrl: "https://example.com/pipeline.png", + presentation: { + blocks: [ + { + type: "table", + caption: "Large pipeline", + headers: ["Account", "Stage"], + rows: Array.from({ length: 400 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(80)}`, + "Review", + ]), + }, + ], + }, + }, + ], + }); + + const textChunks = sendMessageFeishuMock.mock.calls.map((call) => { + const text = (call[0] as { text?: unknown } | undefined)?.text; + return typeof text === "string" ? text : ""; + }); + const deliveredText = textChunks.join("\n"); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ mediaUrl: "https://example.com/pipeline.png", to: "chat_1" }), + ); + expect(textChunks.length).toBeGreaterThan(1); + expect(textChunks.every((chunk) => Array.from(chunk).length <= 4000)).toBe(true); + expect(deliveredText).toContain("account-0-"); + expect(deliveredText).toContain("account-399-"); + }); +}); diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index fd96cc6b082b..b2ffec6c0109 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -3,7 +3,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound"; -import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; +import { + adaptMessagePresentationForChannel, + renderMessagePresentationFallbackText, + type MessagePresentation, +} from "openclaw/plugin-sdk/interactive-runtime"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../runtime-api.js"; @@ -136,6 +140,42 @@ const cardRenderConfig: ClawdbotConfig = { }, }; +function createOversizedTablePresentation() { + return adaptMessagePresentationForChannel({ + presentation: { + blocks: [ + { + type: "table", + caption: "Large pipeline", + headers: ["Account", "Stage"], + rows: Array.from({ length: 400 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(80)}`, + "Review", + ]), + }, + ], + }, + capabilities: feishuOutbound.presentationCapabilities, + }); +} + +function createElementLimitedCommandPresentation(): MessagePresentation { + return { + blocks: [ + ...Array.from({ length: 200 }, () => ({ type: "divider" as const })), + { + type: "buttons", + buttons: [ + { + label: "Approve", + action: { type: "command", command: "/approve req_1" }, + }, + ], + }, + ], + }; +} + afterAll(() => { vi.doUnmock("./media.js"); vi.doUnmock("./send.js"); @@ -587,6 +627,361 @@ describe("feishuOutbound.sendPayload native cards", () => { ]); }); + it("falls back to chunked text when a table exceeds the Feishu card envelope", async () => { + const presentation = createOversizedTablePresentation(); + const rawCardText = JSON.stringify({ + schema: "2.0", + body: { elements: [{ tag: "markdown", content: "Raw card JSON must stay hidden" }] }, + }); + const payload = { text: rawCardText, presentation }; + const rendered = await feishuOutbound.renderPresentation?.({ + payload, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload, + }, + }); + if (!rendered) { + throw new Error("expected explicit Feishu fallback payload"); + } + const directResult = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: rawCardText, + accountId: "main", + payload, + }); + const directDeliveredText = sendMessageFeishuMock.mock.calls + .map((call) => String(call[0]?.text ?? "")) + .join("\n"); + sendMessageFeishuMock.mockClear(); + const { presentation: _presentation, ...coreRenderedPayload } = rendered; + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: coreRenderedPayload.text ?? "", + accountId: "main", + payload: coreRenderedPayload, + }); + const textChunks = sendMessageFeishuMock.mock.calls.map((call) => String(call[0]?.text ?? "")); + const deliveredText = textChunks.join("\n"); + + expect(presentation.blocks.length).toBeGreaterThan(1); + expect( + Buffer.byteLength(renderMessagePresentationFallbackText({ presentation }), "utf8"), + ).toBeGreaterThan(30 * 1024); + expect(rendered.text).not.toContain("Raw card JSON must stay hidden"); + expect(rendered.text).not.toContain(rawCardText); + expect(directDeliveredText).toContain("account-0-"); + expect(directDeliveredText).toContain("account-399-"); + expect(directDeliveredText).not.toContain("Raw card JSON must stay hidden"); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(textChunks.length).toBeGreaterThan(1); + expect(deliveredText).toContain("account-0-"); + expect(deliveredText).toContain("account-399-"); + expect(deliveredText).not.toContain("Raw card JSON must stay hidden"); + expectFeishuResult(directResult, "text_msg"); + expectFeishuResult(result, "text_msg"); + }); + + it("sends media once before chunking an oversized table fallback", async () => { + const presentation = createOversizedTablePresentation(); + const rendered = await feishuOutbound.renderPresentation?.({ + payload: { presentation, mediaUrl: "/tmp/pipeline.png" }, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload: { presentation, mediaUrl: "/tmp/pipeline.png" }, + }, + }); + if (!rendered) { + throw new Error("expected explicit Feishu fallback payload"); + } + const { presentation: _presentation, ...coreRenderedPayload } = rendered; + + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: coreRenderedPayload.text ?? "", + accountId: "main", + mediaLocalRoots: ["/tmp"], + replyToId: " ", + threadId: "om_thread", + payload: coreRenderedPayload, + }); + const textSendParams = sendMessageFeishuMock.mock.calls.map((call) => call[0]); + const textChunks = textSendParams.map((params) => String(params?.text ?? "")); + const deliveredText = textChunks.join("\n"); + + expect(rendered.text).toContain("account-399-"); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaCall()).toEqual( + expect.objectContaining({ + to: "chat_1", + mediaUrl: "/tmp/pipeline.png", + mediaLocalRoots: ["/tmp"], + }), + ); + expect(sendMediaCall()?.text).toBeUndefined(); + expect(sendMediaCall()?.replyToMessageId).toBe("om_thread"); + expect(sendMediaCall()?.replyInThread).toBe(true); + expect(textChunks.length).toBeGreaterThan(1); + expect( + textSendParams.every( + (params) => params?.replyToMessageId === "om_thread" && params.replyInThread === true, + ), + ).toBe(true); + expect(deliveredText).toContain("account-0-"); + expect(deliveredText).toContain("account-399-"); + expectFeishuResult(result, "text_msg"); + }); + + it("preserves command guidance in core-rendered element-limit fallbacks for comments", async () => { + const presentation = createElementLimitedCommandPresentation(); + const payload = { presentation }; + const rendered = await feishuOutbound.renderPresentation?.({ + payload, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload, + }, + }); + if (!rendered) { + throw new Error("expected explicit Feishu fallback payload"); + } + const { presentation: _presentation, ...coreRenderedPayload } = rendered; + + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: coreRenderedPayload.text ?? "", + accountId: "main", + payload: coreRenderedPayload, + }); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(commentThreadParams()?.content).toBe( + "- Approve: `/approve req_1`\n\n> Interactive buttons are unavailable in Feishu document comments. You can type the command shown above manually.", + ); + expectFeishuResult(result, "reply_msg"); + }); + + it("consumes a single-use reply once for short element-limit fallback media", async () => { + const presentation = createElementLimitedCommandPresentation(); + const payload = { presentation, mediaUrl: "/tmp/pipeline.png" }; + const rendered = await feishuOutbound.renderPresentation?.({ + payload, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload, + }, + }); + if (!rendered) { + throw new Error("expected explicit Feishu fallback payload"); + } + const { presentation: _presentation, ...coreRenderedPayload } = rendered; + + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: coreRenderedPayload.text ?? "", + accountId: "main", + replyToId: "om_reply", + replyToIdSource: "implicit", + replyToMode: "first", + payload: coreRenderedPayload, + }); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaCall()).toMatchObject({ + mediaUrl: "/tmp/pipeline.png", + replyToMessageId: "om_reply", + }); + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMessageCall()).toMatchObject({ + text: "- Approve: `/approve req_1`", + replyToMessageId: undefined, + }); + expectFeishuResult(result, "text_msg"); + }); + + it("consumes a single-use reply target on media before fallback text chunks", async () => { + const fallbackText = renderMessagePresentationFallbackText({ + presentation: createOversizedTablePresentation(), + }); + + await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: fallbackText, + accountId: "main", + replyToId: "om_reply", + replyToIdSource: "implicit", + replyToMode: "first", + payload: { + text: fallbackText, + mediaUrl: "/tmp/pipeline.png", + }, + }); + + expect(sendMediaCall()?.replyToMessageId).toBe("om_reply"); + expect(sendMessageFeishuMock).toHaveBeenCalled(); + expect( + sendMessageFeishuMock.mock.calls.every((call) => call[0]?.replyToMessageId === undefined), + ).toBe(true); + }); + + it("keeps oversized media fallbacks on Feishu document comment targets", async () => { + const fallbackText = renderMessagePresentationFallbackText({ + presentation: createOversizedTablePresentation(), + }); + + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: fallbackText, + accountId: "main", + payload: { + text: fallbackText, + mediaUrl: "https://example.com/pipeline.png", + }, + }); + const commentText = deliverCommentThreadTextMock.mock.calls + .slice(1) + .map((_call, index) => String(commentThreadParams(index + 1)?.content ?? "")) + .join("\n"); + + expect(sendMediaFeishuMock).not.toHaveBeenCalled(); + expect(commentThreadParams()?.content).toBe("https://example.com/pipeline.png"); + expect(deliverCommentThreadTextMock.mock.calls.length).toBeGreaterThan(2); + expect(commentText).toContain("account-0-"); + expect(commentText).toContain("account-399-"); + expectFeishuResult(result, "reply_msg"); + }); + + it("separates comment media from a chunked in-envelope presentation fallback", async () => { + const presentation: MessagePresentation = { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: Array.from({ length: 90 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(48)}`, + "Review", + ]), + }, + ], + }; + const rendered = await feishuOutbound.renderPresentation?.({ + payload: { presentation }, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload: { presentation }, + }, + }); + const renderedCard = ( + rendered?.channelData as { feishu?: { card?: Record } } | undefined + )?.feishu?.card; + + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "", + accountId: "main", + payload: { + presentation, + mediaUrl: "https://example.com/pipeline.png", + }, + }); + const commentTexts = deliverCommentThreadTextMock.mock.calls.map((_call, index) => + String(commentThreadParams(index)?.content ?? ""), + ); + const fallbackChunks = commentTexts.slice(1); + + expect(renderedCard).toBeDefined(); + expect(commentTexts[0]).toBe("https://example.com/pipeline.png"); + expect(fallbackChunks.length).toBeGreaterThan(1); + expect(fallbackChunks.every((chunk) => Array.from(chunk).length <= 4000)).toBe(true); + expect(fallbackChunks.join("\n")).toContain("account-89-"); + expect(sendMediaFeishuMock).not.toHaveBeenCalled(); + expectFeishuResult(result, "reply_msg"); + }); + + it("ignores oversized native card data for document comment text delivery", async () => { + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "Safe comment fallback", + accountId: "main", + payload: { + text: "Safe comment fallback", + channelData: { + feishu: { + card: { + schema: "2.0", + body: { + elements: [{ tag: "markdown", content: "x".repeat(31 * 1024) }], + }, + }, + }, + }, + }, + }); + + expect(commentThreadParams()?.content).toBe("Safe comment fallback"); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expectFeishuResult(result, "reply_msg"); + }); + + it("rejects oversized caller-supplied native cards instead of leaking their JSON as text", async () => { + await expect( + feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: "safe fallback", + accountId: "main", + payload: { + text: "safe fallback", + channelData: { + feishu: { + card: { + schema: "2.0", + body: { + elements: [{ tag: "markdown", content: "x".repeat(31 * 1024) }], + }, + }, + }, + }, + }, + }), + ).rejects.toThrow("Feishu native card exceeds the 30 KB or 200-element API limit"); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + }); + it("does not duplicate title-only presentation cards in outbound fallbacks", async () => { const presentation: MessagePresentation = { title: "Status", @@ -1085,6 +1480,52 @@ describe("feishuOutbound.sendPayload native cards", () => { expect(sendCardCall()?.replyInThread).toBe(false); }); + it("consumes an implicit first-reply target on valid-card media", async () => { + await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + replyToId: "om_reply", + replyToIdSource: "implicit", + replyToMode: "first", + payload: { + mediaUrl: "/tmp/image.png", + presentation: { + blocks: [{ type: "table", caption: "Pipeline", headers: ["Account"], rows: [["Acme"]] }], + }, + }, + }); + + expect(sendMediaCall()?.replyToMessageId).toBe("om_reply"); + expect(sendCardCall()?.replyToMessageId).toBeUndefined(); + }); + + it("keeps valid-card media and the final card in an explicit thread", async () => { + await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + threadId: "om_thread", + payload: { + mediaUrl: "/tmp/image.png", + presentation: { + blocks: [{ type: "table", caption: "Pipeline", headers: ["Account"], rows: [["Acme"]] }], + }, + }, + }); + + expect(sendMediaCall()).toMatchObject({ + replyToMessageId: "om_thread", + replyInThread: true, + }); + expect(sendCardCall()).toMatchObject({ + replyToMessageId: "om_thread", + replyInThread: true, + }); + }); + it("keeps text/media fallback behavior for non-card payloads, including local image text", async () => { const { dir, file } = await createTmpImage(); try { @@ -1137,6 +1578,28 @@ describe("feishuOutbound.sendPayload native cards", () => { expectFeishuResult(result, "reply_msg"); }); + it("rejects card-only document comments instead of reporting an empty delivery", async () => { + const text = JSON.stringify({ + header: { title: { tag: "plain_text", content: "Raw card" } }, + elements: [{ tag: "markdown", content: "Raw body" }], + }); + + await expect( + feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text, + accountId: "main", + payload: { text }, + }), + ).rejects.toThrow( + "Feishu native cards cannot be sent to document comments without a text or media fallback.", + ); + + expect(deliverCommentThreadTextMock).not.toHaveBeenCalled(); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + }); + it.each([ [ "presentation", diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index b28c0f58ccd9..e84cb14d184b 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,5 +1,6 @@ // Feishu plugin module implements outbound behavior. import path from "node:path"; +import { createReplyToFanout } from "openclaw/plugin-sdk/channel-outbound"; import { attachChannelToResult, createAttachedChannelResultAdapter, @@ -40,7 +41,11 @@ import { sanitizeNativeFeishuCard, } from "./native-card.js"; import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js"; -import { buildFeishuPresentationCardElements } from "./presentation-card.js"; +import { + assertFeishuCardWithinEnvelope, + buildFeishuPresentationCardElements, + isFeishuCardWithinEnvelope, +} from "./presentation-card.js"; import { sendCardFeishu, sendMarkdownCardFeishu, @@ -49,6 +54,8 @@ import { } from "./send.js"; const RENDERED_FEISHU_CARD = Symbol("openclaw.renderedFeishuCard"); +const FEISHU_PRESENTATION_FALLBACK_MARKER = "__openclawPresentationFallback"; +const FEISHU_TEXT_CHUNK_LIMIT = 4000; function normalizePossibleLocalImagePath(text: string | undefined): string | null { const raw = text?.trim(); @@ -119,6 +126,36 @@ function readNativeFeishuCard(payload: { channelData?: Record } return sanitizedCard ? markRenderedFeishuCard(sanitizedCard) : undefined; } +type FeishuOutboundPayload = Parameters< + NonNullable +>[0]["payload"]; +type FeishuSendPayloadContext = Parameters>[0]; + +function consumeFeishuPresentationFallbackMarker(payload: FeishuOutboundPayload): { + payload: FeishuOutboundPayload; + presentationFallback: boolean; +} { + const feishuData = isRecord(payload.channelData?.feishu) ? payload.channelData.feishu : undefined; + if (feishuData?.[FEISHU_PRESENTATION_FALLBACK_MARKER] !== true) { + return { payload, presentationFallback: false }; + } + const nextFeishuData = { ...feishuData }; + delete nextFeishuData[FEISHU_PRESENTATION_FALLBACK_MARKER]; + const nextChannelData = { ...payload.channelData }; + if (Object.keys(nextFeishuData).length > 0) { + nextChannelData.feishu = nextFeishuData; + } else { + delete nextChannelData.feishu; + } + return { + payload: { + ...payload, + channelData: Object.keys(nextChannelData).length > 0 ? nextChannelData : undefined, + }, + presentationFallback: true, + }; +} + function buildFeishuPayloadCard(params: { payload: Parameters>[0]["payload"]; text?: string; @@ -126,6 +163,7 @@ function buildFeishuPayloadCard(params: { }): Record | undefined { const nativeCard = readNativeFeishuCard(params.payload); if (nativeCard) { + assertFeishuCardWithinEnvelope(nativeCard, "Feishu native card"); return nativeCard; } @@ -136,7 +174,11 @@ function buildFeishuPayloadCard(params: { normalizeMessagePresentation(params.payload.presentation) ?? (interactive ? interactiveReplyToPresentation(interactive) : undefined); if (!presentation && !interactive) { - return textCard ? markRenderedFeishuCard(textCard) : undefined; + if (!textCard) { + return undefined; + } + assertFeishuCardWithinEnvelope(textCard, "Feishu native card"); + return markRenderedFeishuCard(textCard); } const text = textCard @@ -166,7 +208,7 @@ function buildFeishuPayloadCard(params: { : "blue", ); - return markRenderedFeishuCard({ + const card = markRenderedFeishuCard({ schema: "2.0", config: { width_mode: "fill" }, ...(title @@ -179,6 +221,7 @@ function buildFeishuPayloadCard(params: { : {}), body: { elements }, }); + return isFeishuCardWithinEnvelope(card) ? card : undefined; } // Keep this aligned with the shared fallback renderer: guidance is valid only @@ -207,22 +250,40 @@ function renderFeishuPresentationPayload({ presentation, ctx, }: Parameters>[0]) { + const textCard = readNativeFeishuCardJson(payload.text); + const fallbackText = renderMessagePresentationFallbackText({ + text: textCard ? undefined : payload.text, + presentation, + }); const card = buildFeishuPayloadCard({ payload, text: payload.text, identity: ctx.identity, }); - if (!card) { - return null; - } const existingFeishuData = isRecord(payload.channelData?.feishu) ? payload.channelData.feishu : undefined; - // Core consumes presentation before sendPayload; carry the fallback fact. const fallbackHasCommand = hasVisibleFallbackCommand(presentation?.blocks); + if (!card) { + // The marker keeps core on sendPayload after it strips presentation; that path + // consumes it and fans out text instead of using the whole fallback as a caption. + return { + ...payload, + text: fallbackText, + channelData: { + ...payload.channelData, + feishu: { + ...existingFeishuData, + [FEISHU_PRESENTATION_FALLBACK_MARKER]: true, + ...(fallbackHasCommand ? { fallbackHasCommand: true } : {}), + }, + }, + }; + } + // Core consumes presentation before sendPayload; carry the fallback fact. return { ...payload, - text: renderMessagePresentationFallbackText({ text: payload.text, presentation }), + text: fallbackText, channelData: { ...payload.channelData, feishu: { @@ -235,9 +296,9 @@ function renderFeishuPresentationPayload({ } type FeishuReplyMode = - | { replyToMessageId: string; replyInThread: false } - | { replyToMessageId: string; replyInThread: true } - | { replyToMessageId: undefined; replyInThread: false }; + | { normalizedReplyToId: string; replyToMessageId: string; replyInThread: false } + | { normalizedReplyToId: undefined; replyToMessageId: string; replyInThread: true } + | { normalizedReplyToId: undefined; replyToMessageId: undefined; replyInThread: false }; // Target selection and thread mode are one decision; all payload parts reuse this result. function resolveFeishuReplyMode(params: { @@ -246,13 +307,17 @@ function resolveFeishuReplyMode(params: { }): FeishuReplyMode { const replyToMessageId = params.replyToId?.trim(); if (replyToMessageId) { - return { replyToMessageId, replyInThread: false }; + return { normalizedReplyToId: replyToMessageId, replyToMessageId, replyInThread: false }; } const threadId = params.threadId == null ? undefined : String(params.threadId).trim(); return threadId - ? { replyToMessageId: threadId, replyInThread: true } - : { replyToMessageId: undefined, replyInThread: false }; + ? { normalizedReplyToId: undefined, replyToMessageId: threadId, replyInThread: true } + : { + normalizedReplyToId: undefined, + replyToMessageId: undefined, + replyInThread: false, + }; } async function sendCommentThreadReply(params: { @@ -335,11 +400,71 @@ async function sendOutboundText(params: { return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId, replyInThread }); } +async function sendFeishuFallbackPayload(params: { + ctx: FeishuSendPayloadContext; + payload: FeishuOutboundPayload; + separateMediaAndText?: boolean; +}) { + const ctx = { ...params.ctx, payload: params.payload }; + const mediaUrls = normalizeStringEntries(resolvePayloadMediaUrls(params.payload)); + const text = params.payload.text ?? ""; + const textChunks = text ? chunkTextForOutbound(text, FEISHU_TEXT_CHUNK_LIMIT) : []; + const shouldSeparate = + mediaUrls.length > 0 && (params.separateMediaAndText === true || textChunks.length > 1); + if (!shouldSeparate) { + return await sendTextMediaPayload({ + channel: "feishu", + ctx, + adapter: feishuOutbound, + }); + } + + const { normalizedReplyToId } = resolveFeishuReplyMode({ + replyToId: ctx.replyToId, + threadId: ctx.threadId, + }); + const nextReplyToId = createReplyToFanout({ + replyToId: normalizedReplyToId, + replyToIdSource: ctx.replyToIdSource, + replyToMode: ctx.replyToMode, + }); + const sendMedia = feishuOutbound.sendMedia; + const sendText = feishuOutbound.sendText; + if (!sendMedia || !sendText) { + throw new Error("Feishu fallback delivery is not available."); + } + + // Card fallbacks can exceed media-caption limits. Deliver attachments first, + // then preserve the complete fallback through the normal 4k text fanout. + let lastResult: Awaited> | undefined; + for (const mediaUrl of mediaUrls) { + lastResult = await sendMedia({ + ...ctx, + text: "", + mediaUrl, + replyToId: nextReplyToId(), + audioAsVoice: params.payload.audioAsVoice ?? ctx.audioAsVoice, + onDeliveryResult: undefined, + }); + await ctx.onDeliveryResult?.(lastResult); + } + for (const chunk of textChunks) { + lastResult = await sendText({ + ...ctx, + text: chunk, + replyToId: nextReplyToId(), + onDeliveryResult: undefined, + }); + await ctx.onDeliveryResult?.(lastResult); + } + return lastResult!; +} + export const feishuOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkTextForOutbound, chunkerMode: "markdown", - textChunkLimit: 4000, + textChunkLimit: FEISHU_TEXT_CHUNK_LIMIT, presentationCapabilities: { supported: true, buttons: true, @@ -354,7 +479,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { maxValueBytes: 1024, }, text: { - maxLength: 4000, + maxLength: FEISHU_TEXT_CHUNK_LIMIT, encoding: "characters", markdownDialect: "markdown", }, @@ -362,80 +487,111 @@ export const feishuOutbound: ChannelOutboundAdapter = { }, renderPresentation: renderFeishuPresentationPayload, sendPayload: async (ctx) => { - const card = buildFeishuPayloadCard({ - payload: ctx.payload, - text: ctx.text, - identity: ctx.identity, - }); - if (!card) { - return await sendTextMediaPayload({ - channel: "feishu", - ctx, - adapter: feishuOutbound, - }); - } - - const { replyToMessageId, replyInThread } = resolveFeishuReplyMode({ - replyToId: ctx.replyToId, - threadId: ctx.threadId, - }); - const commentTarget = parseFeishuCommentTarget(ctx.to); - if (commentTarget) { + const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload); + if (parseFeishuCommentTarget(ctx.to)) { + const interactive = normalizeInteractiveReply(payload.interactive); const normalizedPresentation = - normalizeMessagePresentation(ctx.payload.presentation) ?? - (() => { - const interactive = normalizeInteractiveReply(ctx.payload.interactive); - return interactive ? interactiveReplyToPresentation(interactive) : undefined; - })(); - // Structured content replaces raw card JSON; document comments should - // render only the usable text fallback instead of exposing both forms. - const fallbackSourceText = - normalizedPresentation && readNativeFeishuCardJson(ctx.payload.text) - ? undefined - : ctx.payload.text; + normalizeMessagePresentation(payload.presentation) ?? + (interactive ? interactiveReplyToPresentation(interactive) : undefined); + // Document comments cannot render cards. Resolve the text path before + // validating card limits so unused native card data cannot block delivery. + const textCard = readNativeFeishuCardJson(payload.text); + const fallbackSourceText = textCard ? undefined : payload.text; const presentationFallbackText = renderMessagePresentationFallbackText({ text: fallbackSourceText, presentation: normalizedPresentation, }); + const hasFallbackMedia = normalizeStringEntries(resolvePayloadMediaUrls(payload)).length > 0; + if ( + !presentationFallbackText.trim() && + !hasFallbackMedia && + (textCard || readNativeFeishuCard(payload)) + ) { + throw new Error( + "Feishu native cards cannot be sent to document comments without a text or media fallback.", + ); + } // Direct delivery retains blocks; core-rendered delivery carries the fact. const fallbackHasCommand = hasVisibleFallbackCommand(normalizedPresentation?.blocks) || - (isRecord(ctx.payload.channelData?.feishu) && - ctx.payload.channelData.feishu.fallbackHasCommand === true); + (isRecord(payload.channelData?.feishu) && + payload.channelData.feishu.fallbackHasCommand === true); const text = fallbackHasCommand ? `${presentationFallbackText}\n\n> Interactive buttons are unavailable in Feishu document comments. You can type the command shown above manually.` : presentationFallbackText; - - return await sendTextMediaPayload({ - channel: "feishu", - ctx: { - ...ctx, - payload: { - ...ctx.payload, - text, - interactive: undefined, - presentation: undefined, - channelData: undefined, - }, - }, - adapter: feishuOutbound, + const fallbackPayload = { + ...payload, + text, + interactive: undefined, + presentation: undefined, + channelData: undefined, + }; + return await sendFeishuFallbackPayload({ + ctx, + payload: fallbackPayload, + separateMediaAndText: true, }); } - const mediaUrls = normalizeStringEntries(resolvePayloadMediaUrls(ctx.payload)); + const card = buildFeishuPayloadCard({ + payload, + text: ctx.text, + identity: ctx.identity, + }); + if (!card) { + const interactive = normalizeInteractiveReply(payload.interactive); + const presentation = + normalizeMessagePresentation(payload.presentation) ?? + (interactive ? interactiveReplyToPresentation(interactive) : undefined); + const fallbackPayload = presentation + ? { + ...payload, + text: renderMessagePresentationFallbackText({ + text: readNativeFeishuCardJson(payload.text) ? undefined : payload.text, + presentation, + }), + presentation: undefined, + interactive: undefined, + } + : payload; + return await sendFeishuFallbackPayload({ + ctx, + payload: fallbackPayload, + separateMediaAndText: presentationFallback || presentation !== undefined, + }); + } + + const { normalizedReplyToId } = resolveFeishuReplyMode({ + replyToId: ctx.replyToId, + threadId: ctx.threadId, + }); + // Media and the final card are separate payloads: consume an implicit + // first-reply id once, while an explicit thread remains sticky for both. + const nextReplyToId = createReplyToFanout({ + replyToId: normalizedReplyToId, + replyToIdSource: ctx.replyToIdSource, + replyToMode: ctx.replyToMode, + }); + const nextReplyMode = () => + resolveFeishuReplyMode({ + replyToId: nextReplyToId(), + threadId: ctx.threadId, + }); + const mediaUrls = normalizeStringEntries(resolvePayloadMediaUrls(payload)); return attachChannelToResult( "feishu", await sendPayloadMediaSequenceAndFinalize< SendMediaResult, Awaited> >({ - text: ctx.payload.text ?? "", + text: payload.text ?? "", mediaUrls, onResult: async (deliveryResult) => { await ctx.onDeliveryResult?.(attachChannelToResult("feishu", deliveryResult)); }, - send: async ({ mediaUrl }) => - await sendMediaFeishu({ + send: async ({ mediaUrl }) => { + const { replyToMessageId, replyInThread } = nextReplyMode(); + return await sendMediaFeishu({ cfg: ctx.cfg, to: ctx.to, mediaUrl, @@ -443,19 +599,22 @@ export const feishuOutbound: ChannelOutboundAdapter = { mediaLocalRoots: ctx.mediaLocalRoots, replyToMessageId, replyInThread, - ...(ctx.payload.audioAsVoice === true || ctx.audioAsVoice === true + ...(payload.audioAsVoice === true || ctx.audioAsVoice === true ? { audioAsVoice: true } : {}), - }), - finalize: async () => - await sendCardFeishu({ + }); + }, + finalize: async () => { + const { replyToMessageId, replyInThread } = nextReplyMode(); + return await sendCardFeishu({ cfg: ctx.cfg, to: ctx.to, card, replyToMessageId, replyInThread, accountId: ctx.accountId ?? undefined, - }), + }); + }, }), ); }, @@ -509,6 +668,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { const card = readNativeFeishuCardJson(text); if (card) { + assertFeishuCardWithinEnvelope(card, "Feishu native card"); return await sendCardFeishu({ cfg, to, diff --git a/extensions/feishu/src/presentation-card.test.ts b/extensions/feishu/src/presentation-card.test.ts new file mode 100644 index 000000000000..1cee70cc931f --- /dev/null +++ b/extensions/feishu/src/presentation-card.test.ts @@ -0,0 +1,52 @@ +import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; +import { describe, expect, it } from "vitest"; +import { + buildFeishuPresentationCardElements, + isFeishuCardWithinEnvelope, +} from "./presentation-card.js"; + +describe("buildFeishuPresentationCardElements", () => { + it("renders table blocks through the portable text fallback", () => { + const presentation = normalizeMessagePresentation({ + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000], + ], + }, + ], + }); + if (!presentation) { + throw new Error("expected valid presentation"); + } + + expect(buildFeishuPresentationCardElements({ presentation })).toEqual([ + { + tag: "markdown", + content: + "Pipeline (table)\n- Account: Acme; Stage: Won; ARR: 125000\n- Account: Globex; Stage: Review; ARR: 82000", + }, + ]); + }); +}); + +describe("isFeishuCardWithinEnvelope", () => { + it("counts nested elements against the 200-element API limit", () => { + const buildCard = (elementCount: number) => ({ + schema: "2.0", + body: { + elements: Array.from({ length: elementCount }, (_entry, index) => ({ + tag: "markdown", + content: String(index), + })), + }, + }); + + expect(isFeishuCardWithinEnvelope(buildCard(200))).toBe(true); + expect(isFeishuCardWithinEnvelope(buildCard(201))).toBe(false); + }); +}); diff --git a/extensions/feishu/src/presentation-card.ts b/extensions/feishu/src/presentation-card.ts index 983219914b90..cdd57a3f7ef4 100644 --- a/extensions/feishu/src/presentation-card.ts +++ b/extensions/feishu/src/presentation-card.ts @@ -3,6 +3,7 @@ import { normalizeMessagePresentation, renderMessagePresentationChartFallbackText, renderMessagePresentationFallbackText, + renderMessagePresentationTableFallbackText, type MessagePresentationBlock, type MessagePresentationButton, } from "openclaw/plugin-sdk/interactive-runtime"; @@ -10,6 +11,52 @@ import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; type NormalizedMessagePresentation = NonNullable>; +const FEISHU_CARD_MAX_BYTES = 30 * 1024; +const FEISHU_CARD_MAX_ELEMENTS = 200; + +function countFeishuCardElements(value: unknown, ancestors = new Set()): number { + if (Array.isArray(value)) { + return value.reduce((count, entry) => count + countFeishuCardElements(entry, ancestors), 0); + } + if (!value || typeof value !== "object") { + return 0; + } + if (ancestors.has(value)) { + return FEISHU_CARD_MAX_ELEMENTS + 1; + } + ancestors.add(value); + const record = value as Record; + let count = typeof record.tag === "string" ? 1 : 0; + for (const entry of Object.values(record)) { + count += countFeishuCardElements(entry, ancestors); + if (count > FEISHU_CARD_MAX_ELEMENTS) { + break; + } + } + ancestors.delete(value); + return count; +} + +export function isFeishuCardWithinEnvelope(card: Record): boolean { + try { + return ( + Buffer.byteLength(JSON.stringify(card), "utf8") <= FEISHU_CARD_MAX_BYTES && + countFeishuCardElements(card) <= FEISHU_CARD_MAX_ELEMENTS + ); + } catch { + return false; + } +} + +export function assertFeishuCardWithinEnvelope( + card: Record, + label = "Feishu card", +): void { + if (!isFeishuCardWithinEnvelope(card)) { + throw new Error(`${label} exceeds the 30 KB or 200-element API limit.`); + } +} + function escapeFeishuCardMarkdownText(text: string): string { return text.replace(/[&<>]/g, (char) => { switch (char) { @@ -129,6 +176,14 @@ function buildFeishuCardElementsForBlock( }, ]; } + if (block.type === "table") { + return [ + { + tag: "markdown", + content: escapeFeishuCardMarkdownText(renderMessagePresentationTableFallbackText(block)), + }, + ]; + } const labels = block.options.map((option) => `- ${option.label}`).join("\n"); return [ { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index f08f37e06e8d..b336e7047574 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -38,6 +38,7 @@ import { markdownToSignalTextChunks } from "./format.js"; import { signalMessageActions } from "./message-actions.js"; import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js"; import { resolveSignalOutboundTarget } from "./outbound-session.js"; +import { materializeSignalPresentationFallback } from "./presentation-fallback.js"; import { resolveSignalReactionLevel } from "./reaction-level.js"; import { signalSetupAdapter } from "./setup-core.js"; import { @@ -361,11 +362,12 @@ async function renderSignalApprovalPayloadForReactions( } const { addSignalApprovalReactionHintToStructuredPayload } = await loadSignalApprovalReactionsModule(); + const payload = materializeSignalPresentationFallback(params.payload, params.presentation); return addSignalApprovalReactionHintToStructuredPayload({ cfg: params.ctx.cfg, accountId: params.ctx.accountId ?? undefined, to: params.ctx.to, - payload: params.payload, + payload, targetAuthor, targetAuthorUuid, }); diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index f21ce47cc729..8da6f92f0b2f 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -778,6 +778,68 @@ describe("signal outbound", () => { ).toBeNull(); }); + it("materializes mixed approval presentation before adding reaction guidance", async () => { + const cfg = { + channels: { + signal: { + account: "+15550009999", + allowFrom: ["+15551230000"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "signal", to: "+15551230000" }], + }, + }, + } as OpenClawConfig; + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-mixed-presentation", + approvalSlug: "exec-mixed-presentation", + allowedDecisions: ["allow-once", "deny"], + command: "printf test", + host: "gateway", + }); + const presentation = { + ...payload.presentation!, + blocks: [ + { type: "context" as const, text: "Deployment audit context" }, + { + type: "table" as const, + caption: "Targets", + headers: ["Host", "State"], + rows: [ + ["alpha", "ready"], + ["omega", "waiting"], + ], + }, + ...payload.presentation!.blocks, + ], + }; + + const rendered = await signalPlugin.outbound?.renderPresentation?.({ + payload: { ...payload, presentation }, + presentation, + ctx: { + cfg, + to: "+15551230000", + text: payload.text ?? "", + accountId: "default", + payload, + }, + }); + + expect(rendered?.text).toContain("Deployment audit context"); + expect(rendered?.text).toContain("- Host: alpha; State: ready"); + expect(rendered?.text).toContain("- Host: omega; State: waiting"); + expect(rendered?.text?.match(/React with:/g)).toHaveLength(1); + expect(rendered?.text?.match(/\/approve exec-mixed-presentation allow-once/g)).toHaveLength(1); + expect(rendered?.text?.match(/\/approve exec-mixed-presentation deny/g)).toHaveLength(1); + expect(rendered?.text).not.toContain("- Allow Once:"); + expect(rendered?.text).not.toContain("- Deny:"); + }); + it("registers delivered approval reactions under the resolved default account", async () => { const renderPresentation = signalPlugin.outbound?.renderPresentation; const afterDeliverPayload = signalPlugin.outbound?.afterDeliverPayload; diff --git a/extensions/signal/src/monitor.approval-reply-delivery.test.ts b/extensions/signal/src/monitor.approval-reply-delivery.test.ts index 63b6d1f5038f..6ef507ed0891 100644 --- a/extensions/signal/src/monitor.approval-reply-delivery.test.ts +++ b/extensions/signal/src/monitor.approval-reply-delivery.test.ts @@ -63,7 +63,7 @@ async function deliverReplyPayload( }); } -describe("Signal monitor approval reply delivery", () => { +describe("Signal monitor reply delivery", () => { beforeEach(() => { clearSignalApprovalReactionTargetsForTest(); sendMocks.sendMessageSignal.mockReset().mockResolvedValue({ @@ -108,6 +108,116 @@ describe("Signal monitor approval reply delivery", () => { }); }); + it("materializes table-only presentation replies", async () => { + await deliverReplyPayload({ + presentation: { + blocks: [ + { + type: "table", + caption: "Targets", + headers: ["Host", "State"], + rows: [ + ["alpha", "ready"], + ["omega", "waiting"], + ], + }, + ], + }, + }); + + expect(sendMocks.sendMessageSignal).toHaveBeenCalledTimes(1); + expect(String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "")).toBe( + "Targets (table)\n- Host: alpha; State: ready\n- Host: omega; State: waiting", + ); + }); + + it("preserves table presentation alongside plain reply text", async () => { + await deliverReplyPayload({ + text: "Deployment summary", + presentation: { + blocks: [ + { + type: "table", + caption: "Targets", + headers: ["Host", "State"], + rows: [ + ["alpha", "ready"], + ["omega", "waiting"], + ], + }, + ], + }, + }); + + const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? ""); + expect(sentText).toContain("Deployment summary"); + expect(sentText).toContain("- Host: alpha; State: ready"); + expect(sentText).toContain("- Host: omega; State: waiting"); + }); + + it("preserves ordinary control-only presentation fallback text", async () => { + await deliverReplyPayload({ + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Retry", action: { type: "command", command: "/retry" } }], + }, + { + type: "select", + placeholder: "Choose", + options: [{ label: "Later", action: { type: "callback", value: "later" } }], + }, + ], + }, + }); + + expect(sendMocks.sendMessageSignal).toHaveBeenCalledTimes(1); + expect(String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "")).toBe( + "- Retry: `/retry`\n\nChoose:\n- Later", + ); + }); + + it("materializes mixed approval presentations before adding one reaction hint", async () => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-monitor-mixed", + approvalSlug: "exec-monitor-mixed", + allowedDecisions: ["allow-once", "deny"], + command: "printf mixed", + host: "gateway", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }); + payload.presentation = { + ...payload.presentation!, + blocks: [ + { type: "context", text: "Deployment audit context" }, + { + type: "table", + caption: "Targets", + headers: ["Host", "State"], + rows: [ + ["alpha", "ready"], + ["omega", "waiting"], + ], + }, + ...payload.presentation!.blocks, + ], + }; + + await deliverReplyPayload(payload); + + const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? ""); + expect(sentText).toContain("Deployment audit context"); + expect(sentText).toContain("- Host: alpha; State: ready"); + expect(sentText).toContain("- Host: omega; State: waiting"); + expect(sentText.match(/React with:/g)).toHaveLength(1); + expect(sentText.match(/\/approve exec-monitor-mixed allow-once/g)).toHaveLength(1); + expect(sentText.match(/\/approve exec-monitor-mixed deny/g)).toHaveLength(1); + expect(sentText).not.toContain("- Allow Once:"); + expect(sentText).not.toContain("- Deny:"); + }); + it("registers monitor approval replies for UUID-only linked accounts", async () => { const accountUuid = "123e4567-e89b-12d3-a456-426614174000"; const payload = buildExecApprovalPendingReplyPayload({ diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 09585bef19d7..7c1c650f756c 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -57,6 +57,7 @@ import type { SignalReactionMessage, SignalReactionTarget, } from "./monitor/event-handler.types.js"; +import { materializeSignalPresentationFallback } from "./presentation-fallback.js"; import { sendMessageSignal } from "./send.js"; import { runSignalSseLoop } from "./sse-reconnect.js"; @@ -406,15 +407,16 @@ export async function deliverReplies(params: { messageId: string; meta: { signalVisibleText: string }; }> = []; + const presentationPayload = materializeSignalPresentationFallback(payload); const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({ cfg: params.cfg, accountId, to: target, - payload, + payload: presentationPayload, targetAuthor: account, targetAuthorUuid: accountUuid, - }) ?? payload; + }) ?? presentationPayload; const reply = resolveSendableOutboundReplyParts(deliveredPayload); const nextNativeReply = createSignalNativeReplyResolver({ payload: deliveredPayload, diff --git a/extensions/signal/src/presentation-fallback.ts b/extensions/signal/src/presentation-fallback.ts new file mode 100644 index 000000000000..07196fb4c8bb --- /dev/null +++ b/extensions/signal/src/presentation-fallback.ts @@ -0,0 +1,41 @@ +// Signal plugin module materializes portable presentations into sendable text. +import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-reply-runtime"; +import { + isMessagePresentationInteractiveBlock, + normalizeMessagePresentation, + renderMessagePresentationFallbackText, + type MessagePresentation, +} from "openclaw/plugin-sdk/interactive-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; + +/** Materialize presentation content once before Signal's text-only delivery funnels. */ +export function materializeSignalPresentationFallback( + payload: ReplyPayload, + presentationOverride?: MessagePresentation, +): ReplyPayload { + const presentation = presentationOverride ?? normalizeMessagePresentation(payload.presentation); + if (!presentation) { + return payload; + } + + const currentText = payload.text?.trim() ?? ""; + const approvalTextOwnsControls = Boolean(getExecApprovalReplyMetadata(payload) && currentText); + // Structured approval text already enumerates every command. Rendering its + // control blocks again would duplicate Allow/Deny guidance on text-only Signal. + const fallbackPresentation = approvalTextOwnsControls + ? { + ...presentation, + blocks: presentation.blocks.filter( + (block) => !isMessagePresentationInteractiveBlock(block), + ), + } + : presentation; + const presentationFallback = renderMessagePresentationFallbackText({ + presentation: fallbackPresentation, + }); + const text = currentText.includes(presentationFallback) + ? currentText + : [currentText, presentationFallback].filter(Boolean).join("\n\n"); + const { presentation: _presentation, ...withoutPresentation } = payload; + return { ...withoutPresentation, text }; +} diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 6701f07c6028..a7b771bb442b 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -205,6 +205,29 @@ describe("handleSlackAction", () => { expectLastSlackSend("root", cfg); }); + it("forwards preformatted Slack fallback text without reparsing", async () => { + const cfg = slackConfig(); + + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "- Account: <@U123>", + mediaUrl: "https://example.com/report.csv", + textIsSlackMrkdwn: true, + }, + cfg, + ); + + expectSlackSendCall(0, "channel:C123", "- Account: <@U123>", { + cfg, + mediaUrl: "https://example.com/report.csv", + textIsSlackMrkdwn: true, + blocks: undefined, + }); + expect(sendSlackMessage).toHaveBeenCalledOnce(); + }); + async function resolveReadToken(cfg: OpenClawConfig): Promise { readSlackMessages.mockClear(); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); @@ -1366,6 +1389,66 @@ describe("handleSlackAction", () => { }); }); + it("keeps oversized text and native blocks in the same resolved thread", async () => { + const cfg = slackConfig({ replyToMode: "first" }); + const hasRepliedRef = { value: false }; + const context = createReplyToFirstContext(hasRepliedRef); + const content = "x".repeat(8001); + const blocks = [{ type: "divider" }]; + sendSlackMessage.mockResolvedValueOnce({ channelId: "content" }); + + const result = await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content, + blocks, + replyBroadcast: true, + }, + cfg, + context, + ); + + expect(sendSlackMessage).toHaveBeenCalledOnce(); + expectSlackSendCall(0, "channel:C123", content, { + cfg, + blocks, + replyBroadcast: true, + separateTextAndBlocks: true, + threadTs: "1111111111.111111", + }); + expect(hasRepliedRef.value).toBe(true); + expect(result.details).toEqual({ + ok: true, + result: { channelId: "content" }, + }); + }); + + it("separates explicitly marked short text from native blocks", async () => { + const cfg = slackConfig(); + const content = "Short portable table fallback"; + const blocks = [{ type: "divider" }]; + + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content, + blocks, + separateTextAndBlocks: true, + }, + cfg, + ); + + expect(sendSlackMessage).toHaveBeenCalledOnce(); + expectSlackSendCall(0, "channel:C123", content, { + cfg, + blocks, + separateTextAndBlocks: true, + threadTs: undefined, + }); + }); + it.each([ { name: "JSON blocks", diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 9da18c45b669..c64d9aae7004 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -10,8 +10,10 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coe import type { ResolvedSlackAccount } from "./accounts.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import type { SlackConversationInfo } from "./channel-type.js"; +import { SLACK_TEXT_LIMIT } from "./limits.js"; import { resolveSlackChannelConfig } from "./monitor/channel-config.js"; import { isSlackChannelAllowedByPolicy } from "./monitor/policy.js"; +import { hasSlackNativeDataBlock } from "./native-data-blocks.js"; import { createActionGate, imageResultFromFile, @@ -573,6 +575,8 @@ export async function handleSlackAction( const mediaUrl = readStringParam(params, "mediaUrl"); const blocks = readSlackBlocksParam(params); const replyBroadcast = readBooleanParam(params, "replyBroadcast"); + const textIsSlackMrkdwn = readBooleanParam(params, "textIsSlackMrkdwn"); + const separateTextAndBlocks = readBooleanParam(params, "separateTextAndBlocks"); if (!content && !mediaUrl && !blocks) { throw new Error("Slack sendMessage requires content, blocks, or mediaUrl."); } @@ -595,24 +599,41 @@ export async function handleSlackAction( mediaReadFile: context?.mediaReadFile, threadTs: threadTs ?? undefined, ...(replyBroadcast ? { replyBroadcast } : {}), + ...(textIsSlackMrkdwn ? { textIsSlackMrkdwn: true } : {}), }; - const result = - mediaUrl && blocks - ? await (async () => { + const sendContentAndBlocks = async () => { + const nativeDataOwnsChunking = hasSlackNativeDataBlock(blocks); + if ( + content && + (separateTextAndBlocks || + (content.length > SLACK_TEXT_LIMIT && !nativeDataOwnsChunking)) + ) { + return await slackActionRuntime.sendSlackMessage(to, content, { + ...sendOpts, + blocks, + separateTextAndBlocks: true, + }); + } + return await slackActionRuntime.sendSlackMessage(to, content ?? "", { + ...sendOpts, + blocks, + }); + }; + const result = blocks + ? await (async () => { + if (mediaUrl) { await slackActionRuntime.sendSlackMessage(to, "", { ...sendOpts, mediaUrl, }); - return await slackActionRuntime.sendSlackMessage(to, content ?? "", { - ...sendOpts, - blocks, - }); - })() - : await slackActionRuntime.sendSlackMessage(to, content ?? "", { - ...sendOpts, - mediaUrl: mediaUrl ?? undefined, - blocks, - }); + } + return await sendContentAndBlocks(); + })() + : await slackActionRuntime.sendSlackMessage(to, content ?? "", { + ...sendOpts, + mediaUrl: mediaUrl ?? undefined, + blocks, + }); // Keep "first" mode consistent even when the agent explicitly provided // threadTs: once we send a message to the current channel, consider the diff --git a/extensions/slack/src/actions.blocks.test.ts b/extensions/slack/src/actions.blocks.test.ts index 0ec08e7779fa..151d8d5dd144 100644 --- a/extensions/slack/src/actions.blocks.test.ts +++ b/extensions/slack/src/actions.blocks.test.ts @@ -5,20 +5,6 @@ import { createSlackEditTestClient } from "./blocks.test-helpers.js"; const { editSlackMessage } = await import("./actions.js"); const SLACK_TEXT_LIMIT = 8000; -function readFirstChatUpdatePayload(client: ReturnType): { - text?: string; -} { - const [call] = client.chat.update.mock.calls; - if (!call) { - throw new Error("expected Slack chat.update call"); - } - const [payload] = call; - if (!payload || typeof payload !== "object") { - throw new Error("expected Slack chat.update payload"); - } - return payload as { text?: string }; -} - describe("editSlackMessage blocks", () => { it("preserves long plain-text edits", async () => { const client = createSlackEditTestClient(); @@ -135,7 +121,7 @@ describe("editSlackMessage blocks", () => { }); }); - it("retries rejected native charts as a text-only edit", async () => { + it("retries rejected native charts as visible fallback blocks", async () => { const client = createSlackEditTestClient(); client.chat.update.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); const blocks = [ @@ -170,10 +156,211 @@ describe("editSlackMessage blocks", () => { channel: "C123", ts: "171234.567", text: "Overview\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40", + blocks: [ + blocks[0], + { + type: "section", + text: { + type: "mrkdwn", + text: "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", + verbatim: true, + }, + }, + ], }); }); - it("caps long block fallback text while preserving edit blocks", async () => { + it("retries rejected native tables once with visible complete fallback blocks", async () => { + const client = createSlackEditTestClient(); + client.chat.update.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Overview" } }, + { + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_number", value: 125000, text: "$125k" }, + ], + [ + { type: "raw_text", text: "Globex" }, + { type: "raw_number", value: 82000, text: "$82k" }, + ], + ], + row_header_column_index: 0, + }, + ] as never; + const fallback = [ + "Overview", + "", + "Pipeline report (table)", + "- Account: Acme; ARR: $125k", + "- Account: Globex; ARR: $82k", + ].join("\n"); + + await editSlackMessage("C123", "171234.567", "Overview", { + token: "xoxb-test", + client, + blocks, + }); + + expect(client.chat.update).toHaveBeenCalledTimes(2); + expect(client.chat.update).toHaveBeenNthCalledWith(1, { + channel: "C123", + ts: "171234.567", + text: fallback, + blocks, + }); + expect(client.chat.update).toHaveBeenNthCalledWith(2, { + channel: "C123", + ts: "171234.567", + text: fallback, + blocks: [ + blocks[0], + { + type: "section", + text: { + type: "mrkdwn", + text: [ + "Pipeline report (table)", + "- Account: Acme; ARR: $125k", + "- Account: Globex; ARR: $82k", + ].join("\n"), + verbatim: true, + }, + }, + ], + }); + }); + + it("includes every raw block and control in edit accessibility text", async () => { + const client = createSlackEditTestClient(); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Details" } }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "approve", + text: { type: "plain_text", text: "Approve" }, + value: "approve", + }, + ], + }, + ]; + + await editSlackMessage("C123", "171234.567", "Summary", { + token: "xoxb-test", + client, + blocks, + }); + + expect(client.chat.update).toHaveBeenCalledWith({ + channel: "C123", + ts: "171234.567", + text: "Summary\n\nDetails\n\n- Approve", + blocks, + }); + }); + + it("fails closed when edit fallback expansion would drop 48 siblings", async () => { + const client = createSlackEditTestClient(); + client.chat.update.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const categories = Array.from( + { length: 20 }, + (_entry, index) => `category-${String(index)}-${"x".repeat(80)}`, + ); + + await expect( + editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks: [ + ...Array.from({ length: 48 }, () => ({ type: "divider" })), + { + type: "data_visualization", + title: "Large chart", + chart: { + type: "bar", + axis_config: { categories }, + series: Array.from({ length: 4 }, (_entry, index) => ({ + name: `Series ${String(index)}`, + data: categories.map((label) => ({ label, value: index })), + })), + }, + }, + ] as never, + }), + ).rejects.toThrow(/fallback requires .* blocks to retain every sibling/i); + expect(client.chat.update).toHaveBeenCalledOnce(); + }); + + it("rejects table edits whose complete fallback cannot fit one message", async () => { + const client = createSlackEditTestClient(); + const header = "Account".padEnd(80, "x"); + const blocks = [ + { + type: "data_table", + caption: "Large pipeline", + rows: [ + [{ type: "raw_text", text: header }], + ...Array.from({ length: 100 }, (_entry, index) => [ + { type: "raw_text", text: `account-${String(index)}` }, + ]), + ], + }, + ] as never; + + await expect( + editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks, + }), + ).rejects.toThrow( + "Slack block accessibility fallback exceeds OpenClaw's 8000-character per-edit limit", + ); + expect(client.chat.update).not.toHaveBeenCalled(); + }); + + it("rejects chart edits whose complete fallback cannot fit one message", async () => { + const client = createSlackEditTestClient(); + const categories = Array.from({ length: 20 }, (_entry, index) => + `Category-${String(index)}`.padEnd(20, "x"), + ); + + await expect( + editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks: [ + { + type: "data_visualization", + title: "Large revenue report", + chart: { + type: "bar", + axis_config: { categories }, + series: Array.from({ length: 12 }, (_entry, index) => ({ + name: `Series-${String(index)}`.padEnd(20, "x"), + data: categories.map((label) => ({ label, value: Number.MAX_VALUE })), + })), + }, + }, + ] as never, + }), + ).rejects.toThrow( + "Slack block accessibility fallback exceeds OpenClaw's 8000-character per-edit limit", + ); + expect(client.chat.update).not.toHaveBeenCalled(); + }); + + it("rejects raw-block edits whose accessibility text cannot fit one message", async () => { const client = createSlackEditTestClient(); const longContextText = "a".repeat(3000); const blocks = [ @@ -187,19 +374,16 @@ describe("editSlackMessage blocks", () => { }, ]; - await editSlackMessage("C123", "171234.567", "", { - token: "xoxb-test", - client, - blocks, - }); - - expect(client.chat.update).toHaveBeenCalledWith({ - channel: "C123", - ts: "171234.567", - text: `${longContextText} ${longContextText} ${"a".repeat(SLACK_TEXT_LIMIT - longContextText.length * 2 - 3)}…`, - blocks, - }); - expect(readFirstChatUpdatePayload(client).text).toHaveLength(SLACK_TEXT_LIMIT); + await expect( + editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks, + }), + ).rejects.toThrow( + `Slack block accessibility fallback exceeds OpenClaw's ${String(SLACK_TEXT_LIMIT)}-character per-edit limit`, + ); + expect(client.chat.update).not.toHaveBeenCalled(); }); it("rejects empty blocks arrays", async () => { diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 1ddf14a09c64..6e5b7dcd4c58 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -7,15 +7,16 @@ import { z } from "zod"; import { resolveSlackAccount } from "./accounts.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient, getSlackWriteClient } from "./client.js"; -import { - appendSlackDataVisualizationFallbackText, - hasSlackDataVisualizationBlock, - isSlackInvalidBlocksError, -} from "./data-visualization.js"; import { buildSlackEditTextPayload } from "./edit-text.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; import { resolveSlackMedia } from "./monitor/media.js"; import type { SlackMediaResult } from "./monitor/media.js"; +import { + appendSlackNativeDataFallbackText, + buildSlackNativeDataFallbackBlocks, + hasSlackNativeDataBlock, + isSlackInvalidBlocksError, +} from "./native-data-blocks.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackBotToken } from "./token.js"; import { truncateSlackText } from "./truncate.js"; @@ -314,6 +315,8 @@ export async function sendSlackMessage( uploadFileName?: string; uploadTitle?: string; blocks?: (Block | KnownBlock)[]; + textIsSlackMrkdwn?: boolean; + separateTextAndBlocks?: boolean; }, ) { return await sendMessageSlack(to, content, { @@ -327,6 +330,8 @@ export async function sendSlackMessage( client: opts.client, threadTs: opts.threadTs, replyBroadcast: opts.replyBroadcast, + ...(opts.textIsSlackMrkdwn ? { textIsSlackMrkdwn: true } : {}), + ...(opts.separateTextAndBlocks ? { separateTextAndBlocks: true } : {}), ...(opts.uploadFileName ? { uploadFileName: opts.uploadFileName } : {}), ...(opts.uploadTitle ? { uploadTitle: opts.uploadTitle } : {}), blocks: opts.blocks, @@ -342,12 +347,18 @@ export async function editSlackMessage( const client = await getClient(opts, "write"); const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks); const editText = buildSlackEditTextPayload(content, blocks); - const text = hasSlackDataVisualizationBlock(blocks) - ? truncateSlackText( - appendSlackDataVisualizationFallbackText(editText, blocks), - SLACK_TEXT_LIMIT, - ) + const hasNativeData = hasSlackNativeDataBlock(blocks); + const nativeFallbackText = hasNativeData + ? appendSlackNativeDataFallbackText(editText, blocks) : editText; + if (blocks && nativeFallbackText.length > SLACK_TEXT_LIMIT) { + throw new Error( + `Slack block accessibility fallback exceeds OpenClaw's ${String(SLACK_TEXT_LIMIT)}-character per-edit limit. Send a new message instead.`, + ); + } + const text = hasNativeData + ? truncateSlackText(nativeFallbackText, SLACK_TEXT_LIMIT) + : nativeFallbackText; const update = { channel: channelId, ts: messageId, @@ -357,17 +368,16 @@ export async function editSlackMessage( try { await client.chat.update(update); } catch (error) { - if (!hasSlackDataVisualizationBlock(blocks) || !isSlackInvalidBlocksError(error)) { + if (!hasSlackNativeDataBlock(blocks) || !isSlackInvalidBlocksError(error)) { throw error; } - logVerbose("slack edit: data visualization rejected, retrying with text fallback"); + logVerbose("slack edit: native data block rejected, retrying with text fallback"); + const fallbackBlocks = buildSlackNativeDataFallbackBlocks(blocks); await client.chat.update({ channel: channelId, ts: messageId, - text: truncateSlackText( - appendSlackDataVisualizationFallbackText(text, blocks), - SLACK_TEXT_LIMIT, - ), + text: truncateSlackText(appendSlackNativeDataFallbackText(text, blocks), SLACK_TEXT_LIMIT), + ...(fallbackBlocks?.length ? { blocks: fallbackBlocks } : {}), }); } } diff --git a/extensions/slack/src/blocks-fallback.ts b/extensions/slack/src/blocks-fallback.ts index 95cec7959d18..47beecebfa2c 100644 --- a/extensions/slack/src/blocks-fallback.ts +++ b/extensions/slack/src/blocks-fallback.ts @@ -1,15 +1,40 @@ // Slack plugin module implements blocks fallback behavior. -import type { Block, KnownBlock } from "@slack/web-api"; -import { renderSlackDataVisualizationFallbackText } from "./data-visualization.js"; +import { renderSlackDataTableMrkdwnFallbackText } from "./data-table.js"; +import { renderSlackDataVisualizationMrkdwnFallbackText } from "./data-visualization.js"; +import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; +import { hasSlackNativeDataBlock } from "./native-data-blocks.js"; -type PlainTextObject = { text?: string }; +type SlackTextObject = { text?: string; type?: string }; + +type SlackActionElement = { + type?: string; + text?: SlackTextObject; + url?: string; + placeholder?: SlackTextObject; + options?: Array<{ text?: SlackTextObject }>; +}; + +type SlackRichTextElement = { + type?: string; + text?: string; + url?: string; + user_id?: string; + channel_id?: string; + usergroup_id?: string; + name?: string; + range?: string; + fallback?: string; + elements?: unknown[]; +}; type SlackBlockWithFields = { type?: string; - text?: PlainTextObject & { type?: string }; - title?: PlainTextObject; + text?: SlackTextObject; + title?: SlackTextObject; alt_text?: string; - elements?: Array<{ text?: string; type?: string }>; + elements?: unknown[]; + fields?: SlackTextObject[]; + accessory?: SlackActionElement; }; function cleanCandidate(value: string | undefined): string | undefined { @@ -20,85 +45,331 @@ function cleanCandidate(value: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } +function readTextObject( + value: SlackTextObject | undefined, + defaultType?: "plain_text" | "mrkdwn", +): string | undefined { + const text = cleanCandidate(value?.text); + return text && (value?.type ?? defaultType) === "plain_text" ? escapeSlackMrkdwn(text) : text; +} + function readSectionText(block: SlackBlockWithFields): string | undefined { - return cleanCandidate(block.text?.text); + const parts = [ + readTextObject(block.text), + ...(block.fields?.map((field) => readTextObject(field)).filter(Boolean) ?? []), + ...(block.accessory ? readSlackActionElementText(block.accessory) : []), + ].filter((value): value is string => Boolean(value)); + return parts.length > 0 ? parts.join("\n") : undefined; } function readHeaderText(block: SlackBlockWithFields): string | undefined { - return cleanCandidate(block.text?.text); + return readTextObject(block.text, "plain_text"); } function readImageText(block: SlackBlockWithFields): string | undefined { - return cleanCandidate(block.alt_text) ?? cleanCandidate(block.title?.text); + const altText = cleanCandidate(block.alt_text); + return ( + (altText ? escapeSlackMrkdwn(altText) : undefined) ?? readTextObject(block.title, "plain_text") + ); } function readVideoText(block: SlackBlockWithFields): string | undefined { - return cleanCandidate(block.title?.text) ?? cleanCandidate(block.alt_text); + const altText = cleanCandidate(block.alt_text); + return ( + readTextObject(block.title, "plain_text") ?? (altText ? escapeSlackMrkdwn(altText) : undefined) + ); } function readContextText(block: SlackBlockWithFields): string | undefined { if (!Array.isArray(block.elements)) { return undefined; } - const textParts = block.elements - .map((element) => cleanCandidate(element.text)) + const textParts = (block.elements as SlackTextObject[]) + .map((element) => readTextObject(element, "plain_text")) .filter((value): value is string => Boolean(value)); return textParts.length > 0 ? textParts.join(" ") : undefined; } -export function buildSlackBlocksFallbackText(blocks: (Block | KnownBlock)[]): string { +function isTextOnlyContextBlock(block: SlackBlockWithFields): boolean { + if (!Array.isArray(block.elements) || block.elements.length === 0) { + return false; + } + return block.elements.every((rawElement) => { + if (!rawElement || typeof rawElement !== "object" || Array.isArray(rawElement)) { + return false; + } + const element = rawElement as SlackTextObject; + return ( + (element.type === "plain_text" || element.type === "mrkdwn") && + readTextObject(element) !== undefined + ); + }); +} + +function readSlackRichTextLeaf(element: SlackRichTextElement): string { + switch (element.type) { + case "text": + return typeof element.text === "string" ? escapeSlackMrkdwn(element.text) : ""; + case "link": { + const value = element.text ?? element.url; + return typeof value === "string" ? escapeSlackMrkdwn(value) : ""; + } + case "user": + return element.user_id ? `<@${element.user_id}>` : ""; + case "channel": + return element.channel_id ? `<#${element.channel_id}>` : ""; + case "usergroup": + return element.usergroup_id ? `` : ""; + case "broadcast": + return element.range ? `` : ""; + case "emoji": + return element.name ? `:${element.name}:` : ""; + case "date": + return element.fallback ? escapeSlackMrkdwn(element.fallback) : ""; + default: + return ""; + } +} + +function readSlackRichTextElements(elements: unknown): string { + if (!Array.isArray(elements)) { + return ""; + } + const parts: string[] = []; + for (const rawElement of elements) { + if (!rawElement || typeof rawElement !== "object" || Array.isArray(rawElement)) { + continue; + } + const element = rawElement as SlackRichTextElement; + if (element.type === "rich_text_list") { + const items = (element.elements ?? []) + .map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? readSlackRichTextElements((item as SlackRichTextElement).elements) + : "", + ) + .filter(Boolean); + if (items.length > 0) { + parts.push(items.join("\n")); + } + continue; + } + if ( + element.type === "rich_text_section" || + element.type === "rich_text_preformatted" || + element.type === "rich_text_quote" + ) { + parts.push(readSlackRichTextElements(element.elements)); + continue; + } + parts.push(readSlackRichTextLeaf(element)); + } + return parts.join(""); +} + +function readRichText(block: SlackBlockWithFields): string | undefined { + const text = readSlackRichTextElements(block.elements).trim(); + return text || undefined; +} + +function readSlackActionElementText(element: SlackActionElement): string[] { + if (element.type === "button") { + const label = readTextObject(element.text, "plain_text"); + if (!label) { + return []; + } + const rawUrl = cleanCandidate(element.url); + const url = rawUrl ? escapeSlackMrkdwn(rawUrl) : undefined; + return [`- ${label}${url ? `: ${url}` : ""}`]; + } + if (element.type === "static_select") { + const labels = + element.options + ?.map((option) => readTextObject(option.text, "plain_text")) + .filter((label): label is string => Boolean(label)) ?? []; + if (labels.length === 0) { + return []; + } + const heading = readTextObject(element.placeholder, "plain_text") ?? "Options"; + return [`${heading}:\n${labels.map((label) => `- ${label}`).join("\n")}`]; + } + return []; +} + +function readActionsText(block: SlackBlockWithFields): string | undefined { + if (!Array.isArray(block.elements)) { + return undefined; + } + const parts = (block.elements as unknown as SlackActionElement[]).flatMap( + readSlackActionElementText, + ); + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function readSlackBlockFallbackText(raw: unknown): string | undefined { + const block = raw as SlackBlockWithFields; + switch (block.type) { + case "header": + return readHeaderText(block); + case "section": + return readSectionText(block); + case "rich_text": + return readRichText(block); + case "image": + return readImageText(block) ?? "Shared an image"; + case "video": + return readVideoText(block) ?? "Shared a video"; + case "file": + return "Shared a file"; + case "context": + return readContextText(block); + case "actions": + return readActionsText(block); + case "data_visualization": + return renderSlackDataVisualizationMrkdwnFallbackText(block); + case "data_table": + return renderSlackDataTableMrkdwnFallbackText(block); + default: + return undefined; + } +} + +export function buildSlackBlocksFallbackText(blocks: readonly unknown[]): string { for (const raw of blocks) { - const block = raw as SlackBlockWithFields; - switch (block.type) { - case "header": { - const text = readHeaderText(block); - if (text) { - return text; - } - break; - } - case "section": { - const text = readSectionText(block); - if (text) { - return text; - } - break; - } - case "image": { - const text = readImageText(block); - if (text) { - return text; - } - return "Shared an image"; - } - case "video": { - const text = readVideoText(block); - if (text) { - return text; - } - return "Shared a video"; - } - case "file": { - return "Shared a file"; - } - case "context": { - const text = readContextText(block); - if (text) { - return text; - } - break; - } - case "data_visualization": { - const text = renderSlackDataVisualizationFallbackText(block); - if (text) { - return text; - } - break; - } - default: - break; + const text = readSlackBlockFallbackText(raw); + if (text) { + return text; } } return "Shared a Block Kit message"; } + +/** Build complete screen-reader fallback text for every retained sibling block. */ +export function buildSlackBlocksAccessibleFallbackText(blocks: readonly unknown[]): string { + const parts = blocks + .map(readSlackBlockFallbackText) + .filter((text): text is string => Boolean(text)); + return parts.length > 0 ? parts.join("\n\n") : "Shared a Block Kit message"; +} + +/** Keep native-data posts compact while their complete fallback is sent separately. */ +export function buildSlackBlocksCompactAccessibleFallbackText(blocks: readonly unknown[]): string { + const parts = blocks + .map((raw) => { + const fallback = readSlackBlockFallbackText(raw); + return hasSlackNativeDataBlock([raw]) ? fallback?.split("\n", 1)[0] : fallback; + }) + .filter((text): text is string => Boolean(text)); + return parts.length > 0 ? parts.join("\n\n") : "Shared a Block Kit message"; +} + +/** Keep only native tables whose caption-level accessibility text fits the block post. */ +export function retainSlackDataTablesWithinCompactFallback( + blocks: readonly T[], + limit: number, +): T[] { + const retainedTables = new Set(); + for (const block of blocks) { + if ((block as SlackBlockWithFields).type !== "data_table") { + continue; + } + const candidate = blocks.filter( + (entry) => + (entry as SlackBlockWithFields).type !== "data_table" || + retainedTables.has(entry) || + entry === block, + ); + if (buildSlackBlocksCompactAccessibleFallbackText(candidate).length <= limit) { + retainedTables.add(block); + } + } + return blocks.filter( + (block) => (block as SlackBlockWithFields).type !== "data_table" || retainedTables.has(block), + ); +} + +/** Preserve non-data siblings when a later text part owns rejected native-data fallback. */ +export function buildSlackDeferredNativeDataRejectionFallback(blocks: readonly T[]): { + blocks: T[]; + text: string; +} { + const retainedBlocks = blocks.filter((block) => !hasSlackNativeDataBlock([block])); + return { + blocks: retainedBlocks, + text: + retainedBlocks.length > 0 + ? buildSlackBlocksAccessibleFallbackText(retainedBlocks) + : buildSlackBlocksCompactAccessibleFallbackText(blocks), + }; +} + +/** True when visible text can replace this block without losing interaction or media. */ +export function isSlackBlockRepresentedByTextFallback(raw: unknown): boolean { + const block = raw as SlackBlockWithFields; + switch (block.type) { + case "section": + return !block.accessory; + case "context": + return isTextOnlyContextBlock(block); + case "header": + case "rich_text": + case "data_visualization": + case "data_table": + return true; + default: + return false; + } +} + +function comparableFallbackText(value: string): string { + return value + .replace(/(^|\n)[ \t]*[-*•][ \t]+/gu, "$1• ") + .replace(/\s+/gu, " ") + .trim(); +} + +/** Remove fallback paragraphs already represented by retained visible blocks. */ +export function removeSlackBlocksFallbackParagraphs( + text: string, + blocks: readonly unknown[], +): string { + const represented = new Set( + blocks + .map(readSlackBlockFallbackText) + .filter((value): value is string => Boolean(value)) + .map(comparableFallbackText), + ); + return text + .split(/\n{2,}/u) + .filter((paragraph) => !represented.has(comparableFallbackText(paragraph))) + .join("\n\n") + .trim(); +} + +/** Append complete block accessibility text without repeating represented paragraphs. */ +export function appendSlackBlocksAccessibleFallbackText( + text: string, + blocks: readonly unknown[], +): string { + const parts = text + .split(/\n{2,}/u) + .map((part) => part.trim()) + .filter(Boolean); + const seen = new Set(parts.map(comparableFallbackText)); + for (const block of blocks) { + const fallback = readSlackBlockFallbackText(block); + const comparable = fallback ? comparableFallbackText(fallback) : ""; + if ( + !fallback || + !comparable || + seen.has(comparable) || + parts.some((part) => comparableFallbackText(part).includes(comparable)) + ) { + continue; + } + seen.add(comparable); + parts.push(fallback); + } + return parts.join("\n\n"); +} diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index c2385afd0162..58dc66684251 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -3,7 +3,6 @@ import type { Block, KnownBlock } from "@slack/web-api"; import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; import { reduceInteractiveReply, - renderMessagePresentationChartFallbackText, resolveMessagePresentationControlValue, } from "openclaw/plugin-sdk/interactive-runtime"; import type { @@ -14,12 +13,19 @@ import type { MessagePresentationSelectBlock, } from "openclaw/plugin-sdk/interactive-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + buildSlackDataTableBlock, + countSlackDataTableBlocksCellCharacters, + countSlackDataTableCellCharacters, + SLACK_DATA_TABLE_CELL_CHARACTERS_MAX, +} from "./data-table.js"; import { buildSlackDataVisualizationBlock, canRenderSlackDataVisualization, hasSlackDataVisualizationBlock, SLACK_DATA_VISUALIZATION_BLOCKS_MAX, } from "./data-visualization.js"; +import { renderSlackMessagePresentationChartFallbackText } from "./presentation-fallback.js"; import { SLACK_ACTION_BLOCK_ELEMENTS_MAX, SLACK_ACTION_LABEL_MAX, @@ -42,6 +48,7 @@ export type SlackBlock = Block | KnownBlock; type SlackBlockRenderOptions = { buttonIndexOffset?: number; + dataTableCellCharacterCountOffset?: number; dataVisualizationCountOffset?: number; selectIndexOffset?: number; }; @@ -109,9 +116,11 @@ function readSlackOpenClawBlockIndex(blockId: string, prefix: string): number | return Number.isSafeInteger(value) && value > 0 ? value : undefined; } -/** Resolve existing Block Kit indexes and native chart count before appending portable blocks. */ +/** Resolve existing Block Kit indexes and native-data budgets before appending portable blocks. */ export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackBlockRenderOptions { let buttonIndexOffset = 0; + const dataTableCellCharacterCountOffset = + countSlackDataTableBlocksCellCharacters(blocks) ?? SLACK_DATA_TABLE_CELL_CHARACTERS_MAX + 1; let dataVisualizationCountOffset = 0; let selectIndexOffset = 0; for (const block of blocks ?? []) { @@ -131,7 +140,12 @@ export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackB readSlackOpenClawBlockIndex(blockId, "openclaw_reply_select_") ?? 0, ); } - return { buttonIndexOffset, dataVisualizationCountOffset, selectIndexOffset }; + return { + buttonIndexOffset, + dataTableCellCharacterCountOffset, + dataVisualizationCountOffset, + selectIndexOffset, + }; } /** @@ -254,6 +268,7 @@ export function buildSlackPresentationBlocks( if (!presentation) { return []; } + const renderTablesNatively = canRenderSlackPresentationTables(presentation, options); const blocks: SlackBlock[] = []; if (presentation.title) { blocks.push({ @@ -266,6 +281,7 @@ export function buildSlackPresentationBlocks( }); } let buttonIndex = options.buttonIndexOffset ?? 0; + let dataTableCellCharacterCount = options.dataTableCellCharacterCountOffset ?? 0; let dataVisualizationCount = options.dataVisualizationCountOffset ?? 0; let selectIndex = options.selectIndexOffset ?? 0; for (const block of presentation.blocks) { @@ -313,8 +329,9 @@ export function buildSlackPresentationBlocks( elements: [ { type: "mrkdwn", + verbatim: true, text: truncateSlackText( - renderMessagePresentationChartFallbackText(block), + renderSlackMessagePresentationChartFallbackText(block), SLACK_SECTION_TEXT_MAX, ), }, @@ -323,6 +340,19 @@ export function buildSlackPresentationBlocks( } continue; } + if (block.type === "table") { + if (!renderTablesNatively) { + continue; + } + const rendered = buildSlackDataTableBlock(block, { + cellCharacterCountOffset: dataTableCellCharacterCount, + }); + if (rendered) { + dataTableCellCharacterCount += countSlackDataTableCellCharacters(rendered); + blocks.push(rendered); + } + continue; + } if (block.type === "select") { const rendered = buildSlackPresentationSelectBlock(block, selectIndex + 1); if (rendered) { @@ -391,37 +421,88 @@ function resolveSlackPresentationButtonTarget( return url ? { url } : value ? { value } : undefined; } +/** True when every portable table fits Slack's native per-message table budget. */ +export function canRenderSlackPresentationTables( + presentation: MessagePresentation, + options: SlackBlockRenderOptions = {}, +): boolean { + let cellCharacterCount = options.dataTableCellCharacterCountOffset ?? 0; + for (const block of presentation.blocks) { + if (block.type !== "table") { + continue; + } + const rendered = buildSlackDataTableBlock(block, { + cellCharacterCountOffset: cellCharacterCount, + }); + if (!rendered) { + return false; + } + cellCharacterCount += countSlackDataTableCellCharacters(rendered); + } + return true; +} + /** True when native Slack rendering preserves every portable control. */ -export function canRenderSlackPresentation(presentation: MessagePresentation): boolean { +export function canRenderSlackPresentation( + presentation: MessagePresentation, + options: SlackBlockRenderOptions = {}, +): boolean { if (presentation.title && !isWithinSlackLimit(presentation.title.trim(), SLACK_HEADER_TEXT_MAX)) { return false; } - return presentation.blocks.every((block) => { + if (!canRenderSlackPresentationTables(presentation, options)) { + return false; + } + for (const block of presentation.blocks) { if (block.type === "text" || block.type === "context") { - return isWithinSlackLimit(block.text.trim(), SLACK_SECTION_TEXT_MAX); + if (!isWithinSlackLimit(block.text.trim(), SLACK_SECTION_TEXT_MAX)) { + return false; + } + continue; } if (block.type === "buttons") { - return ( + const allButtonsRenderable = block.buttons.length <= SLACK_ACTION_BLOCK_ELEMENTS_MAX && - block.buttons.every((button) => resolveSlackPresentationButtonTarget(button) !== undefined) - ); + block.buttons.every( + (button) => + isWithinSlackLimit(button.label, SLACK_ACTION_LABEL_MAX) && + resolveSlackPresentationButtonTarget(button) !== undefined, + ); + if (!allButtonsRenderable) { + return false; + } + continue; } if (block.type === "select") { - return ( + const allOptionsRenderable = block.options.length <= SLACK_STATIC_SELECT_OPTIONS_MAX && - block.options.every((option) => - isRenderableSlackOption({ - label: option.label, - value: resolveSlackControlValue(option), - }), - ) - ); + (!block.placeholder || isWithinSlackLimit(block.placeholder, SLACK_ACTION_LABEL_MAX)) && + block.options.every( + (option) => + isWithinSlackLimit(option.label, SLACK_ACTION_LABEL_MAX) && + isRenderableSlackOption({ + label: option.label, + value: resolveSlackControlValue(option), + }), + ); + if (!allOptionsRenderable) { + return false; + } + continue; } if (block.type === "chart") { - return canRenderSlackDataVisualization(block); + if (!canRenderSlackDataVisualization(block)) { + return false; + } + // The renderer preserves valid charts beyond Slack's native two-block + // budget as visible context, so count overflow is still lossless here. + continue; } - return true; - }); + if (block.type === "table") { + continue; + } + } + return true; } function buildSlackPresentationSelectBlock( diff --git a/extensions/slack/src/blocks.test.ts b/extensions/slack/src/blocks.test.ts index 865b284e4986..4e5d2e18fa48 100644 --- a/extensions/slack/src/blocks.test.ts +++ b/extensions/slack/src/blocks.test.ts @@ -42,6 +42,27 @@ describe("buildSlackBlocksFallbackText", () => { ).toBe("Revenue mix (pie chart)\n- Product: 60\n- Services: 40"); }); + it("uses complete data table text", () => { + expect( + buildSlackBlocksFallbackText([ + { + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_number", value: 125000, text: "125000" }, + ], + ], + }, + ] as never), + ).toBe("Pipeline report (table)\n- Account: Acme; ARR: 125000"); + }); + it("uses generic defaults for file and unknown blocks", () => { expect( buildSlackBlocksFallbackText([ diff --git a/extensions/slack/src/channel.message-adapter.test.ts b/extensions/slack/src/channel.message-adapter.test.ts index db54f1326d90..f8a3b8ae6ec4 100644 --- a/extensions/slack/src/channel.message-adapter.test.ts +++ b/extensions/slack/src/channel.message-adapter.test.ts @@ -239,11 +239,11 @@ describe("slack channel message adapter", () => { const [to, text, options] = expectLastSendSlackCall(); expect(to).toBe("C123"); - expect(text).toBe("Fallback"); + expect(text).toBe("Fallback\n\nStatus"); expect(options.blocks).toEqual([ { type: "section", - text: { type: "mrkdwn", text: "Fallback" }, + text: { type: "mrkdwn", text: "Fallback", verbatim: true }, }, { type: "header", diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 2cb17b9fe635..bff644e2cf20 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1262,7 +1262,7 @@ describe("slackPlugin outbound", () => { mediaLocalRoots: ["/tmp/media"], }); expect(requireMockCallArgValue(sendSlack, 2, 0)).toBe("C999"); - expect(requireMockCallArgValue(sendSlack, 2, 1)).toBe("hello"); + expect(requireMockCallArgValue(sendSlack, 2, 1)).toBe("hello\n\nBlock body"); expect(requireMockCallArg(sendSlack, 2, 2).blocks).toEqual([ { type: "section", @@ -1385,6 +1385,9 @@ describe("slackPlugin agentPrompt", () => { expect(hints).toContain( "- Slack plain text sends: write standard Markdown; OpenClaw converts it to Slack mrkdwn, including `**bold**`, headings, lists, and `[label](url)` links.", ); + expect(hints).toContain( + "- For row-and-column data, use an explicit `presentation` table block; Slack renders it as a native table and retains a linear text summary for accessibility. Markdown pipe tables are not auto-promoted.", + ); expect(hints).toContain( "- When mentioning Slack users, use the stable `<@USER_ID>` token from Slack context instead of plain `@name` text so Slack notifies and links the user.", ); @@ -1418,6 +1421,9 @@ describe("slackPlugin agentPrompt", () => { expect(hints).toContain( "- Slack plain text sends: write standard Markdown; OpenClaw converts it to Slack mrkdwn, including `**bold**`, headings, lists, and `[label](url)` links.", ); + expect(hints).toContain( + "- For row-and-column data, use an explicit `presentation` table block; Slack renders it as a native table and retains a linear text summary for accessibility. Markdown pipe tables are not auto-promoted.", + ); expect(hints).toContain( "- When mentioning Slack users, use the stable `<@USER_ID>` token from Slack context instead of plain `@name` text so Slack notifies and links the user.", ); diff --git a/extensions/slack/src/client-delivery.ts b/extensions/slack/src/client-delivery.ts index 90343a00e660..f3401c904922 100644 --- a/extensions/slack/src/client-delivery.ts +++ b/extensions/slack/src/client-delivery.ts @@ -10,12 +10,14 @@ import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared"; import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - appendSlackDataVisualizationFallbackText, - hasSlackDataVisualizationBlock, - isSlackInvalidBlocksError, -} from "./data-visualization.js"; +import { hasSlackDataTableBlock } from "./data-table.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; +import { + appendSlackNativeDataFallbackText, + buildSlackNativeDataFallbackBlocks, + hasSlackNativeDataBlock, + isSlackInvalidBlocksError, +} from "./native-data-blocks.js"; import { postSlackMessageWithIdentityFallback, type SlackPostMessageIdentity, @@ -226,6 +228,10 @@ export async function postSlackMessageBestEffort(params: { replyBroadcast?: boolean; identity?: SlackPostMessageIdentity; blocks?: (Block | KnownBlock)[]; + nativeDataRejectionFallback?: { + text: string; + blocks?: (Block | KnownBlock)[]; + }; metadata?: MessageMetadata; unfurl?: SlackUnfurlOptions; }) { @@ -240,21 +246,38 @@ export async function postSlackMessageBestEffort(params: { identity, }; } catch (error) { - if (!hasSlackDataVisualizationBlock(payload.blocks) || !isSlackInvalidBlocksError(error)) { + if (!hasSlackNativeDataBlock(payload.blocks) || !isSlackInvalidBlocksError(error)) { throw error; } const { blocks, ...textPayload } = payload; - // Slack rejects unsupported chart blocks before posting, so one text-only - // retry preserves the complete accessible summary without duplicating a send. - logVerbose("slack send: data visualization rejected, retrying with text fallback"); + if (params.nativeDataRejectionFallback) { + // A later text part already owns the complete native-data fallback. + // Retrying only retained siblings avoids rendering every table row twice. + const rejectionFallback = params.nativeDataRejectionFallback; + return { + response: await withSlackDnsRequestRetry("chat.postMessage", () => + postChatMessage({ + ...textPayload, + text: rejectionFallback.text, + ...(rejectionFallback.blocks?.length ? { blocks: rejectionFallback.blocks } : {}), + }), + ), + identity, + }; + } + // Replace only native data blocks. If an authored sibling is actually invalid, + // the retry still fails closed instead of silently discarding its controls. + logVerbose("slack send: native data block rejected, retrying with text fallback"); + const fallbackText = appendSlackNativeDataFallbackText(payload.text ?? "", blocks); + const fallbackBlocks = buildSlackNativeDataFallbackBlocks(blocks); return { response: await withSlackDnsRequestRetry("chat.postMessage", () => postChatMessage({ ...textPayload, - text: truncateSlackText( - appendSlackDataVisualizationFallbackText(payload.text ?? "", blocks), - SLACK_TEXT_LIMIT, - ), + text: hasSlackDataTableBlock(blocks) + ? fallbackText + : truncateSlackText(fallbackText, SLACK_TEXT_LIMIT), + ...(fallbackBlocks?.length ? { blocks: fallbackBlocks } : {}), }), ), identity, diff --git a/extensions/slack/src/data-table.test.ts b/extensions/slack/src/data-table.test.ts new file mode 100644 index 000000000000..c3d1eac1af4c --- /dev/null +++ b/extensions/slack/src/data-table.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + buildSlackDataTableBlock, + canRenderSlackDataTable, + countSlackDataTableBlocksCellCharacters, + countSlackDataTableCellCharacters, + hasSlackDataTableBlock, + renderSlackDataTableFallbackText, + SLACK_DATA_TABLE_CELL_CHARACTERS_MAX, +} from "./data-table.js"; + +describe("Slack data table blocks", () => { + it("maps portable cells to Slack's current native shape", () => { + expect( + buildSlackDataTableBlock({ + type: "table", + caption: "Pipeline report", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125_000], + ["Globex", "Review", 82_000], + ], + rowHeaderColumnIndex: 0, + }), + ).toEqual({ + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "Stage" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_text", text: "Won" }, + { type: "raw_number", value: 125_000, text: "125000" }, + ], + [ + { type: "raw_text", text: "Globex" }, + { type: "raw_text", text: "Review" }, + { type: "raw_number", value: 82_000, text: "82000" }, + ], + ], + row_header_column_index: 0, + }); + }); + + it("enforces Slack's column, data-row, and aggregate character limits", () => { + const base = { + type: "table" as const, + caption: "Limits", + headers: ["Value"], + rows: [["ok"]], + }; + expect(canRenderSlackDataTable(base)).toBe(true); + expect( + canRenderSlackDataTable({ + ...base, + headers: Array.from({ length: 21 }, (_, index) => `H${String(index)}`), + rows: [Array.from({ length: 21 }, () => "value")], + }), + ).toBe(false); + expect( + canRenderSlackDataTable({ + ...base, + rows: Array.from({ length: 101 }, () => ["value"]), + }), + ).toBe(false); + expect( + canRenderSlackDataTable(base, { + cellCharacterCountOffset: SLACK_DATA_TABLE_CELL_CHARACTERS_MAX - 7, + }), + ).toBe(true); + expect( + canRenderSlackDataTable(base, { + cellCharacterCountOffset: SLACK_DATA_TABLE_CELL_CHARACTERS_MAX - 6, + }), + ).toBe(false); + }); + + it("counts display text across raw tables and reports malformed native tables", () => { + const table = { + type: "data_table", + caption: "Values", + rows: [ + [{ type: "raw_text", text: "Name" }], + [{ type: "raw_number", value: 125_000, text: "$125k" }], + ], + }; + expect(countSlackDataTableCellCharacters(table)).toBe(9); + expect(countSlackDataTableBlocksCellCharacters([{ type: "section" }, table, table])).toBe(18); + expect( + countSlackDataTableBlocksCellCharacters([ + table, + { type: "data_table", caption: "Broken", rows: [] }, + ]), + ).toBeUndefined(); + }); + + it("extracts complete raw-number and rich-text display values for fallback", () => { + expect( + renderSlackDataTableFallbackText({ + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + { type: "raw_text", text: "Owner" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_number", value: 125_000, text: "$125k" }, + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "user", user_id: "U123" }, + { type: "text", text: " ready" }, + ], + }, + ], + }, + ], + ], + row_header_column_index: 0, + }), + ).toBe("Pipeline report (table)\n- Account: Acme; ARR: $125k; Owner: <@U123> ready"); + }); + + it("detects native tables and keeps a caption fallback for malformed raw blocks", () => { + expect(hasSlackDataTableBlock([{ type: "section" }])).toBe(false); + expect(hasSlackDataTableBlock([{ type: "data_table" }])).toBe(true); + expect( + renderSlackDataTableFallbackText({ + type: "data_table", + caption: " Provider table ", + rows: [], + }), + ).toBe("Provider table"); + }); +}); diff --git a/extensions/slack/src/data-table.ts b/extensions/slack/src/data-table.ts new file mode 100644 index 000000000000..6e3be353cb21 --- /dev/null +++ b/extensions/slack/src/data-table.ts @@ -0,0 +1,322 @@ +// Slack data_table Block Kit contract, projection, and text fallback. +import type { Block } from "@slack/web-api"; +import { + renderMessagePresentationTableFallbackText, + type MessagePresentationTableBlock, +} from "openclaw/plugin-sdk/interactive-runtime"; +import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; +import { renderSlackMessagePresentationTableFallbackText } from "./presentation-fallback.js"; + +export const SLACK_DATA_TABLE_COLUMNS_MAX = 20; +export const SLACK_DATA_TABLE_ROWS_MAX = 100; +export const SLACK_DATA_TABLE_CELL_CHARACTERS_MAX = 10_000; + +type SlackDataTableRawTextCell = { + type: "raw_text"; + text: string; +}; + +type SlackDataTableRawNumberCell = { + type: "raw_number"; + value: number; + text: string; +}; + +export type SlackDataTableCell = SlackDataTableRawTextCell | SlackDataTableRawNumberCell; + +export type SlackDataTableBlock = Block & { + type: "data_table"; + caption: string; + rows: SlackDataTableCell[][]; + row_header_column_index?: number; +}; + +type SlackDataTableBuildOptions = { + cellCharacterCountOffset?: number; +}; + +type ParsedSlackDataTable = { + caption: string; + headers: string[]; + rows: string[][]; + cellCharacterCount: number; +}; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function countCharacters(value: string): number { + return Array.from(value).length; +} + +function readRichTextLeaf(record: Record): string { + const text = readNonEmptyString(record.text); + if (text) { + return text; + } + switch (record.type) { + case "link": + return readNonEmptyString(record.url) ?? ""; + case "user": { + const userId = readNonEmptyString(record.user_id); + return userId ? `<@${userId}>` : ""; + } + case "channel": { + const channelId = readNonEmptyString(record.channel_id); + return channelId ? `<#${channelId}>` : ""; + } + case "usergroup": { + const usergroupId = readNonEmptyString(record.usergroup_id); + return usergroupId ? `` : ""; + } + case "broadcast": { + const range = readNonEmptyString(record.range); + return range ? `` : ""; + } + case "emoji": { + const name = readNonEmptyString(record.name); + return name ? `:${name}:` : ""; + } + case "date": + return readNonEmptyString(record.fallback) ?? ""; + default: + return ""; + } +} + +function readRichTextElements(value: unknown): string { + if (!Array.isArray(value)) { + return ""; + } + const parts: string[] = []; + for (const rawElement of value) { + const element = asRecord(rawElement); + if (!element) { + continue; + } + if (Array.isArray(element.elements)) { + const rendered = readRichTextElements(element.elements); + if (rendered) { + parts.push(rendered); + } + continue; + } + const rendered = readRichTextLeaf(element); + if (rendered) { + parts.push(rendered); + } + } + return parts.join(""); +} + +function readSlackDataTableCell(value: unknown, allowRichText: boolean): string | undefined { + const cell = asRecord(value); + if (!cell) { + return undefined; + } + if (cell.type === "raw_text") { + return readNonEmptyString(cell.text); + } + if (cell.type === "raw_number") { + return typeof cell.value === "number" && Number.isFinite(cell.value) + ? readNonEmptyString(cell.text) + : undefined; + } + if (allowRichText && cell.type === "rich_text") { + return readNonEmptyString(readRichTextElements(cell.elements)); + } + return undefined; +} + +function parseSlackDataTable(value: unknown): ParsedSlackDataTable | undefined { + const block = asRecord(value); + const caption = readNonEmptyString(block?.caption); + if (block?.type !== "data_table" || !caption || !Array.isArray(block.rows)) { + return undefined; + } + if (block.rows.length < 2) { + return undefined; + } + const rawHeader = block.rows[0]; + if (!Array.isArray(rawHeader) || rawHeader.length < 1) { + return undefined; + } + const headers = rawHeader.map((cell) => readSlackDataTableCell(cell, false)); + if (!headers.every((header): header is string => Boolean(header))) { + return undefined; + } + const rows = block.rows.slice(1).map((rawRow) => { + if (!Array.isArray(rawRow) || rawRow.length !== headers.length) { + return undefined; + } + const cells = rawRow.map((cell) => readSlackDataTableCell(cell, true)); + return cells.every((cell): cell is string => Boolean(cell)) ? cells : undefined; + }); + if (!rows.every((row): row is string[] => Boolean(row))) { + return undefined; + } + const cellCharacterCount = [...headers, ...rows.flat()].reduce( + (total, cell) => total + countCharacters(cell), + 0, + ); + return { caption, headers, rows, cellCharacterCount }; +} + +/** Detect current native table blocks without depending on unreleased Slack SDK types. */ +export function hasSlackDataTableBlock(blocks?: readonly unknown[]): boolean { + return blocks?.some((block) => asRecord(block)?.type === "data_table") ?? false; +} + +/** Count display characters in one structurally valid native table. */ +export function countSlackDataTableCellCharacters(value: SlackDataTableBlock): number; +export function countSlackDataTableCellCharacters(value: unknown): number | undefined; +export function countSlackDataTableCellCharacters(value: unknown): number | undefined { + return parseSlackDataTable(value)?.cellCharacterCount; +} + +/** Count the aggregate native-table cell characters already present in a message. */ +export function countSlackDataTableBlocksCellCharacters( + blocks?: readonly unknown[], +): number | undefined { + let total = 0; + for (const block of blocks ?? []) { + if (!hasSlackDataTableBlock([block])) { + continue; + } + const cellCharacterCount = countSlackDataTableCellCharacters(block); + if (cellCharacterCount === undefined) { + return undefined; + } + total += cellCharacterCount; + } + return total; +} + +function resolvePortableTableCellCharacterCount( + block: MessagePresentationTableBlock, +): number | undefined { + if ( + typeof block.caption !== "string" || + block.caption.trim().length === 0 || + !Array.isArray(block.headers) || + block.headers.length < 1 || + block.headers.length > SLACK_DATA_TABLE_COLUMNS_MAX || + !Array.isArray(block.rows) || + block.rows.length < 1 || + block.rows.length > SLACK_DATA_TABLE_ROWS_MAX || + new Set(block.headers).size !== block.headers.length || + !block.headers.every((header) => typeof header === "string" && header.trim().length > 0) || + (block.rowHeaderColumnIndex !== undefined && + (!Number.isInteger(block.rowHeaderColumnIndex) || + block.rowHeaderColumnIndex < 0 || + block.rowHeaderColumnIndex >= block.headers.length)) + ) { + return undefined; + } + const values: string[] = [...block.headers]; + for (const row of block.rows) { + if (!Array.isArray(row) || row.length !== block.headers.length) { + return undefined; + } + for (const cell of row) { + if (typeof cell === "number") { + if (!Number.isFinite(cell)) { + return undefined; + } + values.push(String(cell)); + continue; + } + if (typeof cell !== "string" || cell.trim().length === 0) { + return undefined; + } + values.push(cell); + } + } + return values.reduce((total, value) => total + countCharacters(value), 0); +} + +/** True when a portable table fits Slack's per-table and per-message contracts. */ +export function canRenderSlackDataTable( + block: MessagePresentationTableBlock, + options: SlackDataTableBuildOptions = {}, +): boolean { + const cellCharacterCountOffset = options.cellCharacterCountOffset ?? 0; + if (!Number.isSafeInteger(cellCharacterCountOffset) || cellCharacterCountOffset < 0) { + return false; + } + const cellCharacterCount = resolvePortableTableCellCharacterCount(block); + return ( + cellCharacterCount !== undefined && + cellCharacterCountOffset + cellCharacterCount <= SLACK_DATA_TABLE_CELL_CHARACTERS_MAX + ); +} + +/** Map a validated portable table to Slack's current app-facing Block Kit shape. */ +export function buildSlackDataTableBlock( + block: MessagePresentationTableBlock, + options: SlackDataTableBuildOptions = {}, +): SlackDataTableBlock | undefined { + if (!canRenderSlackDataTable(block, options)) { + return undefined; + } + const header: SlackDataTableCell[] = block.headers.map((text) => ({ type: "raw_text", text })); + const rows = block.rows.map((row) => + row.map((cell) => + typeof cell === "number" + ? { type: "raw_number", value: cell, text: String(cell) } + : { type: "raw_text", text: cell }, + ), + ); + return { + type: "data_table", + caption: block.caption, + rows: [header, ...rows], + ...(block.rowHeaderColumnIndex !== undefined + ? { row_header_column_index: block.rowHeaderColumnIndex } + : {}), + }; +} + +/** Extract a deterministic accessible summary from a native Slack table block. */ +export function renderSlackDataTableFallbackText(value: unknown): string | undefined { + const block = asRecord(value); + if (block?.type !== "data_table") { + return undefined; + } + const parsed = parseSlackDataTable(block); + if (parsed) { + return renderMessagePresentationTableFallbackText({ + type: "table", + caption: parsed.caption, + headers: parsed.headers, + rows: parsed.rows, + }); + } + return readNonEmptyString(block.caption)?.trim(); +} + +/** Render a native table as mrkdwn without activating raw cell control tokens. */ +export function renderSlackDataTableMrkdwnFallbackText(value: unknown): string | undefined { + const block = asRecord(value); + if (block?.type !== "data_table") { + return undefined; + } + const parsed = parseSlackDataTable(block); + if (parsed) { + return renderSlackMessagePresentationTableFallbackText({ + type: "table", + caption: parsed.caption, + headers: parsed.headers, + rows: parsed.rows, + }); + } + const caption = readNonEmptyString(block.caption)?.trim(); + return caption ? escapeSlackMrkdwn(caption) : undefined; +} diff --git a/extensions/slack/src/data-visualization.test.ts b/extensions/slack/src/data-visualization.test.ts index b213898198cb..7069b4c30343 100644 --- a/extensions/slack/src/data-visualization.test.ts +++ b/extensions/slack/src/data-visualization.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest"; import { - appendSlackDataVisualizationFallbackText, buildSlackDataVisualizationBlock, canRenderSlackDataVisualization, - hasSlackDataVisualizationBlock, - isSlackInvalidBlocksError, renderSlackDataVisualizationFallbackText, } from "./data-visualization.js"; @@ -152,45 +149,4 @@ describe("Slack data visualization blocks", () => { }), ).toBe("Quarterly revenue (line chart)\n- Revenue: Q1: 120; Q2: 145"); }); - - it("detects native charts and structural invalid_blocks errors", () => { - expect(hasSlackDataVisualizationBlock([{ type: "section" }])).toBe(false); - expect(hasSlackDataVisualizationBlock([{ type: "data_visualization" }])).toBe(true); - expect(isSlackInvalidBlocksError({ data: { error: "invalid_blocks" } })).toBe(true); - expect(isSlackInvalidBlocksError({ data: "invalid_blocks" })).toBe(true); - expect(isSlackInvalidBlocksError({ response: { data: { error: "invalid_blocks" } } })).toBe( - true, - ); - expect(isSlackInvalidBlocksError({ response: { data: "invalid_blocks" } })).toBe(true); - expect(isSlackInvalidBlocksError({ error: "INVALID_BLOCKS" })).toBe(true); - expect(isSlackInvalidBlocksError(new Error("invalid_blocks"))).toBe(false); - }); - - it("appends chart data once to text-only fallbacks", () => { - const blocks = [ - { - type: "data_visualization", - title: "Revenue mix", - chart: { - type: "pie", - segments: [ - { label: "Product", value: 60 }, - { label: "Services", value: 40 }, - ], - }, - }, - ]; - const chartText = "Revenue mix (pie chart)\n- Product: 60\n- Services: 40"; - - expect(appendSlackDataVisualizationFallbackText("Overview", blocks)).toBe( - `Overview\n\n${chartText}`, - ); - expect(appendSlackDataVisualizationFallbackText(chartText, blocks)).toBe(chartText); - expect( - appendSlackDataVisualizationFallbackText( - "Revenue mix (pie chart) - Product: 60 - Services: 40", - blocks, - ), - ).toBe("Revenue mix (pie chart) - Product: 60 - Services: 40"); - }); }); diff --git a/extensions/slack/src/data-visualization.ts b/extensions/slack/src/data-visualization.ts index 2bd6a1e61c4e..9954d963d4a7 100644 --- a/extensions/slack/src/data-visualization.ts +++ b/extensions/slack/src/data-visualization.ts @@ -5,6 +5,8 @@ import { renderMessagePresentationChartFallbackText, type MessagePresentationChartBlock, } from "openclaw/plugin-sdk/interactive-runtime"; +import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; +import { renderSlackMessagePresentationChartFallbackText } from "./presentation-fallback.js"; export const SLACK_CHART_TITLE_MAX = 50; export const SLACK_CHART_LABEL_MAX = 20; @@ -49,22 +51,6 @@ export function hasSlackDataVisualizationBlock(blocks?: readonly unknown[]): boo return blocks?.some((block) => asRecord(block)?.type === "data_visualization") ?? false; } -/** Match Slack's Web API and response_url `invalid_blocks` error shapes. */ -export function isSlackInvalidBlocksError(error: unknown): boolean { - const record = asRecord(error); - const rawData = record?.data; - const data = asRecord(rawData); - const rawResponseData = asRecord(record?.response)?.data; - const responseData = asRecord(rawResponseData); - const code = - data?.error ?? - (typeof rawData === "string" ? rawData : undefined) ?? - responseData?.error ?? - (typeof rawResponseData === "string" ? rawResponseData : undefined) ?? - record?.error; - return typeof code === "string" && code.trim().toLowerCase() === "invalid_blocks"; -} - function isStringWithin(value: unknown, maxLength: number): value is string { return ( typeof value === "string" && value.trim().length > 0 && Array.from(value).length <= maxLength @@ -248,16 +234,17 @@ export function renderSlackDataVisualizationFallbackText(value: unknown): string return typeof block.title === "string" && block.title.trim() ? block.title.trim() : undefined; } -/** Preserve every native chart's data when Slack requires a text-only retry. */ -export function appendSlackDataVisualizationFallbackText( - text: string, - blocks?: readonly unknown[], -): string { - const base = text.trim(); - const comparableBase = base.replace(/\s+/gu, " "); - const chartTexts = (blocks ?? []) - .map(renderSlackDataVisualizationFallbackText) - .filter((chartText): chartText is string => Boolean(chartText)) - .filter((chartText) => !comparableBase.includes(chartText.replace(/\s+/gu, " "))); - return [base, ...chartTexts].filter(Boolean).join("\n\n"); +/** Render a native chart as mrkdwn without activating raw data control tokens. */ +export function renderSlackDataVisualizationMrkdwnFallbackText(value: unknown): string | undefined { + const block = asRecord(value); + if (block?.type !== "data_visualization") { + return undefined; + } + const parsed = parseSlackDataVisualizationBlock(block); + if (parsed) { + return renderSlackMessagePresentationChartFallbackText(parsed); + } + return typeof block.title === "string" && block.title.trim() + ? escapeSlackMrkdwn(block.title.trim()) + : undefined; } diff --git a/extensions/slack/src/edit-text.ts b/extensions/slack/src/edit-text.ts index 6f122197f66f..c6d354c09626 100644 --- a/extensions/slack/src/edit-text.ts +++ b/extensions/slack/src/edit-text.ts @@ -1,19 +1,20 @@ // Slack plugin module implements edit text behavior. import type { Block, KnownBlock } from "@slack/web-api"; -import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; -import { SLACK_TEXT_LIMIT } from "./limits.js"; -import { truncateSlackText } from "./truncate.js"; +import { + appendSlackBlocksAccessibleFallbackText, + buildSlackBlocksAccessibleFallbackText, +} from "./blocks-fallback.js"; export function buildSlackEditTextPayload( content: string, blocks?: (Block | KnownBlock)[], ): string { const trimmedContent = content.trim(); - if (trimmedContent) { - return trimmedContent; - } if (blocks?.length) { - return truncateSlackText(buildSlackBlocksFallbackText(blocks), SLACK_TEXT_LIMIT); + return ( + appendSlackBlocksAccessibleFallbackText(trimmedContent, blocks) || + buildSlackBlocksAccessibleFallbackText(blocks) + ); } - return " "; + return trimmedContent || " "; } diff --git a/extensions/slack/src/format.ts b/extensions/slack/src/format.ts index 9e97b2fc8d95..dff28ae457a7 100644 --- a/extensions/slack/src/format.ts +++ b/extensions/slack/src/format.ts @@ -1,6 +1,7 @@ // Slack helper module supports format behavior. import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; import { + chunkTextForOutbound, markdownToIR, type MarkdownLinkSpan, renderMarkdownIRChunksWithinLimit, @@ -105,6 +106,80 @@ type SlackMarkdownOptions = { tableMode?: MarkdownTableMode; }; +type SlackCodeMarker = "`" | "```"; + +function tokenizeSlackMrkdwn(text: string): string[] { + const tokens: string[] = []; + for (let index = 0; index < text.length; ) { + if (text.startsWith("```", index)) { + tokens.push("```"); + index += 3; + continue; + } + const entity = ["&", "<", ">"].find((candidate) => text.startsWith(candidate, index)); + if (entity) { + tokens.push(entity); + index += entity.length; + continue; + } + if (text[index] === "<") { + const end = text.indexOf(">", index + 1); + const angleToken = end >= 0 ? text.slice(index, end + 1) : undefined; + if (angleToken && !angleToken.includes("\n") && isAllowedSlackAngleToken(angleToken)) { + tokens.push(angleToken); + index += angleToken.length; + continue; + } + } + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + break; + } + const character = String.fromCodePoint(codePoint); + index += character.length; + if (character === "\\" && index < text.length) { + const escapedCodePoint = text.codePointAt(index); + if (escapedCodePoint !== undefined) { + const escapedCharacter = String.fromCodePoint(escapedCodePoint); + tokens.push(character + escapedCharacter); + index += escapedCharacter.length; + continue; + } + } + tokens.push(character); + } + return tokens; +} + +function resolveSlackCodeMarkerTransition( + active: SlackCodeMarker | undefined, + token: string, +): SlackCodeMarker | undefined | null { + if (token === "```" && active !== "`") { + return active === "```" ? undefined : "```"; + } + if (token === "`" && active !== "```") { + return active === "`" ? undefined : "`"; + } + return null; +} + +function hardSliceSlackToken(token: string, limit: number): string[] { + const chunks: string[] = []; + let chunk = ""; + for (const character of token) { + if (chunk && chunk.length + character.length > limit) { + chunks.push(chunk); + chunk = ""; + } + chunk += character; + } + if (chunk) { + chunks.push(chunk); + } + return chunks; +} + function buildSlackRenderOptions() { return { styleMarkers: { @@ -137,6 +212,73 @@ export function normalizeSlackOutboundText(markdown: string): string { return markdownToSlackMrkdwn(markdown ?? ""); } +/** Chunk already-rendered Slack mrkdwn without splitting entities or code markers. */ +export function chunkSlackMrkdwnText(text: string, limit: number): string[] { + if (text.length <= limit) { + return [text]; + } + const hasProtectedToken = + text.includes("`") || + text.includes("&") || + text.includes("<") || + text.includes(">") || + (text.match(/<[^>\n]+>/gu)?.some(isAllowedSlackAngleToken) ?? false) || + /\\[\s\S]/u.test(text); + if (!hasProtectedToken) { + return chunkTextForOutbound(text, limit); + } + + const chunks: string[] = []; + let activeMarker: SlackCodeMarker | undefined; + let content = ""; + const wrapper = () => + activeMarker && limit > activeMarker.length * 2 ? activeMarker : undefined; + const capacity = () => limit - (wrapper()?.length ?? 0) * 2; + const flush = () => { + if (!content) { + return; + } + const marker = wrapper(); + chunks.push(marker ? `${marker}${content}${marker}` : content); + content = ""; + }; + + for (const token of tokenizeSlackMrkdwn(text)) { + const transition = resolveSlackCodeMarkerTransition(activeMarker, token); + if (transition !== null) { + flush(); + activeMarker = transition; + continue; + } + + const contentLimit = capacity(); + if (token.length > contentLimit) { + flush(); + const marker = wrapper(); + if (activeMarker && isAllowedSlackAngleToken(token)) { + if (marker) { + chunks.push( + ...hardSliceSlackToken(token, contentLimit).map( + (fragment) => `${marker}${fragment}${marker}`, + ), + ); + } else { + chunks.push(...hardSliceSlackToken(escapeSlackMrkdwnSegment(token), limit)); + } + continue; + } + chunks.push(...(token.length <= limit ? [token] : chunkTextForOutbound(token, limit))); + continue; + } + if (content && content.length + token.length > contentLimit) { + flush(); + } + content += token; + } + flush(); + return chunks; +} + export function markdownToSlackMrkdwnChunks( markdown: string, limit: number, diff --git a/extensions/slack/src/limits.ts b/extensions/slack/src/limits.ts index ba093e11f384..ded9b8838ddd 100644 --- a/extensions/slack/src/limits.ts +++ b/extensions/slack/src/limits.ts @@ -1,2 +1,3 @@ // Slack plugin module implements limits behavior. export const SLACK_TEXT_LIMIT = 8000; +export const SLACK_RESPONSE_URL_MAX_USES = 5; diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 3d23c1f425bd..33f247986705 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -56,6 +56,21 @@ function elementAt(block: Record, index: number) { return element; } +function largeTablePresentation() { + return { + blocks: [ + { + type: "table", + caption: "Large pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + index === 0 ? "<@U123>" : `account-${String(index)} ${"x".repeat(110)}`, + ]), + }, + ], + }; +} + describe("handleSlackMessageAction", () => { it("defaults reactions to the current inbound Slack message", async () => { const invoke = createInvokeSpy(); @@ -111,7 +126,8 @@ describe("handleSlackMessageAction", () => { const action = firstAction(invoke); expect(blockAt(action, 0).type).toBe("section"); - const actionsBlock = blockAt(action, 1); + expect(blockAt(action, 1).type).toBe("section"); + const actionsBlock = blockAt(action, 2); expect(actionsBlock.type).toBe("actions"); expect(elementAt(actionsBlock, 0).value).toBe("approve"); }); @@ -149,7 +165,7 @@ describe("handleSlackMessageAction", () => { expect(action.content).toBe( "Revenue summary\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40", ); - expect(blockAt(action, 0)).toEqual({ + expect(blockAt(action, 1)).toEqual({ type: "data_visualization", title: "Revenue mix", chart: { @@ -162,6 +178,273 @@ describe("handleSlackMessageAction", () => { }); }); + it("sends native tables with a complete accessible text representation", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "send", + cfg: {}, + params: { + to: "channel:C1", + message: "Pipeline summary", + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "ARR"], + rows: [ + ["Acme", 125000], + ["Globex", 82000], + ], + }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + expect(firstAction(invoke).content).toBe( + "Pipeline summary\n\nPipeline (table)\n- Account: Acme; ARR: 125000\n- Account: Globex; ARR: 82000", + ); + }); + + it("edits native tables with a complete accessible text representation", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "edit", + cfg: {}, + params: { + channelId: "C1", + messageId: "171234.567", + message: "Updated pipeline", + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: [["Acme", "Won"]], + }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + expect(firstAction(invoke)).toMatchObject({ + action: "editMessage", + channelId: "C1", + messageId: "171234.567", + content: "Updated pipeline\n\nPipeline (table)\n- Account: Acme; Stage: Won", + }); + }); + + it("routes non-native tables through Slack-safe text with native controls", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "send", + cfg: {}, + params: { + to: "channel:C1", + presentation: largeTablePresentation(), + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + const action = firstAction(invoke); + expect(action.separateTextAndBlocks).toBe(true); + expect(action.textIsSlackMrkdwn).toBe(true); + expect(action.content).toContain("- Account: <@U123>"); + expect(action.content).toContain("- Account: account-99"); + const actionsBlock = blockAt(action, 0); + expect(actionsBlock.type).toBe("actions"); + expect(elementAt(actionsBlock, 0).value).toBe("refresh"); + expect(String(action.content).length).toBeGreaterThan(8000); + }); + + it("keeps lossless presentation controls native during table fallback", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "send", + cfg: {}, + params: { + to: "channel:C1", + message: "Pipeline summary", + presentation: { + title: "Quarterly report", + blocks: [ + { type: "context", text: "Confidential" }, + ...largeTablePresentation().blocks, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + const action = firstAction(invoke); + expect(action.separateTextAndBlocks).toBe(true); + expect(blockAt(action, 0)).toMatchObject({ + type: "actions", + elements: [{ type: "button", value: "refresh" }], + }); + expect(action.content).toContain("Quarterly report"); + expect(action.content).toContain("Confidential"); + expect(action.content).toContain("- Account: account-99"); + expect(action.content).not.toContain("- Refresh"); + }); + + it("keeps a native table while assigning its over-8k fallback one text owner", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "send", + cfg: {}, + params: { + to: "channel:C1", + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + index === 0 ? "<@U123>" : `account-${String(index)} ${"x".repeat(65)}`, + ]), + }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + const action = firstAction(invoke); + expect(action.separateTextAndBlocks).toBe(true); + expect(blockAt(action, 0)).toMatchObject({ type: "data_table", caption: "Pipeline" }); + expect(action.content).toContain("- Account: account-99"); + expect(String(action.content).match(/Pipeline \(table\)/g)).toHaveLength(1); + }); + + it("keeps edit accessibility complete while rendering table fallback beside controls", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "edit", + cfg: {}, + params: { + channelId: "C1", + messageId: "171234.567", + presentation: { + title: "Quarterly report", + blocks: [ + { + type: "table", + caption: "Wide pipeline", + headers: Array.from({ length: 21 }, (_entry, index) => `Column ${String(index)}`), + rows: [Array.from({ length: 21 }, (_entry, index) => `Value ${String(index)}`)], + }, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + const action = firstAction(invoke); + expect(action.content).toContain("Quarterly report"); + expect(action.content).toContain("- Refresh"); + expect(blockAt(action, 0).type).toBe("actions"); + expect(blockAt(action, 1)).toMatchObject({ + type: "section", + text: { type: "mrkdwn", verbatim: true }, + }); + }); + + it("uses text-only edits for non-native tables that fit one message", async () => { + const invoke = createInvokeSpy(); + const headers = Array.from({ length: 21 }, (_entry, index) => `Column ${String(index)}`); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "edit", + cfg: {}, + params: { + channelId: "C1", + messageId: "171234.567", + presentation: { + blocks: [ + { + type: "table", + caption: "Wide pipeline", + headers, + rows: [headers.map((_header, index) => `Value ${String(index)}`)], + }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + const action = firstAction(invoke); + expect(action.blocks).toBeUndefined(); + expect(action.content).toContain("Column 20: Value 20"); + }); + + it("rejects non-native table edits whose complete fallback cannot fit", async () => { + const invoke = createInvokeSpy(); + + await expect( + handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "edit", + cfg: {}, + params: { + channelId: "C1", + messageId: "171234.567", + presentation: largeTablePresentation(), + }, + } as never, + invoke: invoke as never, + }), + ).rejects.toThrow( + "Slack presentation fallback exceeds OpenClaw's 8000-character per-edit limit", + ); + expect(invoke).not.toHaveBeenCalled(); + }); + it("keeps generated Slack control ids unique when presentation and interactive controls are merged", async () => { const invoke = createInvokeSpy(); @@ -195,10 +478,10 @@ describe("handleSlackMessageAction", () => { }); const action = firstAction(invoke); - const firstButtons = blockAt(action, 0); + const firstButtons = blockAt(action, 1); expect(firstButtons.block_id).toBe("openclaw_reply_buttons_1"); expect(elementAt(firstButtons, 0).action_id).toBe("openclaw:reply_button:1:1"); - const secondButtons = blockAt(action, 1); + const secondButtons = blockAt(action, 2); expect(secondButtons.block_id).toBe("openclaw_reply_buttons_2"); expect(elementAt(secondButtons, 0).action_id).toBe("openclaw:reply_button:2:1"); }); @@ -233,7 +516,7 @@ describe("handleSlackMessageAction", () => { const action = firstAction(invoke); expect(action.action).toBe("sendMessage"); expect(action.to).toBe("channel:C1"); - expect(action.content).toBe("Approval required"); + expect(action.content).toBe("Approval required\n\n- Approve"); expect(action.mediaUrl).toBe("https://example.com/report.md"); const actionsBlock = blockAt(action, 0); expect(actionsBlock.type).toBe("actions"); diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index d9a64d1e6abd..b84b938ba386 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -7,19 +7,18 @@ import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-co import { normalizeInteractiveReply, normalizeMessagePresentation, - renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime"; import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; import { resolveDefaultSlackAccountId } from "./accounts.js"; -import { - buildSlackInteractiveBlocks, - buildSlackPresentationBlocks, - resolveSlackBlockOffsets, -} from "./blocks-render.js"; +import { SLACK_MAX_BLOCKS } from "./blocks-input.js"; +import { SLACK_TEXT_LIMIT } from "./limits.js"; +import { SLACK_SECTION_TEXT_MAX } from "./presentation.js"; +import { resolveSlackReplyRenderPlan } from "./reply-blocks.js"; type SlackActionInvoke = ( action: Record, @@ -27,15 +26,6 @@ type SlackActionInvoke = ( toolContext?: ChannelMessageActionContext["toolContext"], ) => Promise>; -function resolveSlackPresentationText( - content: string | undefined, - presentation: ReturnType, -): string { - return presentation?.blocks.some((block) => block.type === "chart") - ? renderMessagePresentationFallbackText({ text: content, presentation }) - : (content ?? ""); -} - /** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */ export async function handleSlackMessageAction(params: { providerId: string; @@ -63,15 +53,12 @@ export async function handleSlackMessageAction(params: { const mediaUrl = readStringParam(actionParams, "media", { trim: false }); const presentation = normalizeMessagePresentation(actionParams.presentation); const interactive = normalizeInteractiveReply(actionParams.interactive); - const presentationBlocks = presentation - ? buildSlackPresentationBlocks(presentation) - : undefined; - const interactiveBlocks = interactive - ? buildSlackInteractiveBlocks(interactive, resolveSlackBlockOffsets(presentationBlocks)) - : undefined; - const mergedBlocks = [...(presentationBlocks ?? []), ...(interactiveBlocks ?? [])]; - const blocks = mergedBlocks.length > 0 ? mergedBlocks : undefined; - const accessibleContent = resolveSlackPresentationText(content, presentation); + const renderPlan = resolveSlackReplyRenderPlan({ presentation, interactive }, content, { + textLimit: SLACK_TEXT_LIMIT, + }); + const blocks = renderPlan.mode === "single" ? renderPlan.blocks : renderPlan.blockPart?.blocks; + const accessibleContent = + renderPlan.mode === "single" ? renderPlan.text : renderPlan.fallbackText; if (!accessibleContent && !mediaUrl && !blocks) { throw new Error("Slack send requires message, blocks, or media."); } @@ -94,6 +81,11 @@ export async function handleSlackMessageAction(params: { ...(topLevel ? { topLevel: true } : {}), ...(replyBroadcast ? { replyBroadcast } : {}), ...(blocks ? { blocks } : {}), + ...(renderPlan.mode === "split" + ? { separateTextAndBlocks: true, textIsSlackMrkdwn: true } + : renderPlan.textIsSlackMrkdwn + ? { textIsSlackMrkdwn: true } + : {}), }, cfg, ctx.toolContext, @@ -172,8 +164,31 @@ export async function handleSlackMessageAction(params: { }); const content = readStringParam(actionParams, "message", { allowEmpty: true }); const presentation = normalizeMessagePresentation(actionParams.presentation); - const blocks = presentation ? buildSlackPresentationBlocks(presentation) : undefined; - const accessibleContent = resolveSlackPresentationText(content, presentation); + const renderPlan = resolveSlackReplyRenderPlan({ presentation }, content, { + textLimit: SLACK_TEXT_LIMIT, + }); + const accessibleContent = renderPlan.hookText; + if (renderPlan.mode === "split" && accessibleContent.length > SLACK_TEXT_LIMIT) { + throw new Error( + `Slack presentation fallback exceeds OpenClaw's ${String(SLACK_TEXT_LIMIT)}-character per-edit limit. Send a new message instead.`, + ); + } + const presentationFallbackBlocks = + renderPlan.mode === "split" && renderPlan.blockPart + ? chunkTextForOutbound(renderPlan.fallbackText, SLACK_SECTION_TEXT_MAX).map((text) => ({ + type: "section" as const, + text: { type: "mrkdwn" as const, text, verbatim: true }, + })) + : []; + const nativeBlocks = + renderPlan.mode === "single" + ? (renderPlan.blocks ?? []) + : (renderPlan.blockPart?.blocks ?? []); + const mergedBlocks = [...nativeBlocks, ...presentationFallbackBlocks]; + if (mergedBlocks.length > SLACK_MAX_BLOCKS) { + throw new Error(`Slack blocks cannot exceed ${String(SLACK_MAX_BLOCKS)} items after render`); + } + const blocks = mergedBlocks.length > 0 ? mergedBlocks : undefined; if (!accessibleContent && !blocks) { throw new Error("Slack edit requires message or blocks."); } diff --git a/extensions/slack/src/monitor/block-text.test.ts b/extensions/slack/src/monitor/block-text.test.ts index 103c961cf2b7..c6c862f56585 100644 --- a/extensions/slack/src/monitor/block-text.test.ts +++ b/extensions/slack/src/monitor/block-text.test.ts @@ -35,7 +35,32 @@ describe("resolveSlackBlocksText data visualizations", () => { "- p95: Mon: 250; Tue: 230", ].join("\n"), hasRichText: false, - hasDataVisualization: true, + hasNativeData: true, + }); + }); + + it("preserves native table values in inbound conversation context", () => { + expect( + resolveSlackBlocksText([ + { + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_number", value: 125000, text: "$125k" }, + ], + ], + }, + ]), + ).toEqual({ + text: "Pipeline report (table)\n- Account: Acme; ARR: $125k", + hasRichText: false, + hasNativeData: true, }); }); diff --git a/extensions/slack/src/monitor/block-text.ts b/extensions/slack/src/monitor/block-text.ts index bb7d118fa3f3..607c66fb24e7 100644 --- a/extensions/slack/src/monitor/block-text.ts +++ b/extensions/slack/src/monitor/block-text.ts @@ -2,6 +2,7 @@ import { normalizeOptionalString, readStringValue as readString, } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { renderSlackDataTableFallbackText } from "../data-table.js"; import { renderSlackDataVisualizationFallbackText } from "../data-visualization.js"; type SlackTextObject = { @@ -32,7 +33,7 @@ type SlackBlockLike = { type SlackBlocksText = { text: string; hasRichText: boolean; - hasDataVisualization: boolean; + hasNativeData: boolean; }; function readTextObject(value: unknown): string | undefined { @@ -151,6 +152,8 @@ function readSlackBlockText(block: unknown): string | undefined { ); case "data_visualization": return renderSlackDataVisualizationFallbackText(block); + case "data_table": + return renderSlackDataTableFallbackText(block); default: return undefined; } @@ -162,21 +165,19 @@ export function resolveSlackBlocksText(blocks: unknown[] | undefined): SlackBloc } const parts: string[] = []; let hasRichText = false; - let hasDataVisualization = false; + let hasNativeData = false; for (const block of blocks) { if (block && typeof block === "object") { const blockType = (block as SlackBlockLike).type; hasRichText ||= blockType === "rich_text"; - hasDataVisualization ||= blockType === "data_visualization"; + hasNativeData ||= blockType === "data_visualization" || blockType === "data_table"; } const text = readSlackBlockText(block); if (text) { parts.push(text); } } - return parts.length > 0 - ? { text: parts.join("\n"), hasRichText, hasDataVisualization } - : undefined; + return parts.length > 0 ? { text: parts.join("\n"), hasRichText, hasNativeData } : undefined; } export function chooseSlackPrimaryText(params: { @@ -190,7 +191,7 @@ export function chooseSlackPrimaryText(params: { if (!messageText) { return blocksText.text; } - if (blocksText.hasDataVisualization) { + if (blocksText.hasNativeData) { const comparableMessageText = messageText.replace(/\s+/g, " ").trim(); const comparableBlocksText = blocksText.text.replace(/\s+/g, " ").trim(); if (comparableMessageText.includes(comparableBlocksText)) { diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index e66f99535343..80b01c65ae63 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -10,6 +10,7 @@ const deliverRepliesMock = vi.fn( async () => undefined as { messageId?: string; channelId?: string } | undefined, ); const finalizeSlackPreviewEditMock = vi.fn(async () => {}); +const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim()); const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" })); const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" })); const recordInboundSessionMock = vi.fn(async () => undefined); @@ -843,7 +844,8 @@ vi.mock("../../draft-stream.js", () => ({ })); vi.mock("../../format.js", () => ({ - normalizeSlackOutboundText: (value: string) => value.trim(), + markdownToSlackMrkdwnChunks: (value: string) => [value], + normalizeSlackOutboundText: normalizeSlackOutboundTextMock, })); vi.mock("../../limits.js", () => ({ @@ -1245,6 +1247,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { createSlackDraftStreamMock.mockReset(); deliverRepliesMock.mockReset(); finalizeSlackPreviewEditMock.mockReset(); + normalizeSlackOutboundTextMock.mockClear(); postMessageMock.mockClear(); chatUpdateMock.mockClear(); recordInboundSessionMock.mockReset(); @@ -1659,7 +1662,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(draftStream.clear).not.toHaveBeenCalled(); }); - it("finalizes native chart blocks with accessible preview text", async () => { + it("finalizes native chart blocks without re-escaping accessible preview text", async () => { const draftStream = { ...createDraftStreamStub(), flush: vi.fn(noopAsync), @@ -1667,8 +1670,13 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { discardPending: vi.fn(noopAsync), seal: vi.fn(noopAsync), }; - const accessibleText = "Quarterly results\n\nRevenue (bar chart)\n- USD: Q1: 12; Q2: 18"; + const accessibleText = + "Quarterly results\n\nRevenue (bar chart)\n- <@U123>: Q1: 12; Q2: 18"; mockedSlackReplyBlocks = [ + { + type: "section", + text: { type: "mrkdwn", text: "Quarterly results", verbatim: true }, + }, { type: "data_visualization", title: "Revenue", @@ -1676,7 +1684,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { type: "bar", series: [ { - name: "USD", + name: "<@U123>", data: [ { label: "Q1", value: 12 }, { label: "Q2", value: 18 }, @@ -1701,7 +1709,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { chartType: "bar", title: "Revenue", categories: ["Q1", "Q2"], - series: [{ name: "USD", values: [12, 18] }], + series: [{ name: "<@U123>", values: [12, 18] }], }, ], }, @@ -1718,6 +1726,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { blocks: mockedSlackReplyBlocks, threadTs: THREAD_TS, }); + expect(normalizeSlackOutboundTextMock).not.toHaveBeenCalled(); expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "chart preview message_sent", { content: accessibleText, success: true, @@ -1727,6 +1736,124 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(draftStream.clear).not.toHaveBeenCalled(); }); + it("normalizes only authored preview text when blocks own their fallback", async () => { + const draftStream = { + ...createDraftStreamStub(), + flush: vi.fn(noopAsync), + clear: vi.fn(noopAsync), + discardPending: vi.fn(noopAsync), + seal: vi.fn(noopAsync), + }; + createSlackDraftStreamMock.mockReturnValueOnce(draftStream); + finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined); + mockedDispatchSequence = [ + { + kind: "final", + payload: { + text: "**Summary**", + presentation: { + blocks: [ + { + type: "buttons", + buttons: [ + { + label: "Owner <@U123>", + action: { type: "callback", value: "owner" }, + }, + ], + }, + ], + }, + }, + }, + ]; + + await dispatchPreparedSlackMessage(createPreparedSlackMessage()); + + expect(normalizeSlackOutboundTextMock).toHaveBeenCalledTimes(1); + expect(normalizeSlackOutboundTextMock).toHaveBeenCalledWith("**Summary**"); + expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "block preview edit params", { + text: "**Summary**", + }); + }); + + it("delivers split table fallbacks normally instead of hiding them in a preview edit", async () => { + const draftStream = { + ...createDraftStreamStub(), + flush: vi.fn(noopAsync), + clear: vi.fn(noopAsync), + discardPending: vi.fn(noopAsync), + seal: vi.fn(noopAsync), + }; + const payload = { + text: "Accounts", + presentation: { + blocks: [ + { + type: "table", + caption: "Account owners", + headers: ["Owner"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + `owner-${String(index)}-${"x".repeat(110)}`, + ]), + }, + { + type: "buttons", + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }; + createSlackDraftStreamMock.mockReturnValueOnce(draftStream); + mockedDispatchSequence = [{ kind: "final", payload }]; + + await dispatchPreparedSlackMessage(createPreparedSlackMessage()); + + expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled(); + const delivered = requireRecord( + requireMockCall(deliverRepliesMock, 0, "deliver replies")[0], + "deliver replies params", + ); + expect(delivered.replies).toEqual([payload]); + }); + + it("keeps distinct split table fallbacks distinct in delivery tracking", async () => { + mockedSlackStreamingMode = "off"; + const buildPayload = (owner: string) => ({ + text: "Accounts", + presentation: { + blocks: [ + { + type: "table", + caption: "Account owners", + headers: ["Owner"], + rows: Array.from({ length: 100 }, () => [`${owner}-${"x".repeat(110)}`]), + }, + ], + }, + }); + const firstPayload = buildPayload("Ada"); + const secondPayload = buildPayload("Grace"); + mockedDispatchSequence = [ + { kind: "final", payload: firstPayload }, + { kind: "final", payload: secondPayload }, + ]; + + await dispatchPreparedSlackMessage(createPreparedSlackMessage()); + + expect(deliverRepliesMock).toHaveBeenCalledTimes(2); + const firstDelivery = requireRecord( + requireMockCall(deliverRepliesMock, 0, "first table delivery")[0], + "first table delivery params", + ); + const secondDelivery = requireRecord( + requireMockCall(deliverRepliesMock, 1, "second table delivery")[0], + "second table delivery params", + ); + expect(firstDelivery.replies).toEqual([firstPayload]); + expect(secondDelivery.replies).toEqual([secondPayload]); + }); + it("does not clear a finalized Slack draft when a later tool warning is delivered", async () => { const draftStream = { ...createDraftStreamStub(), @@ -2305,6 +2432,36 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { }); }); + it("routes split table fallbacks around native text streaming", async () => { + mockedNativeStreaming = true; + const payload = { + text: "Accounts", + presentation: { + blocks: [ + { + type: "table", + caption: "Account owners", + headers: ["Owner"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + `owner-${String(index)}-${"x".repeat(110)}`, + ]), + }, + ], + }, + }; + mockedDispatchSequence = [{ kind: "final", payload }]; + + await dispatchPreparedSlackMessage(createPreparedSlackMessage()); + + expect(startSlackStreamMock).not.toHaveBeenCalled(); + expect(appendSlackStreamMock).not.toHaveBeenCalled(); + const delivered = requireRecord( + requireMockCall(deliverRepliesMock, 0, "split table delivery")[0], + "split table delivery params", + ); + expect(delivered.replies).toEqual([payload]); + }); + it("emits message_sent for every final payload appended to one text stream", async () => { mockedNativeStreaming = true; mockedDispatchSequence = [ @@ -3405,12 +3562,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { discardPending: vi.fn(noopAsync), seal: vi.fn(noopAsync), }; - mockedSlackReplyBlocks = [ - { - type: "section", - text: { type: "mrkdwn", text: "Spoken answer" }, - }, - ]; createSlackDraftStreamMock.mockReturnValueOnce(draftStream); finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined); mockedReplyThreadTsSequence = [undefined]; @@ -3434,7 +3585,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { channelId: "C123", messageId: "171234.567", text: "Spoken answer", - blocks: mockedSlackReplyBlocks, }); const delivered = requireRecord( requireMockCall(deliverRepliesMock, 0, "deliver replies")[0], @@ -3596,6 +3746,10 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { seal: vi.fn(noopAsync), }; mockedSlackReplyBlocks = [ + { + type: "section", + text: { type: "mrkdwn", text: "Spoken answer", verbatim: true }, + }, { type: "data_visualization", title: "Revenue", @@ -3603,7 +3757,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { type: "bar", series: [ { - name: "USD", + name: "<@U123>", data: [ { label: "Q1", value: 12 }, { label: "Q2", value: 18 }, @@ -3631,7 +3785,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { chartType: "bar", title: "Revenue", categories: ["Q1", "Q2"], - series: [{ name: "USD", values: [12, 18] }], + series: [{ name: "<@U123>", values: [12, 18] }], }, ], }, @@ -3641,6 +3795,12 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { await dispatchPreparedSlackMessage(createPreparedSlackMessage()); + expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "chart TTS preview edit params", { + text: "Spoken answer\n\nRevenue (bar chart)\n- <@U123>: Q1: 12; Q2: 18", + blocks: mockedSlackReplyBlocks, + }); + expect(normalizeSlackOutboundTextMock).not.toHaveBeenCalled(); + const delivered = requireRecord( requireMockCall(deliverRepliesMock, 0, "deliver replies")[0], "deliver replies params", @@ -3659,7 +3819,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { chartType: "bar", title: "Revenue", categories: ["Q1", "Q2"], - series: [{ name: "USD", values: [12, 18] }], + series: [{ name: "<@U123>", values: [12, 18] }], }, ], }, diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 6cbdcda1272a..349b0aa67638 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -69,7 +69,7 @@ import { buildSlackProgressStreamStartChunks, buildSlackProgressStreamUpdateChunks, } from "../../progress-blocks.js"; -import { resolveSlackReplyText } from "../../reply-blocks.js"; +import { resolveSlackReplyRenderPlan } from "../../reply-blocks.js"; import { recordSlackThreadParticipation } from "../../sent-thread-cache.js"; import { applyAppendOnlyStreamUpdate, resolveSlackStreamingConfig } from "../../stream-mode.js"; import type { SlackStreamSession } from "../../streaming.js"; @@ -241,15 +241,22 @@ function buildSlackEventDeliveryKey(params: SlackEventDeliveryAttempt): string | const reply = resolveSendableOutboundReplyParts(params.payload, { text: params.textOverride, }); - const slackBlocks = readSlackReplyBlocks(params.payload); - if (!reply.hasContent && !slackBlocks?.length) { + const renderPlan = resolveSlackReplyRenderPlan( + params.payload, + params.textOverride ?? params.payload.text, + ); + const plannedBlocks = + renderPlan.mode === "single" ? renderPlan.blocks : renderPlan.blockPart?.blocks; + const slackBlocks = readSlackReplyBlocks(params.payload) ?? plannedBlocks; + const renderedText = renderPlan.mode === "single" ? renderPlan.text : renderPlan.fallbackText; + if (!reply.hasContent && !slackBlocks?.length && !renderedText.trim()) { return null; } return JSON.stringify({ kind: params.kind, threadTs: params.threadTs ?? "", replyToId: params.payload.replyToId ?? null, - text: reply.trimmedText, + text: renderedText || reply.trimmedText, mediaUrls: reply.mediaUrls, blocks: slackBlocks ?? null, }); @@ -1017,9 +1024,14 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag return; } const reply = resolveSendableOutboundReplyParts(params.payload); + const renderPlan = resolveSlackReplyRenderPlan(params.payload); + const plannedBlocks = + renderPlan.mode === "single" ? renderPlan.blocks : renderPlan.blockPart?.blocks; if ( streamFailed || reply.hasMedia || + renderPlan.mode === "split" || + Boolean(plannedBlocks?.length) || readSlackReplyBlocks(params.payload)?.length || !reply.hasText ) { @@ -1260,12 +1272,23 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag } const reply = resolveSendableOutboundReplyParts(payload); - const slackBlocks = readSlackReplyBlocks(payload); const ttsSupplement = getReplyPayloadTtsSupplement(payload); - const trimmedFinalText = resolveSlackReplyText( - payload, - payload.text ?? ttsSupplement?.spokenText, - ).trim(); + const replySourceText = payload.text ?? ttsSupplement?.spokenText; + const replyRenderPlan = resolveSlackReplyRenderPlan(payload, replySourceText); + const plannedBlocks = + replyRenderPlan.mode === "single" + ? replyRenderPlan.blocks + : replyRenderPlan.blockPart?.blocks; + const slackBlocks = plannedBlocks; + const requiresSeparateFallbackDelivery = replyRenderPlan.mode === "split"; + const trimmedFinalText = + replyRenderPlan.mode === "single" + ? replyRenderPlan.text.trim() + : replyRenderPlan.fallbackText.trim(); + const previewFinalText = + replyRenderPlan.mode === "single" && replyRenderPlan.textIsSlackMrkdwn + ? trimmedFinalText + : normalizeSlackOutboundText((replySourceText ?? "").trim()); const shouldRestoreTtsSupplementTextForPreviewFallback = Boolean(ttsSupplement) && ttsSupplement?.visibleTextAlreadyDelivered !== true && @@ -1283,6 +1306,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag !observedFinalReplyDelivery && previewStreamingEnabled && !payload.isError && + !requiresSeparateFallbackDelivery && trimmedFinalText.length > 0 ) { const channelId = draftStream.channelId(); @@ -1298,7 +1322,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag accountId: account.accountId, channelId, messageId, - text: normalizeSlackOutboundText(trimmedFinalText), + text: previewFinalText, ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), threadTs: finalThreadTs, }); @@ -1366,12 +1390,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag !previewStreamingEnabled || (reply.hasMedia && !ttsSupplement) || payload.isError || + requiresSeparateFallbackDelivery || (trimmedFinalText.length === 0 && !slackBlocks?.length) ) { return undefined; } return { - text: normalizeSlackOutboundText(trimmedFinalText), + text: previewFinalText, blocks: slackBlocks, threadTs: usedReplyThreadTs ?? statusThreadTs, }; diff --git a/extensions/slack/src/monitor/message-handler/preview-finalize.test.ts b/extensions/slack/src/monitor/message-handler/preview-finalize.test.ts index 25d6a306af50..a227c716ba6e 100644 --- a/extensions/slack/src/monitor/message-handler/preview-finalize.test.ts +++ b/extensions/slack/src/monitor/message-handler/preview-finalize.test.ts @@ -108,10 +108,10 @@ describe("finalizeSlackPreviewEdit", () => { typeof testing.buildExpectedSlackEditText >[0]["blocks"], }), - ).toBe("*Done*"); + ).toBe("_Done_"); }); - it("matches truncated fallback text for long blocks-only edit readback", async () => { + it("builds complete fallback text for long blocks-only edits", () => { const longContextText = "a".repeat(3000); const blocks = [ { @@ -129,22 +129,44 @@ describe("finalizeSlackPreviewEdit", () => { typeof testing.buildExpectedSlackEditText >[0]["blocks"], }); + expect(expectedText).toHaveLength(9002); + }); + + it("accepts native-data fallback blocks after an ambiguous retry response", async () => { + editSlackMessageMock.mockRejectedValueOnce(new Error("socket closed")); + const blocks = [ + { + type: "data_visualization", + title: "Revenue mix", + chart: { + type: "pie", + segments: [ + { label: "Product", value: 60 }, + { label: "Services", value: 40 }, + ], + }, + }, + ] as const; + const text = "Revenue mix (pie chart)\n- Product: 60\n- Services: 40"; + const fallbackBlocks = [ + { + type: "section", + text: { type: "mrkdwn", text, verbatim: true }, + }, + ]; const client = createClient({ - historyMessages: [{ ts: "171234.567", text: expectedText, blocks }], + historyMessages: [{ ts: "171234.567", text, blocks: fallbackBlocks }], }); - expect(expectedText).toHaveLength(8000); await expect( - testing.didSlackPreviewEditApplyAfterError({ + finalizeSlackPreviewEdit({ client, token: "xoxb-test", channelId: "C123", messageId: "171234.567", text: "", - blocks: blocks as unknown as Parameters< - typeof testing.didSlackPreviewEditApplyAfterError - >[0]["blocks"], + blocks: blocks as unknown as Parameters[0]["blocks"], }), - ).resolves.toBe(true); + ).resolves.toBeUndefined(); }); }); diff --git a/extensions/slack/src/monitor/message-handler/preview-finalize.ts b/extensions/slack/src/monitor/message-handler/preview-finalize.ts index b4bdd0df61cc..f20f2348fb4e 100644 --- a/extensions/slack/src/monitor/message-handler/preview-finalize.ts +++ b/extensions/slack/src/monitor/message-handler/preview-finalize.ts @@ -4,6 +4,10 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { editSlackMessage } from "../../actions.js"; import { buildSlackEditTextPayload } from "../../edit-text.js"; import { normalizeSlackOutboundText } from "../../format.js"; +import { + buildSlackNativeDataFallbackBlocks, + hasSlackNativeDataBlock, +} from "../../native-data-blocks.js"; type SlackReadbackMessage = { ts?: string; @@ -15,7 +19,7 @@ function buildExpectedSlackEditText(params: { text: string; blocks?: (Block | KnownBlock)[]; }): string { - return buildSlackEditTextPayload(params.text, params.blocks); + return normalizeSlackOutboundText(buildSlackEditTextPayload(params.text, params.blocks)); } function blocksMatch(expected?: (Block | KnownBlock)[], actual?: unknown[]): boolean { @@ -25,7 +29,17 @@ function blocksMatch(expected?: (Block | KnownBlock)[], actual?: unknown[]): boo if (!actual?.length) { return false; } - return JSON.stringify(expected) === JSON.stringify(actual); + if (JSON.stringify(expected) === JSON.stringify(actual)) { + return true; + } + if (!hasSlackNativeDataBlock(expected)) { + return false; + } + try { + return JSON.stringify(buildSlackNativeDataFallbackBlocks(expected)) === JSON.stringify(actual); + } catch { + return false; + } } async function readSlackMessageAfterEditError(params: { diff --git a/extensions/slack/src/monitor/replies.test.ts b/extensions/slack/src/monitor/replies.test.ts index 76d46f0bb29f..132a11cc8d4d 100644 --- a/extensions/slack/src/monitor/replies.test.ts +++ b/extensions/slack/src/monitor/replies.test.ts @@ -49,6 +49,21 @@ function baseParams(overrides?: Record) { }; } +function largePortableTablePresentation() { + return { + blocks: [ + { + type: "table" as const, + caption: "Large pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + index === 0 ? "<@U123>" : `account-${String(index)} ${"x".repeat(110)}`, + ]), + }, + ], + }; +} + function requireSendCall(index = 0) { const call = sendMock.mock.calls[index] as [string, string, Record] | undefined; if (!call) { @@ -99,6 +114,43 @@ describe("deliverReplies identity passthrough", () => { expect(options.identity).toBe(identity); }); + it("routes non-native portable tables through complete Slack-safe text delivery", async () => { + sendMock.mockResolvedValue({ messageId: "table-ts", channelId: "C123" }); + + await deliverReplies( + baseParams({ + textLimit: 8000, + replies: [ + { + presentation: largePortableTablePresentation(), + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }, + ], + }), + ); + + expect(sendMock).toHaveBeenCalledOnce(); + const [_target, text, options] = requireSendCall(); + expect(options.blocks).toEqual([ + expect.objectContaining({ + type: "actions", + elements: [expect.objectContaining({ type: "button", value: "refresh" })], + }), + ]); + expect(text).toContain("- Account: <@U123>"); + expect(text).toContain("- Account: account-99"); + expect(text.length).toBeGreaterThan(8000); + expect(options.textIsSlackMrkdwn).toBe(true); + expect(options.separateTextAndBlocks).toBe(true); + }); + it("delivers media before native chart blocks with the same reply context", async () => { messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sent"); sendMock @@ -196,12 +248,68 @@ describe("deliverReplies identity passthrough", () => { const event = messageHookRunner.runMessageSent.mock.calls[0]?.[0] as Record; expect(event).toMatchObject({ to: "C123", - content: "Revenue summary", + content: "Revenue summary\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40", success: true, }); expect(event).not.toHaveProperty("messageId"); }); + it("assigns an overlong native table fallback to the trailing block send after media", async () => { + sendMock + .mockResolvedValueOnce({ messageId: "media-ts", channelId: "C123" }) + .mockResolvedValueOnce({ messageId: "table-ts", channelId: "C123" }); + + await deliverReplies( + baseParams({ + textLimit: 8_000, + replies: [ + { + text: "Pipeline summary", + mediaUrl: "https://example.com/report.png", + presentation: { + title: "Quarterly report", + blocks: [ + { type: "context", text: "Confidential" }, + { + type: "table", + caption: "Pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + index === 0 ? "<@U123>" : `account-${String(index)} ${"x".repeat(65)}`, + ]), + }, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }, + ], + }), + ); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock.mock.calls[0]?.[1]).toBe(""); + expect(sendMock.mock.calls[0]?.[2]).toMatchObject({ + mediaUrl: "https://example.com/report.png", + }); + const [, fallbackText, options] = requireSendCall(1); + expect(fallbackText.length).toBeGreaterThan(8_000); + expect(fallbackText).toContain("- Account: account-99"); + expect(options).toMatchObject({ + separateTextAndBlocks: true, + textIsSlackMrkdwn: true, + }); + expect((options.blocks as Array<{ type?: string }>).map((block) => block.type)).toEqual([ + "data_table", + "actions", + ]); + expect( + sendMock.mock.calls + .map((call) => String(call[1] ?? "")) + .join("\n") + .match(/- Account: account-99/g), + ).toHaveLength(1); + }); + it("omits identity key when not provided", async () => { sendMock.mockResolvedValue(undefined); await deliverReplies(baseParams()); @@ -271,7 +379,7 @@ describe("deliverReplies identity passthrough", () => { expect(sendMock).toHaveBeenCalledOnce(); const [target, text, options] = requireSendCall(); expect(target).toBe("C123"); - expect(text).toBe(""); + expect(text).toBe("- Option A"); expect(options.blocks).toStrictEqual(blocks); }); @@ -493,7 +601,80 @@ describe("deliverSlackSlashReplies chunking", () => { }); }); - it("retries rejected native charts as a text-only slash response", async () => { + it("sends block-only slash replies when their fallback exceeds the chunk limit", async () => { + const respond = vi.fn(async () => undefined); + const blocks = [ + { + type: "actions", + elements: [ + { + type: "button", + action_id: "refresh", + text: { type: "plain_text", text: "Refresh" }, + value: "refresh", + }, + ], + }, + ]; + + await deliverSlackSlashReplies({ + replies: [{ channelData: { slack: { blocks } } }], + respond, + ephemeral: true, + textLimit: 8, + }); + + expect(respond).toHaveBeenCalledOnce(); + expect(respond).toHaveBeenCalledWith({ + text: "- Refresh", + blocks, + response_type: "ephemeral", + }); + }); + + it("preserves command spans and entities across slash mrkdwn chunks", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown; response_type?: string }) => undefined, + ); + const fallback = "- D: `/say & <@U111111111>`"; + + await deliverSlackSlashReplies({ + replies: [ + { + presentation: { + blocks: [ + { + type: "buttons", + buttons: [ + { + label: "D", + action: { type: "command", command: "/say & <@U111111111>" }, + }, + ], + }, + ], + }, + }, + ], + respond, + ephemeral: true, + textLimit: 16, + }); + + const messages = respond.mock.calls.map(([message]) => message); + const texts = messages.map((message) => message.text); + expect(texts.length).toBeGreaterThan(1); + expect(texts.length).toBeLessThanOrEqual(5); + expect(texts.every((text) => text.length <= 16)).toBe(true); + expect(texts.every((text) => (text.match(/`/gu)?.length ?? 0) % 2 === 0)).toBe(true); + expect(texts.every((text) => !/&(?:a|am|l|g|gt|lt)?$/u.test(text))).toBe(true); + expect(texts.every((text) => !/^(?:amp;|lt;|gt;)/u.test(text))).toBe(true); + expect(texts.join("").replaceAll("`", "")).toBe(fallback.replaceAll("`", "")); + expect(texts.every((text) => !text.includes("<@U111111111>"))).toBe(true); + expect(messages.every((message) => message.response_type === "ephemeral")).toBe(true); + }); + + it("retries rejected native charts as visible fallback blocks", async () => { const respond = vi .fn(async () => undefined) .mockRejectedValueOnce({ response: { data: { error: "invalid_blocks" } } }); @@ -532,10 +713,555 @@ describe("deliverSlackSlashReplies chunking", () => { }); expect(respond).toHaveBeenNthCalledWith(2, { text: "Overview\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40", + blocks: [ + blocks[0], + { + type: "section", + text: { + type: "mrkdwn", + text: "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", + verbatim: true, + }, + }, + ], response_type: "ephemeral", }); }); + it("retries rejected native tables once with visible complete fallback blocks", async () => { + const respond = vi + .fn(async () => undefined) + .mockRejectedValueOnce({ response: { data: { error: "invalid_blocks" } } }); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Overview" } }, + { + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_number", value: 125000, text: "$125k" }, + ], + [ + { type: "raw_text", text: "Globex" }, + { type: "raw_number", value: 82000, text: "$82k" }, + ], + ], + row_header_column_index: 0, + }, + ] as never; + const fallback = [ + "Overview", + "", + "Pipeline report (table)", + "- Account: Acme; ARR: $125k", + "- Account: Globex; ARR: $82k", + ].join("\n"); + + await deliverSlackSlashReplies({ + replies: [ + { + text: "Overview", + channelData: { slack: { blocks } }, + }, + ], + respond, + ephemeral: true, + textLimit: 8000, + }); + + expect(respond).toHaveBeenCalledTimes(2); + expect(respond).toHaveBeenNthCalledWith(1, { + text: fallback, + blocks, + response_type: "ephemeral", + }); + expect(respond).toHaveBeenNthCalledWith(2, { + text: fallback, + blocks: [ + blocks[0], + { + type: "section", + text: { + type: "mrkdwn", + text: [ + "Pipeline report (table)", + "- Account: Acme; ARR: $125k", + "- Account: Globex; ARR: $82k", + ].join("\n"), + verbatim: true, + }, + }, + ], + response_type: "ephemeral", + }); + }); + + it("propagates invalid_blocks when a slash fallback retains an invalid sibling", async () => { + const invalidBlocks = { response: { data: { error: "invalid_blocks" } } }; + const respond = vi.fn(async () => invalidBlocks); + + await expect( + deliverSlackSlashReplies({ + replies: [ + { + text: "Overview", + channelData: { + slack: { + blocks: [ + { type: "section", text: { type: "mrkdwn", text: "Invalid sibling" } }, + { + type: "data_visualization", + title: "Revenue mix", + chart: { + type: "pie", + segments: [{ label: "Product", value: 60 }], + }, + }, + ], + }, + }, + }, + ], + respond, + ephemeral: true, + textLimit: 8000, + }), + ).rejects.toBe(invalidBlocks); + + expect(respond).toHaveBeenCalledTimes(2); + }); + + it("chunks a long chart fallback before slash delivery while retaining siblings", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown; response_type?: string }) => undefined, + ); + const categories = Array.from( + { length: 20 }, + (_entry, index) => `category-${String(index)}-${"x".repeat(80)}`, + ); + + await deliverSlackSlashReplies({ + replies: [ + { + channelData: { + slack: { + blocks: [ + ...Array.from({ length: 47 }, () => ({ type: "divider" })), + { + type: "data_visualization", + title: "Large chart", + chart: { + type: "bar", + axis_config: { categories }, + series: Array.from({ length: 7 }, (_entry, index) => ({ + name: `Series ${String(index)}`, + data: categories.map((label) => ({ label, value: index })), + })), + }, + }, + ], + }, + }, + }, + ], + respond, + ephemeral: true, + textLimit: 8000, + }); + + expect(respond.mock.calls.length).toBeGreaterThan(1); + expect(respond.mock.calls[0]?.[0]).toMatchObject({ + blocks: Array.from({ length: 47 }, () => ({ type: "divider" })), + }); + expect( + respond.mock.calls.slice(1).every(([message]) => !(message as { blocks?: unknown }).blocks), + ).toBe(true); + expect( + respond.mock.calls.map(([message]) => (message as { text?: string }).text ?? "").join("\n"), + ).toContain("Series 6"); + }); + + it("chunks overlong table fallbacks while preserving the native table and controls", async () => { + const respond = vi + .fn(async (_message: { text: string; blocks?: unknown; response_type?: string }) => undefined) + .mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const header = "Account".padEnd(80, "x"); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Overview" } }, + { + type: "data_table", + caption: "Large pipeline", + rows: [ + [{ type: "raw_text", text: header }], + ...Array.from({ length: 100 }, (_entry, index) => [ + { + type: "raw_text", + text: index === 0 ? "<@U123>" : `account-${String(index)}`, + }, + ]), + ], + }, + { + type: "data_visualization", + title: "Revenue mix", + chart: { + type: "pie", + segments: [ + { label: "Product", value: 60 }, + { label: "Services", value: 40 }, + ], + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Refresh" }, + action_id: "refresh", + value: "refresh", + }, + ], + }, + ] as never; + + await deliverSlackSlashReplies({ + replies: [{ channelData: { slack: { blocks } } }], + respond, + ephemeral: true, + textLimit: 8000, + }); + + expect(respond.mock.calls.length).toBeGreaterThan(2); + const messages = respond.mock.calls.map(([message]) => message); + expect(messages[0]).toMatchObject({ + text: "Large pipeline (table)\n\n- Refresh", + blocks: [blocks[1], blocks[3]], + }); + expect(messages[1]).toMatchObject({ text: "- Refresh", blocks: [blocks[3]] }); + expect(messages.slice(2).every((message) => message.blocks === undefined)).toBe(true); + expect(messages.every((message) => message.text.length <= 8000)).toBe(true); + const fallbackText = messages + .slice(2) + .map((message) => message.text) + .join("\n"); + expect(fallbackText).toContain(`- ${header}: <@U123>`); + expect(fallbackText).toContain(`- ${header}: account-99`); + expect(fallbackText).toContain("Revenue mix (pie chart)"); + expect(fallbackText.match(/Large pipeline \(table\)/g)).toHaveLength(1); + expect(fallbackText).not.toContain("<@U123>"); + }); + + it("compacts native table accessibility text at a lower configured chunk limit", async () => { + const respond = vi.fn( + async (_message: { + text: string; + blocks?: Array<{ type?: string }>; + response_type?: string; + }) => undefined, + ); + const header = "H".repeat(1_000); + + await deliverSlackSlashReplies({ + replies: [ + { + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: [header], + rows: Array.from({ length: 5 }, (_entry, index) => [String(index)]), + }, + ], + }, + }, + ], + respond, + ephemeral: true, + textLimit: 4_000, + }); + + expect(respond).toHaveBeenCalledTimes(3); + const messages = respond.mock.calls.map(([message]) => message); + expect(messages[0]).toMatchObject({ text: "Pipeline (table)" }); + expect(messages[0]?.blocks?.some((block) => block.type === "data_table")).toBe(true); + expect(messages.slice(1).every((message) => message.blocks === undefined)).toBe(true); + expect(messages.every((message) => message.text.length <= 4_000)).toBe(true); + expect(messages[0]?.text).not.toContain(": 4"); + expect( + messages + .slice(1) + .map((message) => message.text) + .join("") + .match(/: 4/gu), + ).toHaveLength(1); + }); + + it("uses fallback-only chunks when a native table would exceed the response_url budget", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown; response_type?: string }) => undefined, + ); + const header = "Account".padEnd(150, "h"); + const rows = Array.from({ length: 100 }, (_entry, index) => [ + `account-${String(index)}`.padEnd(90, "x"), + ]); + + await deliverSlackSlashReplies({ + replies: [ + { + presentation: { + blocks: [{ type: "table", caption: "Pipeline", headers: [header], rows }], + }, + }, + ], + respond, + ephemeral: true, + textLimit: 8000, + }); + + expect(respond).toHaveBeenCalledTimes(4); + expect(respond.mock.calls.every(([message]) => !(message as { blocks?: unknown }).blocks)).toBe( + true, + ); + const text = respond.mock.calls + .map(([message]) => (message as { text: string }).text) + .join("\n"); + expect(text).toContain("Pipeline (table)"); + expect(text).toContain(`- ${header}: account-99`); + expect(text).not.toContain("too large for the remaining response_url budget"); + }); + + it("degrades multiple small native tables to fit the response_url budget", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown[]; response_type?: string }) => undefined, + ); + const responseUrlBudget = { used: 0 }; + + await deliverSlackSlashReplies({ + replies: ["Alpha", "Beta", "Gamma"].map((caption) => ({ + presentation: { + blocks: [{ type: "table" as const, caption, headers: ["Value"], rows: [[1]] }], + }, + })), + respond, + responseUrlBudget, + ephemeral: true, + textLimit: 8_000, + }); + + expect(respond).toHaveBeenCalledTimes(3); + expect(responseUrlBudget).toEqual({ used: 3 }); + for (const [index, caption] of ["Alpha", "Beta", "Gamma"].entries()) { + const message = respond.mock.calls[index]?.[0]; + const text = `${caption} (table)\n- Value: 1`; + expect(message).toEqual({ + text, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text, verbatim: true }, + }, + ], + response_type: "ephemeral", + }); + } + expect( + respond.mock.calls.some(([message]) => + message.text.includes("too large for the remaining response_url budget"), + ), + ).toBe(false); + }); + + it("recognizes hard-split table fallback ownership within the response_url budget", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown[]; response_type?: string }) => undefined, + ); + const header = "H".repeat(9_000); + + await deliverSlackSlashReplies({ + replies: [ + { + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: [header], + rows: [["x"]], + }, + ], + }, + }, + ], + respond, + ephemeral: true, + textLimit: 8_000, + }); + + expect(respond).toHaveBeenCalledTimes(4); + const messages = respond.mock.calls.map(([message]) => message); + expect(messages.every((message) => message.blocks === undefined)).toBe(true); + const text = messages.map((message) => message.text).join(""); + expect(text).not.toContain("too large for the remaining response_url budget"); + expect(text.match(/H/gu)).toHaveLength(9_000); + expect(text).toContain(": x"); + }); + + it("explains slash replies that exceed Slack's response_url budget before sending content", async () => { + const respond = vi.fn(async () => undefined); + messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sent"); + + await deliverSlackSlashReplies({ + replies: [{ text: "a".repeat(40_001) }], + respond, + ephemeral: true, + textLimit: 8000, + messageSentHookTarget: "user:U1", + }); + + expect(respond).toHaveBeenCalledOnce(); + expect(respond).toHaveBeenCalledWith({ + text: expect.stringContaining("6 responses needed; 5 available"), + response_type: "ephemeral", + }); + expect(messageHookRunner.runMessageSent).toHaveBeenCalledOnce(); + expect(messageHookRunner.runMessageSent.mock.calls[0]?.[0]).toMatchObject({ + to: "user:U1", + success: false, + error: expect.stringContaining("6 responses needed; 5 available"), + }); + }); + + it("shares the response_url budget across streamed slash deliveries", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown; response_type?: string }) => undefined, + ); + const responseUrlBudget = { used: 0 }; + + await deliverSlackSlashReplies({ + replies: [{ text: "a".repeat(16_001) }], + respond, + responseUrlBudget, + ephemeral: true, + textLimit: 8000, + }); + await deliverSlackSlashReplies({ + replies: [{ text: "b".repeat(16_001) }], + respond, + responseUrlBudget, + ephemeral: true, + textLimit: 8000, + }); + + expect(responseUrlBudget).toEqual({ used: 4, closed: true }); + expect(respond).toHaveBeenCalledTimes(4); + expect(respond.mock.calls[3]?.[0]).toEqual({ + text: expect.stringContaining("3 responses needed; 2 available"), + response_type: "ephemeral", + }); + }); + + it("preserves controls while chunking non-native tables with media links", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown; response_type?: string }) => undefined, + ); + + await deliverSlackSlashReplies({ + replies: [ + { + presentation: largePortableTablePresentation(), + mediaUrls: ["https://example.com/report.png"], + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }, + ], + respond, + ephemeral: true, + textLimit: 8000, + }); + + expect(respond.mock.calls.length).toBeGreaterThan(1); + const messages = respond.mock.calls.map(([message]) => message); + expect(messages[0]?.blocks).toEqual([ + expect.objectContaining({ + type: "actions", + elements: [expect.objectContaining({ type: "button", value: "refresh" })], + }), + ]); + expect(messages.slice(1).every((message) => message.blocks === undefined)).toBe(true); + const deliveredText = messages.map((message) => message.text).join("\n"); + expect(deliveredText).toContain("- Account: <@U123>"); + expect(deliveredText).toContain("- Account: account-99"); + expect(deliveredText).toContain("https://example.com/report.png"); + expect(deliveredText).not.toContain("<@U123>"); + }); + + it("uses one sibling prelude when portable and raw tables both require fallback", async () => { + const respond = vi.fn( + async (_message: { text: string; blocks?: unknown; response_type?: string }) => undefined, + ); + const header = "Raw account".padEnd(80, "x"); + const rawBlocks = [ + { type: "section", text: { type: "mrkdwn", text: "Raw overview" } }, + { + type: "data_table", + caption: "Raw pipeline", + rows: [ + [{ type: "raw_text", text: header }], + ...Array.from({ length: 100 }, (_entry, index) => [ + { type: "raw_text", text: `raw-${String(index)}` }, + ]), + ], + }, + ] as never; + + await deliverSlackSlashReplies({ + replies: [ + { + presentation: largePortableTablePresentation(), + channelData: { slack: { blocks: rawBlocks } }, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }, + ], + respond, + ephemeral: true, + textLimit: 8000, + }); + + const messages = respond.mock.calls.map(([message]) => message); + expect(messages[0]?.blocks).toEqual([ + rawBlocks[0], + expect.objectContaining({ + type: "actions", + elements: [expect.objectContaining({ type: "button", value: "refresh" })], + }), + ]); + expect(messages.slice(1).every((message) => message.blocks === undefined)).toBe(true); + const deliveredText = messages.map((message) => message.text).join("\n"); + expect(deliveredText).toContain("- Account: account-99"); + expect(deliveredText).toContain(`- ${header}: raw-99`); + expect(deliveredText.match(/Raw overview/g)).toHaveLength(1); + }); + it("suppresses reasoning payloads in slash replies", async () => { const respond = vi.fn(async () => undefined); diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index 6dd70d87f4a2..cb95137356c4 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -17,21 +17,25 @@ import { } from "openclaw/plugin-sdk/reply-payload"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-reference"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { - appendSlackDataVisualizationFallbackText, - hasSlackDataVisualizationBlock, - isSlackInvalidBlocksError, -} from "../data-visualization.js"; -import { markdownToSlackMrkdwnChunks } from "../format.js"; -import { SLACK_TEXT_LIMIT } from "../limits.js"; +import { buildSlackDeferredNativeDataRejectionFallback } from "../blocks-fallback.js"; +import { chunkSlackMrkdwnText, markdownToSlackMrkdwnChunks } from "../format.js"; +import { SLACK_RESPONSE_URL_MAX_USES, SLACK_TEXT_LIMIT } from "../limits.js"; import { emitSlackMessageSentHooks } from "../message-sent-hook.js"; -import { resolveSlackReplyBlocks, resolveSlackReplyText } from "../reply-blocks.js"; +import { + appendSlackNativeDataFallbackText, + buildSlackNativeDataFallbackBlocks, + hasCompleteSlackNativeDataFallbackText, + hasSlackNativeDataBlock, + isSlackInvalidBlocksError, +} from "../native-data-blocks.js"; +import { resolveSlackReplyRenderPlan } from "../reply-blocks.js"; import { truncateSlackText } from "../truncate.js"; import type { SlackEventScope } from "./event-scope.js"; import { sendMessageSlack, type SlackSendIdentity, type SlackSendResult } from "./send.runtime.js"; export function readSlackReplyBlocks(payload: ReplyPayload) { - return resolveSlackReplyBlocks(payload); + const plan = resolveSlackReplyRenderPlan(payload); + return plan.mode === "single" ? plan.blocks : plan.blockPart?.blocks; } function resolveSlackMediaHookSpokenText(payload: ReplyPayload): string | undefined { @@ -89,6 +93,8 @@ export async function deliverReplies(params: { threadTs?: string | undefined; mediaUrl?: string | undefined; blocks?: (Block | KnownBlock)[] | undefined; + separateTextAndBlocks?: boolean; + textIsSlackMrkdwn?: boolean; }): Promise => { return await sendMessageSlack(params.target, input.text, { cfg: params.cfg, @@ -97,6 +103,8 @@ export async function deliverReplies(params: { accountId: params.accountId, mediaUrl: input.mediaUrl, blocks: input.blocks, + ...(input.separateTextAndBlocks ? { separateTextAndBlocks: true } : {}), + ...(input.textIsSlackMrkdwn ? { textIsSlackMrkdwn: true } : {}), ...(params.eventScope ? { client: params.eventScope.client, @@ -119,8 +127,24 @@ export async function deliverReplies(params: { replyThreadTs: params.replyThreadTs, }); const reply = resolveSendableOutboundReplyParts(payload); - const slackBlocks = readSlackReplyBlocks(payload); - if (!reply.hasContent && !slackBlocks?.length) { + const renderText = + reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN) + ? reply.trimmedText + : undefined; + const renderPlan = resolveSlackReplyRenderPlan(payload, renderText, { + includeAuthoredTextBlock: !reply.hasMedia, + }); + const slackBlocks = + renderPlan.mode === "single" ? renderPlan.blocks : renderPlan.blockPart?.blocks; + const tableFallbackText = + renderPlan.mode === "split" ? renderPlan.fallbackText.trim() : undefined; + const mediaBlockPartOwnsFallback = Boolean( + reply.hasMedia && + renderPlan.mode === "split" && + renderPlan.blockPart && + hasCompleteSlackNativeDataFallbackText(renderPlan.fallbackText, renderPlan.blockPart.blocks), + ); + if (!reply.hasContent && !slackBlocks?.length && !tableFallbackText) { continue; } @@ -159,8 +183,8 @@ export async function deliverReplies(params: { }); }; - if (!reply.hasMedia && slackBlocks?.length) { - const trimmed = resolveSlackReplyText(payload, reply.trimmedText).trim(); + if (renderPlan.mode === "single" && !reply.hasMedia && slackBlocks?.length) { + const trimmed = renderPlan.text.trim(); if (!trimmed && !slackBlocks?.length) { continue; } @@ -173,6 +197,7 @@ export async function deliverReplies(params: { text: trimmed, threadTs, ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), + ...(renderPlan.textIsSlackMrkdwn ? { textIsSlackMrkdwn: true } : {}), }); } catch (error) { emitFailed(trimmed, error); @@ -183,16 +208,52 @@ export async function deliverReplies(params: { params.runtime.log?.(`delivered reply to ${params.target}`); continue; } + if (renderPlan.mode === "split" && !reply.hasMedia) { + const trimmed = renderPlan.fallbackText.trim(); + if (!trimmed && !renderPlan.blockPart) { + continue; + } + try { + const result = await sendReply({ + text: trimmed, + threadTs, + ...(renderPlan.blockPart + ? { + blocks: renderPlan.blockPart.blocks, + separateTextAndBlocks: true, + } + : {}), + textIsSlackMrkdwn: true, + }); + emitSent(renderPlan.hookText, result); + latestResult = result; + } catch (error) { + emitFailed(renderPlan.hookText, error); + throw error; + } + params.runtime.log?.(`delivered reply to ${params.target}`); + continue; + } const spokenText = resolveSlackMediaHookSpokenText(payload); const mediaHookContent = reply.hasText ? reply.text : spokenText || reply.text; - const hookContent = reply.hasMedia ? mediaHookContent : reply.trimmedText; + const deliveryText = + (mediaBlockPartOwnsFallback ? "" : tableFallbackText) ?? + (reply.hasMedia && renderPlan.mode === "single" && renderPlan.textVisibleInBlocks + ? "" + : reply.text); + const hookContent = + renderPlan.mode === "split" + ? renderPlan.hookText + : reply.hasMedia + ? renderPlan.hookText || mediaHookContent + : reply.trimmedText; let lastResult: SlackSendResult | undefined; let delivered: Awaited>; try { delivered = await deliverTextOrMediaReply({ payload, - text: reply.text, + text: deliveryText, chunkText: !reply.hasMedia ? (value) => { const trimmed = value.trim(); @@ -206,6 +267,7 @@ export async function deliverReplies(params: { lastResult = await sendReply({ text: trimmed, threadTs, + ...(renderPlan.mode === "split" ? { textIsSlackMrkdwn: true } : {}), }); }, sendMedia: async ({ mediaUrl, caption }) => { @@ -213,17 +275,26 @@ export async function deliverReplies(params: { text: caption ?? "", mediaUrl, threadTs, + ...(renderPlan.mode === "split" ? { textIsSlackMrkdwn: true } : {}), }); }, }); if (reply.hasMedia && slackBlocks?.length) { // Slack file uploads cannot carry blocks. Preserve their ordering and // report one terminal outcome only after the trailing block message. - const text = resolveSlackReplyText(payload, reply.trimmedText).trim(); + const text = + mediaBlockPartOwnsFallback && renderPlan.mode === "split" + ? renderPlan.fallbackText + : renderPlan.mode === "split" + ? (renderPlan.blockPart?.text ?? "") + : renderPlan.text.trim(); lastResult = await sendReply({ text, threadTs, blocks: slackBlocks, + ...(mediaBlockPartOwnsFallback + ? { separateTextAndBlocks: true, textIsSlackMrkdwn: true } + : {}), }); } } catch (error) { @@ -276,6 +347,92 @@ type SlackReplyDeliveryPlan = { markSent: () => void; }; +type SlackSlashReplyWireMessage = { + text: string; + blocks?: ReturnType; +}; + +type SlackSlashReplyMessage = SlackSlashReplyWireMessage & { + nativeDataRejectionFallback?: SlackSlashReplyWireMessage; +}; + +type SlackNativeDataFallbackResolution = { + deferred: boolean; + message: SlackSlashReplyWireMessage; +}; + +type SlackSlashReplyDelivery = { + hookContent: string; + messages: SlackSlashReplyMessage[]; +}; + +function countSlackResponseUrlUses(deliveries: readonly SlackSlashReplyDelivery[]): number { + return deliveries.reduce( + (total, delivery) => + total + + delivery.messages.reduce( + (messageTotal, message) => + messageTotal + 1 + (hasSlackNativeDataBlock(message.blocks) ? 1 : 0), + 0, + ), + 0, + ); +} + +function resolveSlackNativeDataFallback( + message: SlackSlashReplyMessage, +): SlackNativeDataFallbackResolution | undefined { + if (!hasSlackNativeDataBlock(message.blocks)) { + return undefined; + } + if (message.nativeDataRejectionFallback) { + return { deferred: true, message: message.nativeDataRejectionFallback }; + } + const blocks = buildSlackNativeDataFallbackBlocks(message.blocks); + return { + deferred: false, + message: { + text: truncateSlackText( + appendSlackNativeDataFallbackText(message.text, message.blocks), + SLACK_TEXT_LIMIT, + ), + ...(blocks?.length ? { blocks } : {}), + }, + }; +} + +function degradeSlackResponseUrlNativeTables( + deliveries: readonly SlackSlashReplyDelivery[], + textLimit: number, +): SlackSlashReplyDelivery[] { + return deliveries + .map((delivery) => ({ + ...delivery, + messages: delivery.messages.flatMap((message) => { + if (!hasSlackNativeDataBlock(message.blocks)) { + return [message]; + } + let fallback: SlackNativeDataFallbackResolution | undefined; + try { + fallback = resolveSlackNativeDataFallback(message); + } catch { + return [message]; + } + if (!fallback) { + return [message]; + } + if (fallback.deferred && !fallback.message.blocks?.length) { + return []; + } + if (fallback.message.text.length > textLimit) { + return [message]; + } + return [fallback.message]; + }), + })) + .filter((delivery) => delivery.messages.length > 0); +} + function createSlackReplyReferencePlanner(params: { replyToMode: "off" | "first" | "all" | "batched"; incomingThreadTs: string | undefined; @@ -325,6 +482,7 @@ export function createSlackReplyDeliveryPlan(params: { export async function deliverSlackSlashReplies(params: { replies: ReplyPayload[]; respond: SlackRespondFn; + responseUrlBudget?: { used: number; closed?: boolean }; ephemeral: boolean; textLimit: number; tableMode?: MarkdownTableMode; @@ -335,85 +493,158 @@ export async function deliverSlackSlashReplies(params: { isGroup?: boolean; groupId?: string; }) { - const deliveries: Array<{ - hookContent: string; - messages: Array<{ text: string; blocks?: ReturnType }>; - }> = []; + const responseUrlBudget = params.responseUrlBudget ?? { used: 0 }; + if (responseUrlBudget.closed) { + return; + } + const deliveries: SlackSlashReplyDelivery[] = []; const chunkLimit = Math.min(params.textLimit, SLACK_TEXT_LIMIT); for (const payload of params.replies) { if (payload.isReasoning === true) { continue; } const reply = resolveSendableOutboundReplyParts(payload); - const slackBlocks = readSlackReplyBlocks(payload); const textRaw = reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN) ? reply.trimmedText : undefined; - const text = slackBlocks?.length - ? truncateSlackText( - appendSlackDataVisualizationFallbackText( - resolveSlackReplyText(payload, textRaw), - slackBlocks, - ), - SLACK_TEXT_LIMIT, - ).trim() - : textRaw; - if (slackBlocks?.length && !reply.hasMedia) { + const renderPlan = resolveSlackReplyRenderPlan(payload, textRaw, { textLimit: chunkLimit }); + if (renderPlan.mode === "single" && renderPlan.blocks?.length && !reply.hasMedia) { deliveries.push({ - hookContent: text ?? "", - messages: [{ text: text ?? "", blocks: slackBlocks }], + hookContent: renderPlan.hookText, + messages: [{ text: renderPlan.text, blocks: renderPlan.blocks }], }); continue; } + const text = renderPlan.mode === "split" ? renderPlan.fallbackText : renderPlan.text; const combined = [text ?? "", ...reply.mediaUrls].filter(Boolean).join("\n"); - if (!combined) { + const blockPart = renderPlan.mode === "split" ? renderPlan.blockPart : undefined; + if (!combined && !blockPart) { continue; } const chunkMode = params.chunkMode ?? "length"; - const markdownChunks = - chunkMode === "newline" - ? chunkMarkdownTextWithMode(combined, chunkLimit, chunkMode) - : [combined]; - const chunks = markdownChunks.flatMap((markdown) => - markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode: params.tableMode }), - ); + const chunks = + combined.length === 0 + ? [] + : renderPlan.mode === "split" || renderPlan.textIsSlackMrkdwn + ? chunkSlackMrkdwnText(combined, chunkLimit) + : (chunkMode === "newline" + ? chunkMarkdownTextWithMode(combined, chunkLimit, chunkMode) + : [combined] + ).flatMap((markdown) => + markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode: params.tableMode }), + ); if (!chunks.length && combined) { chunks.push(combined); } + const messages: SlackSlashReplyMessage[] = chunks.map((chunk) => ({ text: chunk })); + if (blockPart) { + const ownsLaterNativeDataFallback = + renderPlan.mode === "split" && + hasSlackNativeDataBlock(blockPart.blocks) && + hasCompleteSlackNativeDataFallbackText(renderPlan.fallbackText, blockPart.blocks); + const deferredRejection = ownsLaterNativeDataFallback + ? buildSlackDeferredNativeDataRejectionFallback(blockPart.blocks) + : undefined; + messages.unshift({ + text: blockPart.text, + blocks: blockPart.blocks, + ...(deferredRejection + ? { + nativeDataRejectionFallback: { + text: deferredRejection.text, + ...(deferredRejection.blocks.length > 0 + ? { blocks: deferredRejection.blocks } + : {}), + }, + } + : {}), + }); + } deliveries.push({ - hookContent: text ?? resolveSlackMediaHookSpokenText(payload) ?? combined, - messages: chunks.map((chunk) => ({ text: chunk })), + hookContent: renderPlan.hookText || resolveSlackMediaHookSpokenText(payload) || combined, + messages, }); } if (deliveries.length === 0) { return; } + const responseType = params.ephemeral ? "ephemeral" : "in_channel"; + const responseUrlUsesRemaining = Math.max( + 0, + SLACK_RESPONSE_URL_MAX_USES - responseUrlBudget.used, + ); + let plannedDeliveries = deliveries; + let responseUrlUses = countSlackResponseUrlUses(plannedDeliveries); + if (responseUrlUses > responseUrlUsesRemaining) { + const degradedDeliveries = degradeSlackResponseUrlNativeTables(deliveries, chunkLimit); + const degradedUses = countSlackResponseUrlUses(degradedDeliveries); + if (degradedUses < responseUrlUses) { + plannedDeliveries = degradedDeliveries; + responseUrlUses = degradedUses; + } + } + if (responseUrlUses > responseUrlUsesRemaining) { + const errorText = `This Slack slash reply is too large for the remaining response_url budget (${String(responseUrlUses)} responses needed; ${String(responseUrlUsesRemaining)} available). Send the result as a regular message instead.`; + responseUrlBudget.closed = true; + if (params.messageSentHookTarget) { + emitSlackMessageSentHooks({ + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + to: params.messageSentHookTarget, + accountId: params.accountId, + content: plannedDeliveries + .map((delivery) => delivery.hookContent) + .filter(Boolean) + .join("\n"), + success: false, + error: errorText, + isGroup: params.isGroup, + groupId: params.groupId, + }); + } + if (responseUrlUsesRemaining > 0) { + responseUrlBudget.used += 1; + await params.respond({ text: errorText, response_type: responseType }); + } + return; + } + + const respond = async (message: Parameters[0]) => { + responseUrlBudget.used += 1; + return await params.respond(message); + }; // Slack slash command responses can be multi-part by sending follow-ups via response_url. - const responseType = params.ephemeral ? "ephemeral" : "in_channel"; - for (const delivery of deliveries) { + for (const delivery of plannedDeliveries) { try { for (const message of delivery.messages) { - const hasNativeChart = hasSlackDataVisualizationBlock(message.blocks); + const hasNativeData = hasSlackNativeDataBlock(message.blocks); + const outboundMessage: SlackSlashReplyWireMessage = { + text: message.text, + ...(message.blocks?.length ? { blocks: message.blocks } : {}), + }; try { - const response = await params.respond({ ...message, response_type: responseType }); - if (!hasNativeChart || !isSlackInvalidBlocksError(response)) { + const response = await respond({ ...outboundMessage, response_type: responseType }); + if (!hasNativeData || !isSlackInvalidBlocksError(response)) { continue; } } catch (error) { - if (!hasNativeChart || !isSlackInvalidBlocksError(error)) { + if (!hasNativeData || !isSlackInvalidBlocksError(error)) { throw error; } } - await params.respond({ - text: truncateSlackText( - appendSlackDataVisualizationFallbackText(message.text, message.blocks), - SLACK_TEXT_LIMIT, - ), + const fallback = resolveSlackNativeDataFallback(message); + if (!fallback) { + throw new Error("Slack native-data fallback was unavailable"); + } + const fallbackResponse = await respond({ + ...fallback.message, response_type: responseType, }); + if (isSlackInvalidBlocksError(fallbackResponse)) { + throw fallbackResponse; + } } } catch (error) { if (params.messageSentHookTarget) { diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 914a72bd716a..c31a0be8b5e5 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -36,6 +36,7 @@ import { compileSlackInteractiveReplies, isSlackInteractiveRepliesEnabled, } from "../interactive-replies.js"; +import { SLACK_RESPONSE_URL_MAX_USES } from "../limits.js"; import { truncateSlackText } from "../truncate.js"; import { resolveSlackCommandIngress, resolveSlackEffectiveAllowFrom } from "./auth.js"; import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js"; @@ -391,6 +392,7 @@ export async function registerSlackMonitorSlashCommands(params: { commandDefinition?: ChatCommandDefinition; }) => { const { command, ack, respond, body, prompt, commandArgs, commandDefinition } = p; + const responseUrlBudget: { used: number; closed?: boolean } = { used: 0 }; const cfg = getRuntimeConfigSnapshot() ?? ctx.cfg; try { if (ctx.shouldDropMismatchedSlackEvent?.(body)) { @@ -743,6 +745,7 @@ export async function registerSlackMonitorSlashCommands(params: { await deliverSlackSlashReplies({ replies, respond, + responseUrlBudget, ephemeral: slashCommand.ephemeral, textLimit: ctx.textLimit, messageSentHookTarget, @@ -781,10 +784,13 @@ export async function registerSlackMonitorSlashCommands(params: { } } catch (err) { runtime.error?.(danger(`slack slash handler failed: ${formatErrorMessage(err)}`)); - await respond({ - text: "Sorry, something went wrong handling that command.", - response_type: "ephemeral", - }); + if (!responseUrlBudget.closed && responseUrlBudget.used < SLACK_RESPONSE_URL_MAX_USES) { + responseUrlBudget.used += 1; + await respond({ + text: "Sorry, something went wrong handling that command.", + response_type: "ephemeral", + }); + } } }; diff --git a/extensions/slack/src/native-data-blocks.test.ts b/extensions/slack/src/native-data-blocks.test.ts new file mode 100644 index 000000000000..3995e0f4976f --- /dev/null +++ b/extensions/slack/src/native-data-blocks.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + appendSlackNativeDataFallbackText, + hasSlackNativeDataBlock, + isSlackInvalidBlocksError, + renderSlackNativeDataFallbackText, +} from "./native-data-blocks.js"; + +const chart = { + type: "data_visualization", + title: "Revenue mix", + chart: { + type: "pie", + segments: [ + { label: "Product", value: 60 }, + { label: "Services", value: 40 }, + ], + }, +}; + +const table = { + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_number", value: 125_000, text: "$125k" }, + ], + ], + row_header_column_index: 0, +}; + +describe("Slack native data blocks", () => { + it("detects charts and current data tables", () => { + expect(hasSlackNativeDataBlock([{ type: "section" }])).toBe(false); + expect(hasSlackNativeDataBlock([chart])).toBe(true); + expect(hasSlackNativeDataBlock([table])).toBe(true); + }); + + it("matches structural invalid_blocks error responses", () => { + expect(isSlackInvalidBlocksError({ data: { error: "invalid_blocks" } })).toBe(true); + expect(isSlackInvalidBlocksError({ data: "invalid_blocks" })).toBe(true); + expect(isSlackInvalidBlocksError({ response: { data: { error: "invalid_blocks" } } })).toBe( + true, + ); + expect(isSlackInvalidBlocksError({ response: { data: "invalid_blocks" } })).toBe(true); + expect(isSlackInvalidBlocksError({ error: "INVALID_BLOCKS" })).toBe(true); + expect(isSlackInvalidBlocksError(new Error("invalid_blocks"))).toBe(false); + }); + + it("routes supported blocks to their complete fallback renderers", () => { + expect(renderSlackNativeDataFallbackText(chart)).toBe( + "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", + ); + expect(renderSlackNativeDataFallbackText(table)).toBe( + "Pipeline report (table)\n- Account: Acme; ARR: $125k", + ); + expect(renderSlackNativeDataFallbackText({ type: "section" })).toBeUndefined(); + }); + + it("escapes raw structured-data tokens before rendering mrkdwn fallbacks", () => { + expect( + renderSlackNativeDataFallbackText({ + type: "data_table", + caption: " pipeline", + rows: [[{ type: "raw_text", text: "Owner" }], [{ type: "raw_text", text: "<@U123>" }]], + }), + ).toBe("<!channel> pipeline (table)\n- Owner: <@U123>"); + expect( + renderSlackNativeDataFallbackText({ + type: "data_visualization", + title: " revenue", + chart: { + type: "pie", + segments: [{ label: "<@U456>", value: 1 }], + }, + }), + ).toBe("<!here> revenue (pie chart)\n- <@U456>: 1"); + }); + + it("appends mixed native data in block order exactly once", () => { + const chartText = "Revenue mix (pie chart)\n- Product: 60\n- Services: 40"; + const tableText = "Pipeline report (table)\n- Account: Acme; ARR: $125k"; + const expected = `Overview\n\n${chartText}\n\n${tableText}`; + + expect(appendSlackNativeDataFallbackText("Overview", [chart, table, chart])).toBe(expected); + expect(appendSlackNativeDataFallbackText(expected, [chart, table])).toBe(expected); + expect( + appendSlackNativeDataFallbackText("Revenue mix (pie chart) - Product: 60 - Services: 40", [ + chart, + ]), + ).toBe("Revenue mix (pie chart) - Product: 60 - Services: 40"); + }); +}); diff --git a/extensions/slack/src/native-data-blocks.ts b/extensions/slack/src/native-data-blocks.ts new file mode 100644 index 000000000000..63f91e37138f --- /dev/null +++ b/extensions/slack/src/native-data-blocks.ts @@ -0,0 +1,129 @@ +// Shared detection and text fallback for Slack's native chart and table blocks. +import type { Block, KnownBlock } from "@slack/web-api"; +import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +import { SLACK_MAX_BLOCKS } from "./blocks-input.js"; +import { hasSlackDataTableBlock, renderSlackDataTableMrkdwnFallbackText } from "./data-table.js"; +import { + hasSlackDataVisualizationBlock, + renderSlackDataVisualizationMrkdwnFallbackText, +} from "./data-visualization.js"; +import { SLACK_SECTION_TEXT_MAX } from "./presentation.js"; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Detect a native Slack chart or table block. */ +export function hasSlackNativeDataBlock(blocks?: readonly unknown[]): boolean { + return hasSlackDataVisualizationBlock(blocks) || hasSlackDataTableBlock(blocks); +} + +/** Match Slack's Web API and response_url `invalid_blocks` error shapes. */ +export function isSlackInvalidBlocksError(error: unknown): boolean { + const record = asRecord(error); + const rawData = record?.data; + const data = asRecord(rawData); + const rawResponseData = asRecord(record?.response)?.data; + const responseData = asRecord(rawResponseData); + const code = + data?.error ?? + (typeof rawData === "string" ? rawData : undefined) ?? + responseData?.error ?? + (typeof rawResponseData === "string" ? rawResponseData : undefined) ?? + record?.error; + return typeof code === "string" && code.trim().toLowerCase() === "invalid_blocks"; +} + +/** Extract a complete accessible summary from a supported native data block. */ +export function renderSlackNativeDataFallbackText(value: unknown): string | undefined { + const type = asRecord(value)?.type; + if (type === "data_visualization") { + return renderSlackDataVisualizationMrkdwnFallbackText(value); + } + if (type === "data_table") { + return renderSlackDataTableMrkdwnFallbackText(value); + } + return undefined; +} + +/** Replace rejected native data with visible mrkdwn while retaining valid sibling blocks. */ +export function buildSlackNativeDataFallbackBlocks( + blocks?: readonly (Block | KnownBlock)[], +): (Block | KnownBlock)[] | undefined { + if (!blocks) { + return undefined; + } + const fallbackBlocks: (Block | KnownBlock)[] = []; + for (const block of blocks) { + const fallbackText = renderSlackNativeDataFallbackText(block); + if (!fallbackText) { + fallbackBlocks.push(block); + continue; + } + fallbackBlocks.push( + ...chunkTextForOutbound(fallbackText, SLACK_SECTION_TEXT_MAX).map( + (text): KnownBlock => ({ + type: "section", + text: { type: "mrkdwn", text, verbatim: true }, + }), + ), + ); + } + if (fallbackBlocks.length > SLACK_MAX_BLOCKS) { + throw new Error( + `Slack native-data fallback requires ${String(fallbackBlocks.length)} blocks to retain every sibling; Slack allows ${String(SLACK_MAX_BLOCKS)}`, + ); + } + return fallbackBlocks; +} + +function comparableText(value: string): string { + return value.replace(/\s+/gu, " ").trim(); +} + +/** True when text already contains every attached native-data fallback in full. */ +export function hasCompleteSlackNativeDataFallbackText( + text: string, + blocks?: readonly unknown[], +): boolean { + const comparableBase = comparableText(text); + let hasNativeDataFallback = false; + for (const block of blocks ?? []) { + const dataText = renderSlackNativeDataFallbackText(block); + if (!dataText) { + continue; + } + hasNativeDataFallback = true; + const comparable = comparableText(dataText); + if (!comparable || !comparableBase.includes(comparable)) { + return false; + } + } + return hasNativeDataFallback; +} + +/** Preserve every native data block's content once in the accessible fallback. */ +export function appendSlackNativeDataFallbackText( + text: string, + blocks?: readonly unknown[], +): string { + const base = text.trim(); + const comparableBase = comparableText(base); + const seen = new Set(); + const dataTexts: string[] = []; + for (const block of blocks ?? []) { + const dataText = renderSlackNativeDataFallbackText(block); + if (!dataText) { + continue; + } + const comparable = comparableText(dataText); + if (!comparable || comparableBase.includes(comparable) || seen.has(comparable)) { + continue; + } + seen.add(comparable); + dataTexts.push(dataText); + } + return [base, ...dataTexts].filter(Boolean).join("\n\n"); +} diff --git a/extensions/slack/src/outbound-adapter.test.ts b/extensions/slack/src/outbound-adapter.test.ts index ed0dc8408e9b..190e1c4a2633 100644 --- a/extensions/slack/src/outbound-adapter.test.ts +++ b/extensions/slack/src/outbound-adapter.test.ts @@ -68,21 +68,26 @@ describe("slackOutbound", () => { mediaLocalRoots: ["/tmp/workspace"], mediaReadFile: undefined, }); - expect(sendMessageSlackMock).toHaveBeenNthCalledWith(3, "C123", "final text", { - cfg, - threadTs: undefined, - accountId: "default", - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: "final text" }, - }, - { - type: "section", - text: { type: "mrkdwn", text: "Block body" }, - }, - ], - }); + expect(sendMessageSlackMock).toHaveBeenNthCalledWith( + 3, + "C123", + "final text\n\nBlock body", + expect.objectContaining({ + cfg, + threadTs: undefined, + accountId: "default", + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "final text" }, + }, + { + type: "section", + text: { type: "mrkdwn", text: "Block body" }, + }, + ], + }), + ); expect(result).toEqual({ channel: "slack", messageId: "m-final" }); }); diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 016fd466f209..7db12975053b 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -8,7 +8,6 @@ import { } from "openclaw/plugin-sdk/channel-send-result"; import { resolveInteractiveTextFallback, - renderMessagePresentationFallbackText, type InteractiveReply, type MessagePresentation, } from "openclaw/plugin-sdk/interactive-runtime"; @@ -24,7 +23,6 @@ import { parseSlackBlocksInput, SLACK_MAX_BLOCKS } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks, - canRenderSlackPresentation, resolveSlackBlockOffsets, type SlackBlock, } from "./blocks-render.js"; @@ -35,7 +33,7 @@ import { } from "./interactive-replies.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; import { SLACK_PRESENTATION_CAPABILITIES, SLACK_SECTION_TEXT_MAX } from "./presentation.js"; -import { resolveSlackReplyText } from "./reply-blocks.js"; +import { resolveSlackReplyRenderPlan, resolveSlackReplyText } from "./reply-blocks.js"; import type { SlackSendIdentity } from "./send.js"; import { resolveSlackThreadTsValue } from "./thread-ts.js"; @@ -122,14 +120,6 @@ function buildSlackVisiblePayloadTextBlocks(payload: ReplyPayload): SlackBlock[] return buildSlackTextSectionBlocks(text); } -function buildSlackPresentationFallback(presentation: MessagePresentation): { - blocks: SlackBlock[]; - text: string; -} { - const text = renderMessagePresentationFallbackText({ presentation }).trim(); - return { blocks: buildSlackTextSectionBlocks(text), text }; -} - function withSlackPresentationData( payload: ReplyPayload, slackData: SlackOutboundChannelData | undefined, @@ -141,13 +131,13 @@ function withSlackPresentationData( const { presentationBlocks: _presentationBlocks, presentationFallbackText: _presentationFallbackText, - ...rest + ...preservedSlackData } = slackData ?? {}; return { ...payload, channelData: { ...payload.channelData, - slack: { ...rest, ...presentationData }, + slack: { ...preservedSlackData, ...presentationData }, }, }; } @@ -164,6 +154,8 @@ async function sendSlackOutboundMessage(params: { mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; blocks?: NonNullable[2]>["blocks"]; + separateTextAndBlocks?: boolean; + textIsSlackMrkdwn?: boolean; accountId?: string | null; deps?: { [channelId: string]: unknown } | null; replyToId?: string | null; @@ -200,6 +192,8 @@ async function sendSlackOutboundMessage(params: { } : {}), ...(params.blocks ? { blocks: params.blocks } : {}), + ...(params.separateTextAndBlocks ? { separateTextAndBlocks: true } : {}), + ...(params.textIsSlackMrkdwn ? { textIsSlackMrkdwn: true } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}), deliveryQueueId: params.deliveryQueueId, onPlatformSendDispatch: params.onPlatformSendDispatch, @@ -212,6 +206,72 @@ async function sendSlackOutboundMessage(params: { return result; } +function createSlackAttachedSendAdapter(textIsSlackMrkdwn = false) { + return createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + identity, + deliveryQueueId, + onPlatformSendDispatch, + onDeliveryResult, + }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + textIsSlackMrkdwn, + accountId, + deps, + replyToId, + threadId, + identity, + deliveryQueueId, + onPlatformSendDispatch, + onDeliveryResult, + }), + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaAccess, + mediaLocalRoots, + mediaReadFile, + accountId, + deps, + replyToId, + threadId, + identity, + onPlatformSendDispatch, + onDeliveryResult, + }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + textIsSlackMrkdwn, + mediaUrl, + mediaAccess, + mediaLocalRoots, + mediaReadFile, + accountId, + deps, + replyToId, + threadId, + identity, + onPlatformSendDispatch, + onDeliveryResult, + }), + }); +} + function resolveSlackBlocks(payload: { channelData?: Record; interactive?: InteractiveReply; @@ -263,77 +323,34 @@ export const slackOutbound: ChannelOutboundAdapter = { : payload; const slackData = payload.channelData?.slack as SlackOutboundChannelData | undefined; const nativeBlocks = parseSlackBlocksInput(slackData?.blocks) as SlackBlock[] | undefined; - const payloadTextBlocks = buildSlackVisiblePayloadTextBlocks(payloadForBudget); - if (canRenderSlackPresentation(presentation)) { - const presentationBlocks = [ - ...payloadTextBlocks, - ...buildSlackPresentationBlocks(presentation, resolveSlackBlockOffsets(nativeBlocks)), - ]; - const previousBlocks = [...(nativeBlocks ?? []), ...presentationBlocks]; - const interactiveBlocks = resolveRenderedInteractiveBlocks( - payloadForBudget.interactive, - previousBlocks, - ); - if ( - presentationBlocks.length > 0 && - previousBlocks.length + (interactiveBlocks?.length ?? 0) <= SLACK_MAX_BLOCKS - ) { - return withSlackPresentationData( - { - ...payloadForBudget, - text: resolveSlackReplyText( - { ...payloadForBudget, presentation }, - payloadForBudget.text, - ), - }, - slackData, - { presentationBlocks }, - ); - } - } - - const baseInteractiveBlocks = resolveRenderedInteractiveBlocks( - payloadForBudget.interactive, - nativeBlocks, - ); - if ((nativeBlocks?.length ?? 0) + (baseInteractiveBlocks?.length ?? 0) === 0) { - return null; - } - const fallback = buildSlackPresentationFallback(presentation); - const fallbackBlocks = [...payloadTextBlocks, ...fallback.blocks]; - if (fallbackBlocks.length === 0) { - return null; - } - const fallbackPayload = { - ...payloadForBudget, - text: renderMessagePresentationFallbackText({ - text: payloadForBudget.text, + const renderPlan = resolveSlackReplyRenderPlan( + { presentation, - }), - }; - const fallbackPreviousBlocks = [...(nativeBlocks ?? []), ...fallbackBlocks]; - const fallbackInteractiveBlocks = resolveRenderedInteractiveBlocks( - payloadForBudget.interactive, - fallbackPreviousBlocks, + interactive: payloadForBudget.interactive, + ...(nativeBlocks?.length ? { channelData: { slack: { blocks: nativeBlocks } } } : {}), + }, + payloadForBudget.text, ); - if ( - fallbackPreviousBlocks.length + (fallbackInteractiveBlocks?.length ?? 0) <= - SLACK_MAX_BLOCKS - ) { - return withSlackPresentationData(fallbackPayload, slackData, { - presentationBlocks: fallbackBlocks, - }); + const { blocks: _blocks, ...slackDataWithoutBlocks } = slackData ?? {}; + if (renderPlan.mode === "split") { + // Native and interactive blocks remain authored content during presentation fallback. + // Ordinary Slack validation still rejects them rather than silently dropping them. + return withSlackPresentationData( + { ...payloadForBudget, text: renderPlan.hookText, interactive: undefined }, + slackDataWithoutBlocks, + { + presentationBlocks: renderPlan.blockPart?.blocks ?? [], + presentationFallbackText: renderPlan.fallbackText, + }, + ); } - - const separateFallbackText = renderMessagePresentationFallbackText({ - text: payloadTextBlocks.length > 0 ? payloadForBudget.text : undefined, - presentation, - }); - const separateFallbackPayload = - payloadTextBlocks.length > 0 ? { ...payloadForBudget, text: undefined } : payloadForBudget; - return withSlackPresentationData(separateFallbackPayload, slackData, { - presentationFallbackText: separateFallbackText, - }); + // The planner owns block ordering, offsets, and complete accessibility text. + // Rebuilding any subset here can duplicate or hide authored Slack content. + return withSlackPresentationData( + { ...payloadForBudget, text: renderPlan.text, interactive: undefined }, + slackDataWithoutBlocks, + { presentationBlocks: renderPlan.blocks ?? [] }, + ); }, sendPayload: async (ctx) => { const payload = { @@ -347,6 +364,12 @@ export const slackOutbound: ChannelOutboundAdapter = { const accessibleText = resolveSlackReplyText(payload); const slackData = payload.channelData?.slack as SlackOutboundChannelData | undefined; const presentationFallbackText = normalizeOptionalString(slackData?.presentationFallbackText); + const textIsSlackMrkdwn = Boolean( + presentationFallbackText || + payload.presentation?.blocks.some( + (block) => block.type === "chart" || block.type === "table", + ), + ); const blocks = resolveSlackBlocks(payload); if (!blocks) { return await sendTextMediaPayload({ @@ -356,13 +379,13 @@ export const slackOutbound: ChannelOutboundAdapter = { payload: presentationFallbackText ? { ...payload, - text: [normalizeOptionalString(payload.text), presentationFallbackText] - .filter((part): part is string => Boolean(part)) - .join("\n\n"), + text: presentationFallbackText, } : { ...payload, text: accessibleText }, }, - adapter: slackOutbound, + adapter: presentationFallbackText + ? { ...slackOutbound, ...createSlackAttachedSendAdapter(true) } + : slackOutbound, }); } const mediaUrls = resolvePayloadMediaUrls(payload); @@ -388,10 +411,15 @@ export const slackOutbound: ChannelOutboundAdapter = { onDeliveryResult: ctx.onDeliveryResult, }), finalize: async () => { - const blockResult = await sendSlackOutboundMessage({ + return await sendSlackOutboundMessage({ cfg: ctx.cfg, to: ctx.to, - text: accessibleText, + text: presentationFallbackText ?? accessibleText, + ...(presentationFallbackText + ? { separateTextAndBlocks: true, textIsSlackMrkdwn: true } + : textIsSlackMrkdwn + ? { textIsSlackMrkdwn: true } + : {}), mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, @@ -401,88 +429,17 @@ export const slackOutbound: ChannelOutboundAdapter = { replyToId: ctx.replyToId, threadId: ctx.threadId, identity: ctx.identity, - onDeliveryResult: ctx.onDeliveryResult, - }); - if (!presentationFallbackText) { - return blockResult; - } - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: presentationFallbackText, - mediaAccess: ctx.mediaAccess, - mediaLocalRoots: ctx.mediaLocalRoots, - mediaReadFile: ctx.mediaReadFile, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, + ...(mediaUrls.length === 0 + ? { + deliveryQueueId: ctx.deliveryQueueId, + onPlatformSendDispatch: ctx.onPlatformSendDispatch, + } + : {}), onDeliveryResult: ctx.onDeliveryResult, }); }, }), ); }, - ...createAttachedChannelResultAdapter({ - channel: "slack", - sendText: async ({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - identity, - deliveryQueueId, - onPlatformSendDispatch, - onDeliveryResult, - }) => - await sendSlackOutboundMessage({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - identity, - deliveryQueueId, - onPlatformSendDispatch, - onDeliveryResult, - }), - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - accountId, - deps, - replyToId, - threadId, - identity, - onPlatformSendDispatch, - onDeliveryResult, - }) => - await sendSlackOutboundMessage({ - cfg, - to, - text, - mediaUrl, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - accountId, - deps, - replyToId, - threadId, - identity, - onPlatformSendDispatch, - onDeliveryResult, - }), - }), + ...createSlackAttachedSendAdapter(), }; diff --git a/extensions/slack/src/outbound-payload.test.ts b/extensions/slack/src/outbound-payload.test.ts index 8413f570f83d..90530951f4c0 100644 --- a/extensions/slack/src/outbound-payload.test.ts +++ b/extensions/slack/src/outbound-payload.test.ts @@ -3,6 +3,8 @@ import { installChannelOutboundPayloadContractSuite } from "openclaw/plugin-sdk/ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { describe, expect, it } from "vitest"; import { createSlackOutboundPayloadHarness, slackOutbound } from "../test-api.js"; +import { createSlackSendTestClient } from "./blocks.test-helpers.js"; +import { sendMessageSlack } from "./send.js"; function createHarness(params: { payload: ReplyPayload; @@ -121,6 +123,296 @@ describe("slackOutbound sendPayload", () => { ]); }); + it("uses the prepared chart plan for raw siblings and interactive controls", async () => { + const payload: ReplyPayload = { + text: "Revenue summary", + channelData: { + slack: { + blocks: [{ type: "section", text: { type: "mrkdwn", text: "Raw workspace context" } }], + }, + }, + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }], + }, + presentation: { + blocks: [ + { + type: "chart", + chartType: "pie", + title: "Revenue mix", + segments: [ + { label: "Product", value: 60 }, + { label: "Services", value: 40 }, + ], + }, + ], + }, + }; + + const rendered = await slackOutbound.renderPresentation?.({ + payload, + presentation: payload.presentation!, + ctx: { cfg: {}, to: "C12345", text: payload.text ?? "", payload }, + }); + const slackData = rendered?.channelData?.slack as { + blocks?: unknown[]; + presentationBlocks?: Array<{ type?: string }>; + }; + + expect(slackData.blocks).toBeUndefined(); + expect(slackData.presentationBlocks?.map((block) => block.type)).toEqual([ + "section", + "section", + "data_visualization", + "actions", + ]); + expect(rendered?.interactive).toBeUndefined(); + expect(rendered?.text).toContain("Raw workspace context"); + expect(rendered?.text).toContain("- Refresh"); + }); + + it("renders native tables with complete top-level accessibility text", async () => { + const { run, sendMock } = createHarness({ + payload: { + text: "Pipeline summary", + presentation: { + blocks: [ + { + type: "table", + caption: "Open pipeline", + headers: ["Account", "ARR"], + rows: [ + ["Acme", 125000], + ["Globex", 82000], + ], + rowHeaderColumnIndex: 0, + }, + ], + }, + }, + }); + + await run(); + + const call = sendCall(sendMock, 0); + expect(call[1]).toBe( + [ + "Pipeline summary", + "", + "Open pipeline (table)", + "- Account: Acme; ARR: 125000", + "- Account: Globex; ARR: 82000", + ].join("\n"), + ); + expect(sendOptions(call).blocks).toEqual([ + { type: "section", text: { type: "mrkdwn", text: "Pipeline summary" } }, + { + type: "data_table", + caption: "Open pipeline", + row_header_column_index: 0, + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_number", value: 125000, text: "125000" }, + ], + [ + { type: "raw_text", text: "Globex" }, + { type: "raw_number", value: 82000, text: "82000" }, + ], + ], + }, + ]); + }); + + it("posts Slack-safe text when a portable table cannot render natively", async () => { + const payload: ReplyPayload = { + channelData: { + slack: { + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "Existing raw block only" }, + }, + ], + }, + }, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + presentation: { + title: "Pipeline ", + blocks: [ + { + type: "table", + caption: "Accounts", + headers: ["Owner"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + index === 0 ? "<@U123>" : `owner-${String(index)} ${"x".repeat(110)}`, + ]), + }, + ], + }, + }; + + const rendered = await slackOutbound.renderPresentation?.({ + payload, + presentation: payload.presentation!, + ctx: { cfg: {}, to: "C12345", text: "", payload }, + }); + if (!rendered) { + throw new Error("Expected Slack to render a table fallback"); + } + const { presentation: _presentation, ...payloadForSend } = rendered; + const client = createSlackSendTestClient(); + const cfg = { channels: { slack: { botToken: "xoxb-test" } } }; + const sendSlack: typeof sendMessageSlack = async (to, text, opts) => + await sendMessageSlack(to, text, { + ...opts, + cfg, + token: "xoxb-test", + client, + }); + + await slackOutbound.sendPayload?.({ + cfg, + to: "channel:C123", + text: "", + payload: payloadForSend, + deps: { sendSlack }, + }); + + const postedText = client.chat.postMessage.mock.calls + .map(([raw]) => (raw as { text?: string }).text ?? "") + .join("\n"); + expect(client.chat.postMessage.mock.calls.length).toBeGreaterThan(1); + expect(client.chat.postMessage.mock.calls[0]?.[0]).toMatchObject({ + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "Existing raw block only" }, + }, + { + type: "actions", + elements: [expect.objectContaining({ type: "button", value: "refresh" })], + }, + ], + }); + expect( + client.chat.postMessage.mock.calls + .slice(1) + .every(([raw]) => (raw as { blocks?: unknown }).blocks === undefined), + ).toBe(true); + expect(postedText).toContain("Pipeline <!channel>"); + expect(postedText).toContain("Existing raw block only"); + expect(postedText).toContain("- Owner: <@U123>"); + expect(postedText).toContain("- Owner: owner-99"); + expect(postedText).not.toContain(""); + expect(postedText).not.toContain("<@U123>"); + }); + + it("keeps a native table beside its chunked accessibility fallback", async () => { + const payload: ReplyPayload = { + channelData: { + slack: { + blocks: [ + { + type: "data_table", + caption: "Native accounts", + row_header_column_index: 0, + rows: [ + [{ type: "raw_text", text: "Account" }], + ...Array.from({ length: 90 }, (_entry, index) => [ + { + type: "raw_text", + text: `owner-${String(index)}-${"x".repeat(80)}`, + }, + ]), + ], + }, + { + type: "section", + text: { type: "mrkdwn", text: "Existing raw block only" }, + }, + ], + }, + }, + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }; + + const rendered = await slackOutbound.renderPresentation?.({ + payload, + presentation: payload.presentation!, + ctx: { cfg: {}, to: "C12345", text: "", payload }, + }); + if (!rendered) { + throw new Error("Expected Slack to split the native table fallback"); + } + const slackData = rendered.channelData?.slack as { + blocks?: unknown[]; + presentationBlocks?: unknown[]; + presentationFallbackText?: string; + }; + expect(slackData.blocks).toBeUndefined(); + expect(slackData.presentationBlocks).toEqual([ + expect.objectContaining({ type: "data_table", caption: "Native accounts" }), + expect.objectContaining({ + type: "actions", + elements: [expect.objectContaining({ type: "button", value: "refresh" })], + }), + ]); + expect(slackData.presentationFallbackText).toContain("Existing raw block only"); + expect(slackData.presentationFallbackText).toContain("Native accounts (table)"); + expect(slackData.presentationFallbackText).toContain("owner-89-"); + + const { presentation: _presentation, ...payloadForSend } = rendered; + const client = createSlackSendTestClient(); + const cfg = { channels: { slack: { botToken: "xoxb-test" } } }; + const sendSlack: typeof sendMessageSlack = async (to, text, opts) => + await sendMessageSlack(to, text, { + ...opts, + cfg, + token: "xoxb-test", + client, + }); + + await slackOutbound.sendPayload?.({ + cfg, + to: "channel:C123", + text: "", + payload: payloadForSend, + deps: { sendSlack }, + }); + + expect(client.chat.postMessage.mock.calls[0]?.[0]).toMatchObject({ + text: "Native accounts (table)\n\n- Refresh", + blocks: [ + expect.objectContaining({ type: "data_table", caption: "Native accounts" }), + expect.objectContaining({ type: "actions" }), + ], + }); + expect( + client.chat.postMessage.mock.calls + .slice(1) + .every(([raw]) => (raw as { blocks?: unknown }).blocks === undefined), + ).toBe(true); + }); + it("keeps the full portable fallback when any control cannot render natively", async () => { const payload: ReplyPayload = { text: "Fallback", @@ -147,7 +439,11 @@ describe("slackOutbound sendPayload", () => { }, }); - expect(rendered).toBeNull(); + expect(rendered?.channelData?.slack).toEqual({ + presentationBlocks: [], + presentationFallbackText: "Fallback\n\nActions\n\nChoose an action\n\n- Status: `/status`", + }); + expect(rendered?.text).toBe("Fallback\n\nActions\n\nChoose an action\n\n- Status: `/status`"); }); it("renders the portable fallback visibly when native Slack blocks survive", async () => { @@ -172,16 +468,8 @@ describe("slackOutbound sendPayload", () => { }); expect(rendered?.channelData?.slack).toEqual({ - blocks: [{ type: "divider" }], - presentationBlocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: "Actions\n\nChoose an action\n\n• Status: `/status`", - }, - }, - ], + presentationBlocks: [{ type: "divider" }], + presentationFallbackText: "Actions\n\nChoose an action\n\n- Status: `/status`", }); expect(rendered?.text).toBe("Actions\n\nChoose an action\n\n- Status: `/status`"); }); @@ -257,7 +545,14 @@ describe("slackOutbound sendPayload", () => { ctx: { cfg: {}, to: "C12345", text: "", payload }, }); - expect(rendered).toBeNull(); + expect(rendered?.channelData?.slack).toEqual({ + presentationBlocks: [], + presentationFallbackText: + presentation.title ?? + (presentation.blocks[0] && "text" in presentation.blocks[0] + ? presentation.blocks[0].text + : undefined), + }); }); it("marks a separate visible fallback when presentation cannot fit Slack's block limit", async () => { @@ -284,11 +579,18 @@ describe("slackOutbound sendPayload", () => { ctx: { cfg: {}, to: "C12345", text: "", payload }, }); - expect(rendered?.channelData?.slack).toEqual({ - blocks: Array.from({ length: 49 }, () => ({ type: "divider" })), - presentationFallbackText: "Deploy status", - }); - expect(rendered?.text).toBeUndefined(); + const slackData = rendered?.channelData?.slack as { + blocks?: unknown[]; + presentationBlocks?: Array<{ type?: string }>; + presentationFallbackText?: string; + }; + expect(slackData.blocks).toBeUndefined(); + expect(slackData.presentationBlocks).toHaveLength(50); + expect(slackData.presentationBlocks?.slice(0, 49)).toEqual( + Array.from({ length: 49 }, () => ({ type: "divider" })), + ); + expect(slackData.presentationBlocks?.at(-1)).toMatchObject({ type: "actions" }); + expect(slackData.presentationFallbackText).toBe("Deploy status"); }); it("counts legacy interactive blocks compiled after presentation rendering", async () => { @@ -322,10 +624,21 @@ describe("slackOutbound sendPayload", () => { }, }); - expect(rendered?.channelData?.slack).toEqual({ - blocks: Array.from({ length: 48 }, () => ({ type: "divider" })), - presentationFallbackText: "Deploy status", - }); + const slackData = rendered?.channelData?.slack as { + blocks?: unknown[]; + presentationBlocks?: Array<{ type?: string }>; + presentationFallbackText?: string; + }; + expect(slackData.blocks).toBeUndefined(); + expect(slackData.presentationBlocks).toHaveLength(50); + expect(slackData.presentationBlocks?.slice(0, 48)).toEqual( + Array.from({ length: 48 }, () => ({ type: "divider" })), + ); + expect(slackData.presentationBlocks?.slice(-2)).toEqual([ + expect.objectContaining({ type: "section" }), + expect.objectContaining({ type: "actions" }), + ]); + expect(slackData.presentationFallbackText).toContain("Deploy status"); }); it("does not duplicate text compiled around inline legacy controls", async () => { @@ -355,13 +668,14 @@ describe("slackOutbound sendPayload", () => { }); expect(rendered?.channelData?.slack).toEqual({ - presentationBlocks: [{ type: "divider" }], + presentationBlocks: [ + { type: "divider" }, + { type: "section", text: { type: "mrkdwn", text: "Before" } }, + expect.objectContaining({ type: "actions" }), + { type: "section", text: { type: "mrkdwn", text: "after" } }, + ], }); - expect(rendered?.interactive?.blocks).toEqual([ - { type: "text", text: "Before" }, - { type: "buttons", buttons: [{ label: "OK", value: "ok" }] }, - { type: "text", text: "after" }, - ]); + expect(rendered?.interactive).toBeUndefined(); }); it("sends a block-budget fallback as a separate visible message", async () => { @@ -375,18 +689,18 @@ describe("slackOutbound sendPayload", () => { }, }, }, - sendResults: [{ messageId: "sl-blocks" }, { messageId: "sl-fallback" }], + sendResults: [{ messageId: "sl-blocks" }], }); const result = await run(); - expect(sendMock).toHaveBeenCalledTimes(2); - expect(sendCall(sendMock, 0)[0]).toBe(to); - expect(sendOptions(sendCall(sendMock, 0)).blocks).toEqual([{ type: "divider" }]); - expect(sendCall(sendMock, 1)[0]).toBe(to); - expect(sendCall(sendMock, 1)[1]).toBe("Visible presentation fallback"); - expect(sendCall(sendMock, 1)[2]).not.toHaveProperty("blocks"); - expect(result.messageId).toBe("sl-fallback"); + expect(sendMock).toHaveBeenCalledOnce(); + const call = sendCall(sendMock, 0); + expect(call[0]).toBe(to); + expect(call[1]).toBe("Visible presentation fallback"); + expect(sendOptions(call).blocks).toEqual([{ type: "divider" }]); + expect(call[2]).toMatchObject({ separateTextAndBlocks: true, textIsSlackMrkdwn: true }); + expect(result.messageId).toBe("sl-blocks"); }); it("sends media before a separate interactive blocks message", async () => { @@ -422,10 +736,15 @@ describe("slackOutbound sendPayload", () => { expect(result.messageId).toBe("sl-controls"); }); - it("fails when merged Slack blocks exceed the platform limit", async () => { + it("rejects over-limit table fallbacks instead of dropping authored blocks", async () => { const { run, sendMock } = createHarness({ payload: { - presentation: { blocks: Array.from({ length: 50 }, () => ({ type: "divider" })) }, + channelData: { + slack: { blocks: Array.from({ length: 50 }, () => ({ type: "divider" })) }, + }, + presentation: { + blocks: [{ type: "table", caption: "Accounts", headers: ["Account"], rows: [["Acme"]] }], + }, interactive: { blocks: [ { @@ -480,7 +799,7 @@ describe("slackOutbound sendPayload", () => { expect(sendMock).toHaveBeenCalledTimes(1); const call = sendCall(sendMock, 0); expect(call[0]).toBe(to); - expect(call[1]).toBe("Deploy?"); + expect(call[1]).toBe("Deploy?\n\n- Stage"); const blocks = sendOptions(call).blocks; expect(blocks?.[0]?.block_id).toBe("openclaw_reply_buttons_1"); expect(blocks?.[1]?.type).toBe("section"); diff --git a/extensions/slack/src/presentation-fallback.ts b/extensions/slack/src/presentation-fallback.ts new file mode 100644 index 000000000000..55c45c5f03c4 --- /dev/null +++ b/extensions/slack/src/presentation-fallback.ts @@ -0,0 +1,143 @@ +// Slack-specific structured-data fallback keeps raw chart/table values literal in mrkdwn. +import { + renderMessagePresentationChartFallbackText, + renderMessagePresentationFallbackText, + renderMessagePresentationTableFallbackText, + type MessagePresentation, + type MessagePresentationBlock, + type MessagePresentationButton, + type MessagePresentationChartBlock, + type MessagePresentationTableBlock, +} from "openclaw/plugin-sdk/interactive-runtime"; +import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; +import { SLACK_SECTION_TEXT_MAX } from "./presentation.js"; + +function escapeSlackFallbackCommand(command: string, escapedLabel: string): string | undefined { + // Slack parses mrkdwn before decoding these entities. That keeps mention/link + // tokens inert while preserving the displayed, copyable command bytes. + if (/[\r\n]/u.test(escapedLabel) || /[\\`\r\n]/u.test(command)) { + return undefined; + } + const escaped = command.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + const fallbackLine = `- ${escapedLabel}: \`${escaped}\``; + return Array.from(fallbackLine).length <= SLACK_SECTION_TEXT_MAX ? escaped : undefined; +} + +function escapeSlackPresentationButton( + button: MessagePresentationButton, +): MessagePresentationButton { + const { action, ...rest } = button; + const label = escapeSlackMrkdwn(button.label); + const command = + action?.type === "command" ? escapeSlackFallbackCommand(action.command, label) : undefined; + return { + ...rest, + label, + ...(button.value ? { value: escapeSlackMrkdwn(button.value) } : {}), + ...(button.url ? { url: escapeSlackMrkdwn(button.url) } : {}), + ...(button.webApp ? { webApp: { url: escapeSlackMrkdwn(button.webApp.url) } } : {}), + ...(button.web_app ? { web_app: { url: escapeSlackMrkdwn(button.web_app.url) } } : {}), + ...(action?.type === "command" + ? command + ? { action: { ...action, command } } + : {} + : action + ? { action } + : {}), + }; +} + +function escapeSlackPresentationChartBlock( + block: MessagePresentationChartBlock, +): MessagePresentationChartBlock { + if (block.chartType === "pie") { + return { + ...block, + title: escapeSlackMrkdwn(block.title), + segments: block.segments.map((segment) => ({ + ...segment, + label: escapeSlackMrkdwn(segment.label), + })), + }; + } + return { + ...block, + title: escapeSlackMrkdwn(block.title), + categories: block.categories.map(escapeSlackMrkdwn), + series: block.series.map((series) => ({ + ...series, + name: escapeSlackMrkdwn(series.name), + })), + ...(block.xLabel ? { xLabel: escapeSlackMrkdwn(block.xLabel) } : {}), + ...(block.yLabel ? { yLabel: escapeSlackMrkdwn(block.yLabel) } : {}), + }; +} + +function escapeSlackPresentationTableBlock( + block: MessagePresentationTableBlock, +): MessagePresentationTableBlock { + return { + ...block, + caption: escapeSlackMrkdwn(block.caption), + headers: block.headers.map(escapeSlackMrkdwn), + rows: block.rows.map((row) => + row.map((cell) => (typeof cell === "string" ? escapeSlackMrkdwn(cell) : cell)), + ), + }; +} + +function escapeSlackPresentationFallbackBlock( + block: MessagePresentationBlock, +): MessagePresentationBlock { + if (block.type === "chart") { + return escapeSlackPresentationChartBlock(block); + } + if (block.type === "table") { + return escapeSlackPresentationTableBlock(block); + } + if (block.type === "buttons") { + return { + ...block, + buttons: block.buttons.map(escapeSlackPresentationButton), + }; + } + if (block.type === "select") { + return { + ...block, + ...(block.placeholder ? { placeholder: escapeSlackMrkdwn(block.placeholder) } : {}), + options: block.options.map((option) => ({ + ...option, + label: escapeSlackMrkdwn(option.label), + })), + }; + } + return block; +} + +export function renderSlackMessagePresentationChartFallbackText( + block: MessagePresentationChartBlock, +): string { + return renderMessagePresentationChartFallbackText(escapeSlackPresentationChartBlock(block)); +} + +export function renderSlackMessagePresentationTableFallbackText( + block: MessagePresentationTableBlock, +): string { + return renderMessagePresentationTableFallbackText(escapeSlackPresentationTableBlock(block)); +} + +export function renderSlackMessagePresentationFallbackText(params: { + presentation?: MessagePresentation; + emptyFallback?: string | null; + text?: string | null; +}): string { + if (!params.presentation) { + return renderMessagePresentationFallbackText(params); + } + const presentation: MessagePresentation = { + ...params.presentation, + ...(params.presentation.title ? { title: escapeSlackMrkdwn(params.presentation.title) } : {}), + blocks: params.presentation.blocks.map(escapeSlackPresentationFallbackBlock), + }; + return renderMessagePresentationFallbackText({ ...params, presentation }); +} diff --git a/extensions/slack/src/presentation.ts b/extensions/slack/src/presentation.ts index 90f29ec92e8b..933237fd9821 100644 --- a/extensions/slack/src/presentation.ts +++ b/extensions/slack/src/presentation.ts @@ -16,6 +16,7 @@ export const SLACK_PRESENTATION_CAPABILITIES = { context: true, divider: true, charts: true, + tables: true, limits: { actions: { maxActionsPerRow: SLACK_ACTION_BLOCK_ELEMENTS_MAX, diff --git a/extensions/slack/src/reply-blocks.test.ts b/extensions/slack/src/reply-blocks.test.ts new file mode 100644 index 000000000000..da382512a469 --- /dev/null +++ b/extensions/slack/src/reply-blocks.test.ts @@ -0,0 +1,809 @@ +import { describe, expect, it } from "vitest"; +import { resolveSlackReplyRenderPlan, resolveSlackReplyText } from "./reply-blocks.js"; + +describe("resolveSlackReplyText", () => { + it("leaves long plain Markdown on the normal text chunking path", () => { + const text = `[link](https://example.com) ${"x".repeat(8_100)}`; + const plan = resolveSlackReplyRenderPlan({ text }); + + expect(plan.mode).toBe("single"); + if (plan.mode !== "single") { + return; + } + expect(plan.blocks).toBeUndefined(); + expect(plan.text).toBe(text); + expect(plan.textIsSlackMrkdwn).toBeUndefined(); + }); + + it("includes complete portable table data in Slack accessibility text", () => { + expect( + resolveSlackReplyText({ + text: "Pipeline summary", + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000], + ], + }, + ], + }, + }), + ).toBe( + "Pipeline summary\n\nPipeline (table)\n- Account: Acme; Stage: Won; ARR: 125000\n- Account: Globex; Stage: Review; ARR: 82000", + ); + }); + + it("keeps raw table values literal without changing authored Slack text", () => { + expect( + resolveSlackReplyText({ + text: "Intentional ", + presentation: { + title: "Report <@U999>", + blocks: [ + { + type: "table", + caption: " *report*", + headers: ["Owner_name"], + rows: [["<@U123> & "]], + }, + ], + }, + }), + ).toBe( + "Intentional \n\nReport <@U999>\n\n<!channel> \\*report\\* (table)\n- Owner\\_name: <@U123> & <https://example.com>", + ); + }); + + it("keeps plain-text controls literal when they accompany structured data", () => { + expect( + resolveSlackReplyText({ + presentation: { + blocks: [ + { type: "table", caption: "Data", headers: ["Value"], rows: [[1]] }, + { + type: "buttons", + buttons: [ + { + label: "Notify ", + url: "https://example.com/?a=1&b=2", + }, + { + label: "Run <@U1>", + action: { type: "command", command: "/say " }, + }, + ], + }, + { + type: "select", + placeholder: "Owner ", + options: [{ label: "<@U2>", value: "owner" }], + }, + ], + }, + }), + ).toBe( + [ + "Data (table)", + "- Value: 1", + "", + "- Notify <!here>: https://example.com/?a=1&b=2", + "- Run <@U1>: `/say <!channel>`", + "", + "Owner <!channel>:", + "- <@U2>", + ].join("\n"), + ); + }); + + it("marks non-native portable tables for text fallback while retaining native blocks", () => { + const payload = { + channelData: { slack: { blocks: [{ type: "divider" }] } }, + presentation: { + blocks: [ + { + type: "table" as const, + caption: "Large pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + `account-${String(index)} ${"x".repeat(110)}`, + ]), + }, + ], + }, + interactive: { + blocks: [ + { + type: "buttons" as const, + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }; + + expect(resolveSlackReplyRenderPlan(payload)).toEqual({ + mode: "split", + blockPart: { + text: "- Refresh", + blocks: [ + { type: "divider" }, + { + type: "actions", + block_id: "openclaw_reply_buttons_1", + elements: [ + { + type: "button", + action_id: "openclaw:reply_button:1:1", + text: { type: "plain_text", text: "Refresh", emoji: true }, + value: "refresh", + }, + ], + }, + ], + }, + fallbackText: expect.stringContaining("- Account: account-99"), + hookText: expect.stringContaining("- Account: account-99"), + }); + expect(resolveSlackReplyText(payload)).toContain("- Account: account-99"); + }); + + it("rejects over-limit table fallbacks instead of dropping authored blocks", () => { + const payload = { + channelData: { + slack: { blocks: Array.from({ length: 50 }, () => ({ type: "divider" })) }, + }, + presentation: { + blocks: [ + { + type: "table" as const, + caption: "Accounts", + headers: ["Account"], + rows: [["Acme"]], + }, + ], + }, + interactive: { + blocks: [ + { + type: "buttons" as const, + buttons: [{ label: "Refresh", value: "refresh" }], + }, + ], + }, + }; + + expect(() => resolveSlackReplyRenderPlan(payload)).toThrow( + /Slack blocks cannot exceed 50 items/i, + ); + }); + + it("does not create an empty fallback message for non-textual overflow", () => { + const plan = resolveSlackReplyRenderPlan({ + channelData: { + slack: { blocks: Array.from({ length: 50 }, () => ({ type: "divider" })) }, + }, + presentation: { blocks: [{ type: "divider" }] }, + }); + + expect(plan.mode).toBe("single"); + if (plan.mode !== "single") { + return; + } + expect(plan.blocks).toHaveLength(50); + expect(plan.text).toBe("Shared a Block Kit message"); + }); + + it("keeps authored text visible before a native table", () => { + const plan = resolveSlackReplyRenderPlan({ + text: "[Pipeline](https://example.com)", + presentation: { + blocks: [{ type: "table", caption: "Pipeline", headers: ["Account"], rows: [["Acme"]] }], + }, + }); + + expect(plan.mode).toBe("single"); + if (plan.mode !== "single") { + return; + } + expect(plan.blocks?.[0]).toEqual({ + type: "section", + text: { + type: "mrkdwn", + text: "", + verbatim: true, + }, + }); + expect(plan.blocks?.[1]).toMatchObject({ type: "data_table", caption: "Pipeline" }); + }); + + it("moves long presentation text beside a table to complete fallback", () => { + const longText = `start-${"x".repeat(3980)}-tail`; + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { type: "text", text: longText }, + { type: "table", caption: "Pipeline", headers: ["Account"], rows: [["Acme"]] }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart).toBeUndefined(); + expect(plan.fallbackText).toContain("-tail"); + expect(plan.fallbackText).toContain("Pipeline (table)"); + }); + + it("moves standalone overlong presentation text to complete fallback", () => { + const longText = `standalone-${"x".repeat(3980)}-tail`; + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { type: "text", text: longText }, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart?.text).toBe("- Refresh"); + expect(plan.fallbackText).toContain("-tail"); + expect(plan.fallbackText).not.toContain("- Refresh"); + }); + + it("moves an over-limit presentation control row to text fallback", () => { + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { type: "table", caption: "Pipeline", headers: ["Account"], rows: [["Acme"]] }, + { + type: "buttons", + buttons: Array.from({ length: 26 }, (_entry, index) => ({ + label: `Action ${String(index + 1)}`, + value: `action-${String(index + 1)}`, + })), + }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart).toBeUndefined(); + expect(plan.fallbackText).toContain("- Action 26"); + }); + + it("moves standalone over-limit controls to complete text fallback", () => { + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { + type: "buttons", + buttons: Array.from({ length: 26 }, (_entry, index) => ({ + label: `Action ${String(index + 1)}`, + value: `action-${String(index + 1)}`, + })), + }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart).toBeUndefined(); + expect(plan.fallbackText).toContain("- Action 26"); + }); + + it("preserves safe command bytes and drops unsafe code-span fallbacks", () => { + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { + type: "buttons", + buttons: [ + { + label: "Deny", + action: { + type: "command", + command: "/approve req_1 deny & <@U123>", + }, + }, + { + label: "Backtick", + action: { type: "command", command: "/run `unsafe` " }, + }, + { + label: "Multiline", + action: { type: "command", command: "/run\n" }, + }, + { + label: "Overlong", + action: { type: "command", command: `/${"x".repeat(3_000)}` }, + }, + { + label: "Backslash", + action: { type: "command", command: "/path\\" }, + }, + { + label: "Line\nbreak", + action: { type: "command", command: "/status" }, + }, + ...Array.from({ length: 20 }, (_entry, index) => ({ + label: `Action ${String(index + 7)}`, + value: `action-${String(index + 7)}`, + })), + ], + }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.fallbackText).toContain("- Deny: `/approve req_1 deny & <@U123>`"); + expect(plan.fallbackText).not.toContain("req\\_1"); + expect(plan.fallbackText).toContain("- Backtick"); + expect(plan.fallbackText).toContain("- Multiline"); + expect(plan.fallbackText).toContain("- Overlong"); + expect(plan.fallbackText).toContain("- Backslash"); + expect(plan.fallbackText).toContain("- Line\nbreak"); + expect(plan.fallbackText).not.toContain("`unsafe`"); + expect(plan.fallbackText).not.toContain(""); + expect(plan.fallbackText).not.toContain("x".repeat(3_000)); + expect(plan.fallbackText).not.toContain("/path\\"); + expect(plan.fallbackText).not.toContain("`/status`"); + }); + + it("splits a valid native chart when its complete fallback exceeds 8k", () => { + const categories = Array.from({ length: 20 }, (_entry, index) => + `Category-${String(index)}`.padEnd(20, "x"), + ); + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { + type: "chart", + chartType: "bar", + title: "Large revenue report", + categories, + series: Array.from({ length: 12 }, (_entry, index) => ({ + name: `Series-${String(index)}`.padEnd(20, "x"), + values: categories.map(() => Number.MAX_VALUE), + })), + }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart).toBeUndefined(); + expect(plan.fallbackText).toContain("Series-11"); + expect(plan.fallbackText.length).toBeGreaterThan(8_000); + }); + + it("keeps the first two charts native and falls back only the overflow", () => { + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { + type: "chart", + chartType: "pie", + title: "Revenue mix", + segments: [{ label: "Product", value: 60 }], + }, + { + type: "chart", + chartType: "pie", + title: "Active accounts", + segments: [{ label: "Paid", value: 40 }], + }, + { + type: "chart", + chartType: "pie", + title: "Active sessions", + segments: [{ label: "Desktop", value: 30 }], + }, + ], + }, + }); + + expect(plan.mode).toBe("single"); + if (plan.mode !== "single") { + return; + } + expect(plan.blocks?.map((block) => block.type)).toEqual([ + "data_visualization", + "data_visualization", + "context", + ]); + expect(plan.blocks?.[2]).toMatchObject({ + type: "context", + elements: [{ text: expect.stringContaining("Active sessions") }], + }); + expect(plan.text).toContain("Revenue mix"); + expect(plan.text).toContain("Active accounts"); + expect(plan.text).toContain("Active sessions"); + }); + + it("counts raw charts before retaining portable charts", () => { + const plan = resolveSlackReplyRenderPlan({ + channelData: { + slack: { + blocks: [ + { + type: "data_visualization", + title: "Raw traffic", + chart: { + type: "pie", + segments: [{ label: "Direct", value: 50 }], + }, + }, + ], + }, + }, + presentation: { + blocks: [ + { + type: "chart", + chartType: "pie", + title: "Revenue mix", + segments: [{ label: "Product", value: 60 }], + }, + { + type: "chart", + chartType: "pie", + title: "Active sessions", + segments: [{ label: "Desktop", value: 30 }], + }, + ], + }, + }); + + expect(plan.mode).toBe("single"); + if (plan.mode !== "single") { + return; + } + expect(plan.blocks?.map((block) => block.type)).toEqual([ + "data_visualization", + "data_visualization", + "context", + ]); + expect(plan.blocks?.[2]).toMatchObject({ + type: "context", + elements: [{ text: expect.stringContaining("Active sessions") }], + }); + expect(plan.text).toContain("Raw traffic"); + expect(plan.text).toContain("Revenue mix"); + expect(plan.text).toContain("Active sessions"); + }); + + it("keeps a native-eligible table when its rendered fallback exceeds 8k", () => { + const rows = Array.from({ length: 100 }, (_entry, index) => [ + index === 0 ? "<@U123>" : `account-${String(index)} ${"x".repeat(65)}`, + ]); + const plan = resolveSlackReplyRenderPlan({ + text: "Pipeline summary", + presentation: { + title: "Quarterly report", + blocks: [ + { type: "context", text: "Confidential" }, + { type: "table", caption: "Pipeline", headers: ["Account"], rows }, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart?.blocks.map((block) => block.type)).toEqual(["data_table", "actions"]); + expect(plan.blockPart?.text).toBe("Pipeline (table)\n\n- Refresh"); + expect(plan.blockPart?.text.length).toBeLessThanOrEqual(8_000); + expect(plan.fallbackText).toContain("Quarterly report"); + expect(plan.fallbackText).toContain("Confidential"); + expect(plan.fallbackText).toContain("- Account: account-99"); + expect(plan.fallbackText.match(/Pipeline \(table\)/g)).toHaveLength(1); + expect(plan.fallbackText).not.toContain("- Refresh"); + }); + + it("compacts native table accessibility text at the active chunk limit", () => { + const header = "H".repeat(1_000); + const plan = resolveSlackReplyRenderPlan( + { + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: [header], + rows: Array.from({ length: 5 }, (_entry, index) => [String(index)]), + }, + ], + }, + }, + undefined, + { textLimit: 4_000 }, + ); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart?.text).toBe("Pipeline (table)"); + expect(plan.blockPart?.text.length).toBeLessThanOrEqual(4_000); + expect(plan.fallbackText).toContain(": 4"); + }); + + it("falls back a native-eligible table when its compact caption exceeds 8k", () => { + const caption = "c".repeat(8_100); + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { type: "table", caption, headers: ["Account"], rows: [["Acme"]] }, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart?.blocks.map((block) => block.type)).toEqual(["actions"]); + expect(plan.blockPart?.text).toBe("- Refresh"); + expect(plan.fallbackText).toContain(`${caption} (table)`); + expect(plan.fallbackText).toContain("- Account: Acme"); + }); + + it("escapes plain-text control labels in split accessibility fallbacks", () => { + const plan = resolveSlackReplyRenderPlan({ + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(110)}`, + ]), + }, + { type: "buttons", buttons: [{ label: "", value: "refresh" }] }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart?.text).toBe("- <!here>"); + expect(plan.blockPart?.text).not.toContain(""); + }); + + it("keeps section fields and rich text in split accessibility fallbacks", () => { + const plan = resolveSlackReplyRenderPlan({ + channelData: { + slack: { + blocks: [ + { + type: "section", + fields: [ + { type: "plain_text", text: "Owner " }, + { type: "mrkdwn", text: "*Ready*" }, + ], + }, + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "text", text: "Rich " }, + { type: "user", user_id: "U123" }, + ], + }, + ], + }, + { + type: "section", + text: { type: "mrkdwn", text: "Overview" }, + accessory: { + type: "button", + action_id: "open", + text: { type: "plain_text", text: "Open" }, + value: "open", + }, + }, + ], + }, + }, + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + `account-${String(index)}-${"x".repeat(110)}`, + ]), + }, + ], + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart?.text).toBe( + "Owner <!here>\n*Ready*\n\nRich <!channel> <@U123>\n\nOverview\n- Open", + ); + }); + + it("fails closed when retained raw-block accessibility exceeds 8k", () => { + const blocks = Array.from({ length: 3 }, (_entry, index) => ({ + type: "image", + image_url: `https://example.com/${String(index)}.png`, + alt_text: `${String(index)}${"x".repeat(2999)}`, + })); + + expect(() => + resolveSlackReplyRenderPlan({ + channelData: { slack: { blocks } }, + presentation: { + blocks: [ + { + type: "table", + caption: "Large pipeline", + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [ + `account-${String(index)} ${"x".repeat(110)}`, + ]), + }, + ], + }, + }), + ).toThrow(/retained-block accessibility fallback exceeds/i); + }); + + it("moves overlong raw text blocks to visible fallback chunks", () => { + const blocks = Array.from({ length: 3 }, (_entry, index) => ({ + type: "section", + text: { type: "mrkdwn", text: `${String(index)}${"x".repeat(2999)}-tail` }, + })); + + const plan = resolveSlackReplyRenderPlan({ channelData: { slack: { blocks } } }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart).toBeUndefined(); + expect(plan.fallbackText).toContain("2xxx"); + expect(plan.fallbackText).toContain("-tail"); + expect(plan.fallbackText.length).toBeGreaterThan(8_000); + }); + + it("retains image and mixed-media contexts when sibling text moves to fallback", () => { + const imageOnlyContext = { + type: "context", + elements: [ + { + type: "image", + image_url: "https://example.com/status.png", + alt_text: "Status", + }, + ], + }; + const mixedContext = { + type: "context", + elements: [ + { type: "mrkdwn", text: "Status summary" }, + { + type: "image", + image_url: "https://example.com/detail.png", + alt_text: "Detail", + }, + ], + }; + const textBlocks = Array.from({ length: 3 }, (_entry, index) => ({ + type: "section", + text: { type: "mrkdwn", text: `${String(index)}${"x".repeat(2994)}-tail` }, + })); + + const plan = resolveSlackReplyRenderPlan({ + channelData: { + slack: { blocks: [imageOnlyContext, mixedContext, ...textBlocks] }, + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart).toEqual({ + blocks: [imageOnlyContext, mixedContext], + text: "Status summary", + }); + expect(plan.fallbackText).toContain("2xxx"); + expect(plan.fallbackText).toContain("-tail"); + expect(plan.fallbackText).not.toContain("Status summary"); + }); + + it("converts authored Markdown before splitting around raw blocks", () => { + const plan = resolveSlackReplyRenderPlan({ + text: `[docs](https://example.com) ${"x".repeat(8_100)}`, + channelData: { + slack: { + blocks: [ + { + type: "actions", + elements: [ + { + type: "button", + action_id: "refresh", + text: { type: "plain_text", text: "Refresh" }, + value: "refresh", + }, + ], + }, + ], + }, + }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart?.text).toBe("- Refresh"); + expect(plan.fallbackText).toContain(""); + expect(plan.fallbackText).not.toContain("[docs](https://example.com)"); + }); + + it("keeps a long portable sibling complete beside a raw native table", () => { + const longText = `raw-table-sibling-${"x".repeat(3980)}-tail`; + const plan = resolveSlackReplyRenderPlan({ + channelData: { + slack: { + blocks: [ + { + type: "data_table", + caption: "Raw pipeline", + rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]], + }, + ], + }, + }, + presentation: { blocks: [{ type: "text", text: longText }] }, + }); + + expect(plan.mode).toBe("split"); + if (plan.mode !== "split") { + return; + } + expect(plan.blockPart).toBeUndefined(); + expect(plan.fallbackText).toContain("-tail"); + expect(plan.fallbackText).toContain("Raw pipeline (table)"); + expect(plan.hookText).toContain("-tail"); + }); +}); diff --git a/extensions/slack/src/reply-blocks.ts b/extensions/slack/src/reply-blocks.ts index 24c5b98922ce..18352652ccf6 100644 --- a/extensions/slack/src/reply-blocks.ts +++ b/extensions/slack/src/reply-blocks.ts @@ -1,44 +1,332 @@ import { normalizeMessagePresentation, - renderMessagePresentationFallbackText, + type MessagePresentation, + type MessagePresentationBlock, } from "openclaw/plugin-sdk/interactive-runtime"; // Slack plugin module implements reply blocks behavior. import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { + appendSlackBlocksAccessibleFallbackText, + buildSlackBlocksAccessibleFallbackText, + buildSlackBlocksCompactAccessibleFallbackText, + isSlackBlockRepresentedByTextFallback, + removeSlackBlocksFallbackParagraphs, + retainSlackDataTablesWithinCompactFallback, +} from "./blocks-fallback.js"; import { parseSlackBlocksInput, SLACK_MAX_BLOCKS } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks, + canRenderSlackPresentation, + canRenderSlackPresentationTables, resolveSlackBlockOffsets, type SlackBlock, } from "./blocks-render.js"; +import { hasSlackDataTableBlock } from "./data-table.js"; +import { markdownToSlackMrkdwnChunks } from "./format.js"; +import { SLACK_TEXT_LIMIT } from "./limits.js"; +import { + appendSlackNativeDataFallbackText, + hasSlackNativeDataBlock, +} from "./native-data-blocks.js"; +import { renderSlackMessagePresentationFallbackText } from "./presentation-fallback.js"; +import { SLACK_SECTION_TEXT_MAX } from "./presentation.js"; export function resolveSlackReplyText(payload: ReplyPayload, text = payload.text): string { const presentation = normalizeMessagePresentation(payload.presentation); - return presentation?.blocks.some((block) => block.type === "chart") - ? renderMessagePresentationFallbackText({ text, presentation }) + return presentation + ? renderSlackMessagePresentationFallbackText({ text, presentation }) : (text ?? ""); } -export function resolveSlackReplyBlocks(payload: ReplyPayload): SlackBlock[] | undefined { - const slackData = payload.channelData?.slack; - let channelBlocks: SlackBlock[] = []; - if (slackData && typeof slackData === "object" && !Array.isArray(slackData)) { - channelBlocks = - (parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as SlackBlock[]) ?? []; +function splitSlackPresentationForTextFallback( + presentation: MessagePresentation, + options: ReturnType, +): { + fallback: MessagePresentation; + native?: MessagePresentation; +} { + const nativeBlocks: MessagePresentationBlock[] = []; + const fallbackBlocks: MessagePresentationBlock[] = []; + for (const block of presentation.blocks) { + const isControl = block.type === "buttons" || block.type === "select"; + const isDivider = block.type === "divider"; + if ((isControl || isDivider) && canRenderSlackPresentation({ blocks: [block] }, options)) { + nativeBlocks.push(block); + } else { + fallbackBlocks.push(block); + } } - const presentationBlocks = buildSlackPresentationBlocks( - payload.presentation, - resolveSlackBlockOffsets(channelBlocks), + return { + fallback: { + ...(presentation.title ? { title: presentation.title } : {}), + blocks: fallbackBlocks, + }, + ...(nativeBlocks.length > 0 ? { native: { blocks: nativeBlocks } } : {}), + }; +} + +type SlackReplyBlockPart = { + blocks: SlackBlock[]; + text: string; +}; + +export type SlackReplyRenderPlan = + | { + mode: "single"; + textVisibleInBlocks?: true; + textIsSlackMrkdwn?: true; + blocks?: SlackBlock[]; + hookText: string; + text: string; + } + | { + mode: "split"; + blockPart?: SlackReplyBlockPart; + fallbackText: string; + hookText: string; + }; + +function resolveSlackChannelBlocks(payload: ReplyPayload): SlackBlock[] { + const slackData = payload.channelData?.slack; + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return []; + } + return (parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as SlackBlock[]) ?? []; +} + +function buildSlackReplyBlockPart( + blocks: SlackBlock[], + compactLimit = SLACK_TEXT_LIMIT, +): SlackReplyBlockPart | undefined { + if (blocks.length === 0) { + return undefined; + } + let text = buildSlackBlocksAccessibleFallbackText(blocks); + if (text.length > compactLimit && hasSlackDataTableBlock(blocks)) { + // The complete row fallback belongs to the following text chunks. This + // post still names the table and every retained control/media sibling. + text = buildSlackBlocksCompactAccessibleFallbackText(blocks); + } + if (text.length > SLACK_TEXT_LIMIT) { + throw new Error( + `Slack retained-block accessibility fallback exceeds OpenClaw's ${String(SLACK_TEXT_LIMIT)}-character limit`, + ); + } + return { blocks, text }; +} + +function buildSlackAuthoredTextBlocks(text: string | null | undefined): SlackBlock[] { + const trimmed = text?.trim(); + if (!trimmed) { + return []; + } + return markdownToSlackMrkdwnChunks(trimmed, SLACK_SECTION_TEXT_MAX).map((chunk) => ({ + type: "section", + text: { type: "mrkdwn", text: chunk, verbatim: true }, + })); +} + +function isSlackAuthoredTextRepresentedInInteractive( + text: string | null | undefined, + interactive: ReplyPayload["interactive"], +): boolean { + const target = text?.trim().replace(/\s+/gu, " "); + if (!target) { + return false; + } + const fragments = + interactive?.blocks.flatMap((block) => + block.type === "text" && block.text.trim().length <= SLACK_SECTION_TEXT_MAX + ? [block.text.trim().replace(/\s+/gu, " ")] + : [], + ) ?? []; + for (let start = 0; start < fragments.length; start += 1) { + let combined = ""; + for (let end = start; end < fragments.length; end += 1) { + combined = `${combined} ${fragments[end]}`.trim().replace(/\s+/gu, " "); + if (combined === target) { + return true; + } + if (combined.length > target.length) { + break; + } + } + } + return false; +} + +function renderSlackVisiblePresentationFallback(params: { + presentation?: MessagePresentation; + text?: string | null; +}): string { + const authoredText = params.text?.trim() + ? markdownToSlackMrkdwnChunks(params.text.trim(), SLACK_TEXT_LIMIT).join("\n") + : undefined; + return renderSlackMessagePresentationFallbackText({ + ...(authoredText ? { text: authoredText } : {}), + ...(params.presentation ? { presentation: params.presentation } : {}), + }); +} + +export function resolveSlackReplyRenderPlan( + payload: ReplyPayload, + text = payload.text, + options: { includeAuthoredTextBlock?: boolean; textLimit?: number } = {}, +): SlackReplyRenderPlan { + const textLimit = options.textLimit ?? SLACK_TEXT_LIMIT; + const channelBlocks = resolveSlackChannelBlocks(payload); + const presentation = normalizeMessagePresentation(payload.presentation); + const presentationOffsets = resolveSlackBlockOffsets(channelBlocks); + const hasPresentationTable = presentation?.blocks.some((block) => block.type === "table"); + let usesPresentationTextFallback = Boolean( + presentation && + (!canRenderSlackPresentation(presentation, presentationOffsets) || + (hasPresentationTable && + !canRenderSlackPresentationTables(presentation, presentationOffsets))), ); - const interactiveBlocks = buildSlackInteractiveBlocks( + const splitPresentation = + presentation && usesPresentationTextFallback + ? splitSlackPresentationForTextFallback(presentation, presentationOffsets) + : undefined; + let fallbackPresentation = splitPresentation?.fallback; + const presentationForBlocks = splitPresentation ? splitPresentation.native : presentation; + const authoredTextRepresentedInInteractive = isSlackAuthoredTextRepresentedInInteractive( + text, payload.interactive, - resolveSlackBlockOffsets([...channelBlocks, ...presentationBlocks]), ); - const blocks = [...channelBlocks, ...presentationBlocks, ...interactiveBlocks]; + const authoredTextRepresentedInChannelBlocks = Boolean( + text?.trim() && !removeSlackBlocksFallbackParagraphs(text, channelBlocks), + ); + const authoredTextRepresentedInBlocks = + authoredTextRepresentedInInteractive || authoredTextRepresentedInChannelBlocks; + let authoredTextBlocks = + !usesPresentationTextFallback && + options.includeAuthoredTextBlock !== false && + (presentation || channelBlocks.length > 0) && + !authoredTextRepresentedInBlocks + ? buildSlackAuthoredTextBlocks(text) + : []; + const presentationBlocks = buildSlackPresentationBlocks( + presentationForBlocks, + presentationOffsets, + ); + let interactiveBlocks = buildSlackInteractiveBlocks( + payload.interactive, + resolveSlackBlockOffsets([...channelBlocks, ...authoredTextBlocks, ...presentationBlocks]), + ); + let blocks = [ + ...channelBlocks, + ...authoredTextBlocks, + ...presentationBlocks, + ...interactiveBlocks, + ]; + if (blocks.length > SLACK_MAX_BLOCKS && presentation) { + // Portable presentation can degrade completely to text. Raw channel blocks + // and separately-authored interactive controls remain fail-closed. + authoredTextBlocks = []; + interactiveBlocks = buildSlackInteractiveBlocks( + payload.interactive, + resolveSlackBlockOffsets(channelBlocks), + ); + blocks = [...channelBlocks, ...interactiveBlocks]; + usesPresentationTextFallback = true; + fallbackPresentation = presentation; + } if (blocks.length > SLACK_MAX_BLOCKS) { throw new Error( `Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`, ); } - return blocks.length > 0 ? blocks : undefined; + const hookText = appendSlackBlocksAccessibleFallbackText(resolveSlackReplyText(payload, text), [ + ...channelBlocks, + ...interactiveBlocks, + ]); + if (usesPresentationTextFallback) { + let fallbackText = appendSlackNativeDataFallbackText( + renderSlackVisiblePresentationFallback({ + text, + ...(fallbackPresentation ? { presentation: fallbackPresentation } : {}), + }), + blocks, + ); + let retainedBlocks = blocks.filter((block) => !hasSlackNativeDataBlock([block])); + if ( + retainedBlocks.length > 0 && + buildSlackBlocksAccessibleFallbackText(retainedBlocks).length > SLACK_TEXT_LIMIT + ) { + const fallbackBlocks = retainedBlocks.filter(isSlackBlockRepresentedByTextFallback); + fallbackText = appendSlackBlocksAccessibleFallbackText(fallbackText, fallbackBlocks); + retainedBlocks = retainedBlocks.filter( + (block) => !isSlackBlockRepresentedByTextFallback(block), + ); + } + const blockPart = buildSlackReplyBlockPart(retainedBlocks, textLimit); + if (!fallbackText.trim()) { + return { + mode: "single", + ...(blockPart ? { blocks: blockPart.blocks } : {}), + hookText, + text: blockPart?.text ?? hookText, + }; + } + return { + mode: "split", + ...(blockPart ? { blockPart } : {}), + fallbackText, + hookText, + }; + } + + const textIsSlackMrkdwn = + presentation?.blocks.some((block) => block.type === "chart" || block.type === "table") || + hasSlackNativeDataBlock(blocks); + const singleText = textIsSlackMrkdwn + ? appendSlackBlocksAccessibleFallbackText( + renderSlackVisiblePresentationFallback({ presentation, text }), + blocks, + ) + : hookText; + if (blocks.length > 0 && singleText.length > textLimit) { + // A native-eligible table should remain native even when its expanded row + // fallback requires multiple messages. Other text-replaceable blocks move + // completely into those chunks. + const siblingCandidates = blocks.filter( + (block) => hasSlackDataTableBlock([block]) || !isSlackBlockRepresentedByTextFallback(block), + ); + const siblingBlocks = retainSlackDataTablesWithinCompactFallback(siblingCandidates, textLimit); + const splitText = textIsSlackMrkdwn + ? singleText + : appendSlackBlocksAccessibleFallbackText( + renderSlackVisiblePresentationFallback({ presentation, text }), + blocks, + ); + const fallbackText = removeSlackBlocksFallbackParagraphs( + splitText, + siblingBlocks.filter((block) => !hasSlackDataTableBlock([block])), + ); + const blockPart = buildSlackReplyBlockPart(siblingBlocks, textLimit); + return { + mode: "split", + ...(blockPart ? { blockPart } : {}), + fallbackText, + hookText, + }; + } + + return { + mode: "single", + ...(authoredTextBlocks.length > 0 || authoredTextRepresentedInBlocks + ? { textVisibleInBlocks: true as const } + : {}), + ...(blocks.length > 0 ? { blocks } : {}), + ...(textIsSlackMrkdwn ? { textIsSlackMrkdwn: true as const } : {}), + hookText, + text: singleText, + }; +} + +export function resolveSlackReplyBlocks(payload: ReplyPayload): SlackBlock[] | undefined { + const plan = resolveSlackReplyRenderPlan(payload); + return plan.mode === "single" ? plan.blocks : plan.blockPart?.blocks; } diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 0e6f2b9c3d26..8e1f098dc2de 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -33,6 +33,17 @@ function postedMessage(client: ReturnType, cal return mockObjectArg(client.chat.postMessage, "chat.postMessage", callIndex); } +function postedText(message: Record): string { + const text = message.text; + if (text == null) { + return ""; + } + if (typeof text !== "string") { + throw new Error("Expected chat.postMessage text to be a string"); + } + return text; +} + function slackDnsRequestError(): Error { return Object.assign(new Error("A request error occurred: getaddrinfo EAI_AGAIN slack.com"), { code: "slack_webapi_request_error", @@ -203,6 +214,164 @@ describe("sendMessageSlack chunking", () => { expect(postedTexts.join("")).toBe(message); }); + it("keeps normal word boundaries for raw mrkdwn without protected tokens", async () => { + const client = createSlackSendTestClient(); + + await sendMessageSlack("channel:C123", "alpha beta", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 8, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts).toEqual(["alpha", "beta"]); + }); + + it("keeps native Slack angle tokens atomic across raw mrkdwn chunks", async () => { + const client = createSlackSendTestClient(); + + await sendMessageSlack("channel:C123", "abc <@U123>", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 8, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts).toEqual(["abc ", "<@U123>"]); + expect(postedTexts.join("")).toBe("abc <@U123>"); + }); + + it("keeps oversized Slack angle tokens inert inside chunked code", async () => { + const client = createSlackSendTestClient(); + const message = "`<@U123>`x"; + + await sendMessageSlack("channel:C123", message, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 8, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts.length).toBeGreaterThan(1); + expect(postedTexts.every((text) => text.length <= 8)).toBe(true); + expect(postedTexts.every((text) => (text.match(/`/gu)?.length ?? 0) % 2 === 0)).toBe(true); + expect(postedTexts.every((text) => !text.includes("<@U123>"))).toBe(true); + expect(postedTexts.join("").replaceAll("`", "")).toBe(message.replaceAll("`", "")); + }); + + it("preserves spaces while fragmenting an oversized Slack link inside code", async () => { + const client = createSlackSendTestClient(); + const message = "``z"; + + await sendMessageSlack("channel:C123", message, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 10, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts.every((text) => text.length <= 10)).toBe(true); + expect(postedTexts.every((text) => (text.match(/`/gu)?.length ?? 0) % 2 === 0)).toBe(true); + expect(postedTexts.every((text) => !text.includes(""))).toBe(true); + expect(postedTexts.join("").replaceAll("`", "")).toBe(message.replaceAll("`", "")); + }); + + it("preserves short unmatched mrkdwn backticks byte-for-byte", async () => { + const client = createSlackSendTestClient(); + const message = "literal ` marker"; + + await sendMessageSlack("channel:C123", message, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 100, + }); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + expect(postedMessage(client).text).toBe(message); + }); + + it("preserves code spans and entities across small mrkdwn chunks", async () => { + const client = createSlackSendTestClient(); + const message = "- Deny: `/approve req_1 deny & <@U123>`"; + + await sendMessageSlack("channel:C123", message, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 5, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts.length).toBeGreaterThan(1); + expect(postedTexts.every((text) => text.length <= 5)).toBe(true); + expect(postedTexts.every((text) => (text.match(/`/gu)?.length ?? 0) % 2 === 0)).toBe(true); + expect(postedTexts.every((text) => !/&(?:a|am|l|g|gt|lt)?$/u.test(text))).toBe(true); + expect(postedTexts.every((text) => !/^(?:amp;|lt;|gt;)/u.test(text))).toBe(true); + expect(postedTexts.join("").replaceAll("`", "")).toBe(message.replaceAll("`", "")); + expect(postedTexts.join("")).not.toContain("<@U123>"); + }); + + it("preserves fenced code markers across raw mrkdwn chunks", async () => { + const client = createSlackSendTestClient(); + const message = "```sql\nselect \\`name\\` from users;\n```"; + + await sendMessageSlack("channel:C123", message, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 12, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts.length).toBeGreaterThan(1); + expect(postedTexts.every((text) => text.length <= 12)).toBe(true); + expect(postedTexts.every((text) => (text.match(/```/gu)?.length ?? 0) % 2 === 0)).toBe(true); + expect(postedTexts.join("").replaceAll("```", "")).toBe(message.replaceAll("```", "")); + }); + + it("balances fenced code at the smallest wrapped chunk limit", async () => { + const client = createSlackSendTestClient(); + + await sendMessageSlack("channel:C123", "```abcd```", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 7, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts).toEqual(["```a```", "```b```", "```c```", "```d```"]); + }); + + it("degrades fenced code to bounded plain text when wrappers cannot fit", async () => { + const client = createSlackSendTestClient(); + + await sendMessageSlack("channel:C123", "```abcdefg```", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + textIsSlackMrkdwn: true, + textLimit: 6, + }); + + const postedTexts = client.chat.postMessage.mock.calls.map((call) => String(call[0].text)); + expect(postedTexts).toEqual(["abcdef", "g"]); + expect(postedTexts.every((text) => !text.includes("```"))).toBe(true); + }); + it("reports the first Slack chunk before a later chunk fails", async () => { const client = createSlackSendTestClient(); client.chat.postMessage @@ -283,7 +452,7 @@ describe("sendMessageSlack blocks", () => { expect((receiptPart?.raw as Record | undefined)?.channelId).toBe("C123"); }); - it("retries rejected native charts as accessible text", async () => { + it("retries rejected native charts as visible accessible fallback blocks", async () => { const client = createSlackSendTestClient(); client.chat.postMessage.mockRejectedValueOnce( Object.assign(new Error("An API error occurred: invalid_blocks"), { @@ -316,13 +485,592 @@ describe("sendMessageSlack blocks", () => { expect(postedMessage(client, 0).text).toBe( "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", ); - expect(postedMessage(client, 1).blocks).toBeUndefined(); + expect(postedMessage(client, 1).blocks).toEqual([ + { + type: "section", + text: { + type: "mrkdwn", + text: "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", + verbatim: true, + }, + }, + ]); expect(postedMessage(client, 1).text).toBe( "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", ); }); - it("does not retry invalid non-chart blocks", async () => { + it("chunks overlong chart fallbacks instead of truncating screen-reader text", async () => { + const client = createSlackSendTestClient(); + const categories = Array.from({ length: 20 }, (_point, pointIndex) => + `Category-${String(pointIndex)}`.padEnd(20, "x"), + ); + const blocks = [ + { + type: "data_visualization", + title: "Large revenue report", + chart: { + type: "bar", + series: Array.from({ length: 12 }, (_series, seriesIndex) => ({ + name: `Series-${String(seriesIndex)}`.padEnd(20, "x"), + data: categories.map((label) => ({ + label, + value: Number.MAX_VALUE, + })), + })), + axis_config: { categories }, + }, + }, + ]; + + await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts.length).toBeGreaterThan(1); + expect(posts.every((post) => post.blocks === undefined)).toBe(true); + expect( + posts.every((post) => Array.from(postedText(post)).length <= SLACK_TEXT_LIMIT), + ).toBe(true); + expect(posts.map(postedText).join("\n")).toContain("Series-11"); + }); + + it("chunks overlong chart fallback sent separately from authored text", async () => { + const client = createSlackSendTestClient(); + const categories = Array.from({ length: 20 }, (_point, pointIndex) => + `Category-${String(pointIndex)}`.padEnd(20, "x"), + ); + const blocks = [ + { + type: "data_visualization", + title: "Large revenue report", + chart: { + type: "bar", + series: Array.from({ length: 12 }, (_series, seriesIndex) => ({ + name: `Series-${String(seriesIndex)}`.padEnd(20, "x"), + data: categories.map((label) => ({ label, value: Number.MAX_VALUE })), + })), + axis_config: { categories }, + }, + }, + ]; + + await sendMessageSlack("channel:C123", "**Summary**", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + separateTextAndBlocks: true, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts.length).toBeGreaterThan(2); + expect(posts[0]).toMatchObject({ + text: "Large revenue report (bar chart)", + blocks, + }); + expect(posts.slice(1).every((post) => post.blocks === undefined)).toBe(true); + expect( + posts.every((post) => Array.from(postedText(post)).length <= SLACK_TEXT_LIMIT), + ).toBe(true); + const fallbackText = posts.slice(1).map(postedText).join("\n"); + expect(fallbackText).toContain("*Summary*"); + expect(fallbackText.match(/Series-11/g)).toHaveLength(1); + }); + + it("retries rejected native tables once with complete accessible text", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Overview" } }, + { + type: "data_table", + caption: "Pipeline report", + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "<@U123>" }, + { type: "raw_number", value: 125000, text: "$125k" }, + ], + [ + { type: "raw_text", text: "Globex" }, + { type: "raw_number", value: 82000, text: "$82k" }, + ], + ], + row_header_column_index: 0, + }, + ] as never; + const fallback = [ + "Overview", + "", + "Pipeline report (table)", + "- Account: <@U123>; ARR: $125k", + "- Account: Globex; ARR: $82k", + ].join("\n"); + + await sendMessageSlack("channel:C123", "Overview", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + }); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(2); + expect(postedMessage(client, 0).blocks).toEqual(blocks); + expect(postedMessage(client, 0).text).toBe(fallback); + expect(postedMessage(client, 1).blocks).toEqual([ + blocks[0], + { + type: "section", + text: { + type: "mrkdwn", + text: [ + "Pipeline report (table)", + "- Account: <@U123>; ARR: $125k", + "- Account: Globex; ARR: $82k", + ].join("\n"), + verbatim: true, + }, + }, + ]); + expect(postedMessage(client, 1).text).toBe(fallback); + }); + + it("marks data-derived fallback mrkdwn verbatim", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + + await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks: [ + { + type: "data_table", + caption: "Alerts", + rows: [[{ type: "raw_text", text: "Owner" }], [{ type: "raw_text", text: "@here" }]], + }, + ] as never, + }); + + expect(postedMessage(client, 1).blocks).toEqual([ + { + type: "section", + text: { + type: "mrkdwn", + text: "Alerts (table)\n- Owner: @here", + verbatim: true, + }, + }, + ]); + }); + + it("chunks overlong table fallbacks while preserving the native table and controls", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const header = "Account".padEnd(80, "x"); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Overview" } }, + { + type: "data_table", + caption: "Large pipeline", + rows: [ + [{ type: "raw_text", text: header }], + ...Array.from({ length: 100 }, (_entry, index) => [ + { + type: "raw_text", + text: index === 0 ? "<@U123>" : `account-${String(index)}`, + }, + ]), + ], + }, + { + type: "data_visualization", + title: "Revenue mix", + chart: { + type: "pie", + segments: [ + { label: "Product", value: 60 }, + { label: "Services", value: 40 }, + ], + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Refresh" }, + action_id: "refresh", + value: "refresh", + }, + ], + }, + ] as never; + + const result = await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + threadTs: "171234.100", + replyBroadcast: true, + }); + + expect(client.chat.postMessage.mock.calls.length).toBeGreaterThan(2); + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts[0]?.blocks).toEqual([blocks[1], blocks[3]]); + expect(posts[0]?.text).toBe("Large pipeline (table)\n\n- Refresh"); + expect(posts[1]).toMatchObject({ text: "- Refresh", blocks: [blocks[3]] }); + expect(posts.slice(2).every((post) => post.blocks === undefined)).toBe(true); + expect( + posts.every((post) => Array.from(postedText(post)).length <= SLACK_TEXT_LIMIT), + ).toBe(true); + expect(posts[0]?.reply_broadcast).toBeUndefined(); + expect(posts[2]?.reply_broadcast).toBe(true); + expect(posts.every((post) => post.thread_ts === "171234.100")).toBe(true); + expect(result.receipt.parts[0]?.kind).toBe("card"); + expect(result.receipt.parts.slice(1).every((part) => part.kind === "text")).toBe(true); + expect(result.receipt.parts.map((part) => part.index)).toEqual( + Array.from({ length: result.receipt.parts.length }, (_entry, index) => index), + ); + const fallbackText = posts + .slice(2) + .map((post) => post.text) + .join("\n"); + expect(fallbackText).toContain(`- ${header}: <@U123>`); + expect(fallbackText).toContain(`- ${header}: account-99`); + expect(fallbackText).toContain("Revenue mix (pie chart)"); + expect(fallbackText.match(/Overview/g)).toHaveLength(1); + expect(fallbackText.match(/Large pipeline \(table\)/g)).toHaveLength(1); + expect(fallbackText).not.toContain("<@U123>"); + }); + + it("renders rejected native table rows when separate text is unrelated", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const blocks = [ + { + type: "data_table", + caption: "Pipeline", + rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]], + }, + ] as never; + + await sendMessageSlack("channel:C123", "Unrelated follow-up", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + separateTextAndBlocks: true, + textIsSlackMrkdwn: true, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts).toHaveLength(3); + expect(posts[0]?.blocks).toEqual(blocks); + expect(posts[1]).toMatchObject({ + text: "Pipeline (table)\n- Account: Acme", + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "Pipeline (table)\n- Account: Acme", + verbatim: true, + }, + }, + ], + }); + expect(posts[2]).toMatchObject({ text: "Unrelated follow-up" }); + expect(posts[2]?.blocks).toBeUndefined(); + expect( + posts.slice(1).filter((post) => JSON.stringify(post).includes("- Account: Acme")), + ).toHaveLength(1); + }); + + it("chunks overlong separate table fallbacks before native rejection retry", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const authoredMarkdown = "**Important** [Docs](https://example.com)"; + const header = "Account".padEnd(80, "x"); + const blocks = [ + { + type: "data_table", + caption: "Pipeline", + rows: [ + [{ type: "raw_text", text: header }], + ...Array.from({ length: 100 }, (_entry, index) => [ + { type: "raw_text", text: `account-${String(index)}` }, + ]), + ], + }, + { + type: "data_visualization", + title: "Revenue", + chart: { + type: "pie", + segments: [ + { label: "A", value: 1 }, + { label: "B", value: 2 }, + ], + }, + }, + ] as never; + + await sendMessageSlack("channel:C123", authoredMarkdown, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + separateTextAndBlocks: true, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts.length).toBeGreaterThan(3); + const compactFallback = "Pipeline (table)\n\nRevenue (pie chart)"; + expect(posts[0]).toMatchObject({ text: compactFallback, blocks }); + expect(posts[1]).toMatchObject({ text: compactFallback }); + expect(posts[1]?.blocks).toBeUndefined(); + expect(posts.slice(2).every((post) => post.blocks === undefined)).toBe(true); + expect( + posts.every((post) => Array.from(postedText(post)).length <= SLACK_TEXT_LIMIT), + ).toBe(true); + const fallbackText = posts + .slice(2) + .map((post) => post.text) + .join("\n"); + expect(fallbackText).toContain("*Important* "); + expect(fallbackText).not.toContain("**Important**"); + expect(fallbackText).toContain(`- ${header}: account-99`); + expect(fallbackText.match(/Pipeline \(table\)/g)).toHaveLength(1); + const deliveredText = posts + .slice(1) + .map((post) => post.text) + .join("\n"); + expect(deliveredText.match(/- A: 1/g)).toHaveLength(1); + expect(deliveredText.match(/- B: 2/g)).toHaveLength(1); + }); + + it("chunks a long-caption table fallback instead of throwing", async () => { + const client = createSlackSendTestClient(); + const caption = "c".repeat(8_100); + const blocks = [ + { + type: "data_table", + caption, + rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Refresh" }, + action_id: "refresh", + value: "refresh", + }, + ], + }, + ] as never; + + await sendMessageSlack("channel:C123", "Summary", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + textIsSlackMrkdwn: true, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts[0]?.blocks).toEqual([blocks[1]]); + expect(posts.slice(1).every((post) => post.blocks === undefined)).toBe(true); + const fallbackText = posts + .slice(1) + .map((post) => post.text) + .join(""); + expect(fallbackText).toContain(`${caption} (table)`); + expect(fallbackText).toContain("- Account: Acme"); + }); + + it("drops an overlong separate table while compacting retained native siblings", async () => { + const client = createSlackSendTestClient(); + const caption = "c".repeat(8_100); + const blocks = [ + { + type: "data_table", + caption, + rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]], + }, + { + type: "data_visualization", + title: "Revenue", + chart: { + type: "pie", + segments: [ + { label: "A", value: 1 }, + { label: "B", value: 2 }, + ], + }, + }, + ] as never; + + await sendMessageSlack("channel:C123", "Summary", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + separateTextAndBlocks: true, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts.length).toBeGreaterThan(1); + expect(posts[0]).toMatchObject({ text: "Revenue (pie chart)", blocks: [blocks[1]] }); + expect(posts.slice(1).every((post) => post.blocks === undefined)).toBe(true); + expect( + posts.every((post) => Array.from(postedText(post)).length <= SLACK_TEXT_LIMIT), + ).toBe(true); + const fallbackText = posts + .slice(1) + .map((post) => post.text) + .join(""); + expect(fallbackText).toContain("Summary"); + expect(fallbackText).toContain(`${caption} (table)`); + expect(fallbackText).toContain("- Account: Acme"); + expect(fallbackText.match(/- A: 1/g)).toHaveLength(1); + expect(fallbackText.match(/- B: 2/g)).toHaveLength(1); + }); + + it("uses the account text limit when deciding whether to retain a native table", async () => { + const client = createSlackSendTestClient(); + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + textChunkLimit: 100, + accounts: { default: { textChunkLimit: 20 } }, + }, + }, + }; + const blocks = [ + { + type: "data_table", + caption: "Quarterly pipeline", + rows: [[{ type: "raw_text", text: "Account" }], [{ type: "raw_text", text: "Acme" }]], + }, + ] as never; + + await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + cfg, + client, + blocks, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts.every((post) => post.blocks === undefined)).toBe(true); + expect(posts.every((post) => postedText(post).length <= 20)).toBe(true); + expect( + posts + .map((post) => post.text) + .join("") + .replace(/\s+/gu, ""), + ).toContain("Quarterlypipeline(table)-Account:Acme"); + }); + + it("uses an explicit text limit for separate native fallback and rejection retry", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const blocks = [ + { + type: "data_table", + caption: "Pipeline", + rows: [ + [{ type: "raw_text", text: "Account" }], + ...Array.from({ length: 10 }, (_entry, index) => [ + { type: "raw_text", text: `account-${String(index)}` }, + ]), + ], + }, + ] as never; + + await sendMessageSlack("channel:C123", "Summary", { + token: "xoxb-test", + cfg: { channels: { slack: { botToken: "xoxb-test", textChunkLimit: 100 } } }, + client, + blocks, + separateTextAndBlocks: true, + textLimit: 40, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts[0]).toMatchObject({ text: "Pipeline (table)", blocks }); + expect(posts[1]).toMatchObject({ text: "Pipeline (table)" }); + expect(posts[1]?.blocks).toBeUndefined(); + expect(posts.every((post) => postedText(post).length <= 40)).toBe(true); + expect( + posts + .slice(1) + .map((post) => post.text) + .join("\n") + .match(/- Account: account-9/g), + ).toHaveLength(1); + }); + + it("does not repeat retained block text in long separate text sends", async () => { + const client = createSlackSendTestClient(); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Visible summary" } }, + ] as const; + const authoredText = `start-${"x".repeat(8_100)}-tail`; + + await sendMessageSlack("channel:C123", authoredText, { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks: [...blocks], + separateTextAndBlocks: true, + textIsSlackMrkdwn: true, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts[0]?.blocks).toEqual(blocks); + expect(posts[0]?.text).toBe("Visible summary"); + expect(posts.slice(1).every((post) => post.blocks === undefined)).toBe(true); + const chunkedText = posts.slice(1).map(postedText).join("\n"); + expect(chunkedText).toContain("start-"); + expect(chunkedText).toContain("-tail"); + expect(chunkedText).not.toContain("Visible summary"); + }); + + it("does not retry invalid non-data blocks", async () => { const client = createSlackSendTestClient(); client.chat.postMessage.mockRejectedValueOnce( Object.assign(new Error("An API error occurred: invalid_blocks"), { @@ -372,7 +1120,58 @@ describe("sendMessageSlack blocks", () => { expect(postedMessage(client, 0).blocks).toEqual(blocks); }); - it("drops every block and preserves chart data when mixed blocks are rejected", async () => { + it("includes every raw block and control in successful accessibility text", async () => { + const client = createSlackSendTestClient(); + const blocks = [ + { type: "section", text: { type: "mrkdwn", text: "Details" } }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "approve", + text: { type: "plain_text", text: "Approve" }, + value: "approve", + }, + ], + }, + ]; + + await sendMessageSlack("channel:C123", "Summary", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + }); + + expect(postedMessage(client, 0).text).toBe("Summary\n\nDetails\n\n- Approve"); + expect(postedMessage(client, 0).blocks).toEqual(blocks); + }); + + it("chunks overlong raw text blocks without truncating accessibility text", async () => { + const client = createSlackSendTestClient(); + const blocks = Array.from({ length: 3 }, (_entry, index) => ({ + type: "section", + text: { type: "mrkdwn", text: `${String(index)}${"x".repeat(2999)}-tail` }, + })); + + await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks, + }); + + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts.length).toBeGreaterThan(1); + expect(posts.every((post) => post.blocks === undefined)).toBe(true); + expect(posts.map(postedText).join("\n")).toContain("2xxx"); + expect(posts.map(postedText).join("\n")).toContain("-tail"); + }); + + it("replaces rejected native charts while preserving sibling blocks", async () => { const client = createSlackSendTestClient(); client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); const blocks = [ @@ -399,12 +1198,92 @@ describe("sendMessageSlack blocks", () => { expect(client.chat.postMessage).toHaveBeenCalledTimes(2); expect(postedMessage(client, 0).blocks).toEqual(blocks); - expect(postedMessage(client, 1).blocks).toBeUndefined(); + expect(postedMessage(client, 1).blocks).toEqual([ + blocks[0], + { + type: "section", + text: { + type: "mrkdwn", + text: "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", + verbatim: true, + }, + }, + ]); expect(postedMessage(client, 1).text).toBe( "Overview\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40", ); }); + it("propagates invalid_blocks when a retained sibling is also invalid", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValue({ data: { error: "invalid_blocks" } }); + + await expect( + sendMessageSlack("channel:C123", "Overview", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: "Invalid sibling" } }, + { + type: "data_visualization", + title: "Revenue mix", + chart: { + type: "pie", + segments: [{ label: "Product", value: 60 }], + }, + }, + ] as never, + }), + ).rejects.toMatchObject({ data: { error: "invalid_blocks" } }); + + expect(client.chat.postMessage).toHaveBeenCalledTimes(2); + expect(postedMessage(client, 1).blocks).toEqual([ + { type: "section", text: { type: "mrkdwn", text: "Invalid sibling" } }, + { + type: "section", + text: { + type: "mrkdwn", + text: "Revenue mix (pie chart)\n- Product: 60", + verbatim: true, + }, + }, + ]); + }); + + it("fails closed when native fallback expansion would drop 49 siblings", async () => { + const client = createSlackSendTestClient(); + client.chat.postMessage.mockRejectedValueOnce({ data: { error: "invalid_blocks" } }); + const categories = Array.from( + { length: 20 }, + (_entry, index) => `category-${String(index)}-${"x".repeat(80)}`, + ); + + await expect( + sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + cfg: SLACK_TEST_CFG, + client, + blocks: [ + ...Array.from({ length: 49 }, () => ({ type: "divider" })), + { + type: "data_visualization", + title: "Large chart", + chart: { + type: "bar", + axis_config: { categories }, + series: Array.from({ length: 2 }, (_entry, index) => ({ + name: `Series ${String(index)}`, + data: categories.map((label) => ({ label, value: index })), + })), + }, + }, + ] as never, + }), + ).rejects.toThrow(/fallback requires .* blocks to retain every sibling/i); + expect(client.chat.postMessage).toHaveBeenCalledOnce(); + }); + it("uses canonical Slack response thread for block receipts and participation", async () => { clearSlackThreadParticipationCache(); const client = createSlackSendTestClient(); @@ -589,7 +1468,7 @@ describe("sendMessageSlack blocks", () => { expect(postedMessage(client).text).toBe("Shared a file"); }); - it("caps long fallback text while preserving blocks", async () => { + it("chunks long block fallback text without truncation", async () => { const client = createSlackSendTestClient(); const longContextText = "a".repeat(3000); const blocks = [ @@ -610,10 +1489,16 @@ describe("sendMessageSlack blocks", () => { blocks, }); - const post = postedMessage(client); - expect(String(post.text).endsWith("…")).toBe(true); - expect(post.blocks).toBe(blocks); - expect(post.text).toHaveLength(SLACK_TEXT_LIMIT); + const posts = client.chat.postMessage.mock.calls.map((_call, index) => + postedMessage(client, index), + ); + expect(posts.length).toBeGreaterThan(1); + expect(posts.every((post) => post.blocks === undefined)).toBe(true); + expect(posts.every((post) => Array.from(String(post.text)).length <= SLACK_TEXT_LIMIT)).toBe( + true, + ); + expect(posts.map((post) => String(post.text)).join("")).toContain(longContextText); + expect(posts.map((post) => String(post.text)).join("")).not.toContain("…"); }); it("rejects blocks combined with mediaUrl", async () => { diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 948861cad6f7..3cb0b855eb06 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -28,7 +28,15 @@ import { } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount, resolveSlackOperationToken } from "./accounts.js"; -import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; +import { + appendSlackBlocksAccessibleFallbackText, + buildSlackBlocksAccessibleFallbackText, + buildSlackBlocksCompactAccessibleFallbackText, + buildSlackDeferredNativeDataRejectionFallback, + isSlackBlockRepresentedByTextFallback, + removeSlackBlocksFallbackParagraphs, + retainSlackDataTablesWithinCompactFallback, +} from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; import { postSlackMessageBestEffort, @@ -36,10 +44,19 @@ import { withSlackDnsRequestRetry, } from "./client-delivery.js"; import { createSlackTokenCacheKey, createSlackWebClient, getSlackWriteClient } from "./client.js"; -import { appendSlackDataVisualizationFallbackText } from "./data-visualization.js"; +import { hasSlackDataTableBlock } from "./data-table.js"; import { assertSlackDirectSendAllowed } from "./direct-send-admission.js"; -import { markdownToSlackMrkdwnChunks } from "./format.js"; +import { + chunkSlackMrkdwnText, + markdownToSlackMrkdwn, + markdownToSlackMrkdwnChunks, +} from "./format.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; +import { + appendSlackNativeDataFallbackText, + hasCompleteSlackNativeDataFallbackText, + hasSlackNativeDataBlock, +} from "./native-data-blocks.js"; import { recordSlackThreadParticipation } from "./sent-thread-cache.js"; import { canonicalizeSlackApiTargetId, parseSlackTarget } from "./target-parsing.js"; import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js"; @@ -110,6 +127,10 @@ type SlackSendOpts = { enterpriseEventScope?: SlackEnterpriseEventScope; /** Monitor-private delivery limits already resolved for the active listener. */ textLimit?: number; + /** Slack-private marker for text that is already safe mrkdwn and must not be parsed again. */ + textIsSlackMrkdwn?: boolean; + /** Post retained blocks as a prelude before visible text chunks in one queued send. */ + separateTextAndBlocks?: boolean; mediaMaxBytes?: number; threadTs?: string; replyBroadcast?: boolean; @@ -296,6 +317,20 @@ function createSlackSendReceipt(params: { }); } +function createCombinedSlackSendReceipt( + partReceipts: readonly MessageReceipt[], + threadTs?: string, +): MessageReceipt { + const receipt = createMessageReceiptFromOutboundResults({ + results: partReceipts.map((partReceipt) => ({ channel: "slack", receipt: partReceipt })), + threadId: threadTs, + }); + receipt.parts.forEach((part, index) => { + part.index = index; + }); + return receipt; +} + function resolveToken(params: { explicit?: string; accountId: string; @@ -364,19 +399,31 @@ function resolveEnterpriseEventScope(params: { return scope; } -function resolveSlackTextChunks(params: { +function resolveSlackTextLimit(params: { cfg: OpenClawConfig; accountId?: string; - text: string; textLimit?: number; -}): string[] { - const text = params.text.trim(); +}): number { const configuredLimit = params.textLimit ?? resolveTextChunkLimit(params.cfg, "slack", params.accountId, { fallbackLimit: SLACK_TEXT_LIMIT, }); - const chunkLimit = Math.min(configuredLimit, SLACK_TEXT_LIMIT); + return Math.min(configuredLimit, SLACK_TEXT_LIMIT); +} + +function resolveSlackTextChunks(params: { + cfg: OpenClawConfig; + accountId?: string; + text: string; + textLimit?: number; + textIsSlackMrkdwn?: boolean; +}): string[] { + const text = params.text.trim(); + const chunkLimit = resolveSlackTextLimit(params); + if (params.textIsSlackMrkdwn) { + return chunkSlackMrkdwnText(text, chunkLimit); + } const tableMode = resolveMarkdownTableMode({ cfg: params.cfg, channel: "slack", @@ -1031,6 +1078,11 @@ async function sendMessageSlackQueuedInner(params: { }): Promise { const { opts, cfg, account, token, recipient, blocks, trimmedMessage, enterpriseDelivery } = params; + const textLimit = resolveSlackTextLimit({ + cfg, + accountId: account.accountId, + ...(opts.textLimit !== undefined ? { textLimit: opts.textLimit } : {}), + }); const client = enterpriseDelivery?.client ?? opts.client ?? getSlackWriteClient(token); const identity = enterpriseDelivery ? normalizeSlackSendIdentity(opts.identity) @@ -1066,17 +1118,52 @@ async function sendMessageSlackQueuedInner(params: { await opts.onDeliveryResult?.(result); return result; }; - if (blocks) { - if (opts.mediaUrl) { - throw new Error("Slack send does not support blocks with mediaUrl"); - } - const fallbackText = truncateSlackText( - appendSlackDataVisualizationFallbackText( - trimmedMessage || buildSlackBlocksFallbackText(blocks), - blocks, - ), - SLACK_TEXT_LIMIT, - ); + const blockFallbackText = blocks + ? appendSlackBlocksAccessibleFallbackText(trimmedMessage, blocks) || + buildSlackBlocksAccessibleFallbackText(blocks) + : undefined; + const separateBlocksFallbackText = + opts.separateTextAndBlocks && blocks + ? buildSlackBlocksAccessibleFallbackText(blocks) + : undefined; + const requiresSeparateNativeDataFallbackChunks = Boolean( + blocks && + separateBlocksFallbackText && + separateBlocksFallbackText.length > textLimit && + hasSlackNativeDataBlock(blocks), + ); + // Split-plan callers already own the independent visible fallback. Recombining + // retained blocks here would repeat their content in later text chunks. + const requiresChunkedBlockFallback = Boolean( + !opts.separateTextAndBlocks && + blocks && + blockFallbackText && + blockFallbackText.length > textLimit, + ); + const chunkedBlockSiblingCandidates = requiresChunkedBlockFallback + ? blocks?.filter( + (block) => hasSlackDataTableBlock([block]) || !isSlackBlockRepresentedByTextFallback(block), + ) + : undefined; + const chunkedBlockSiblingBlocks = chunkedBlockSiblingCandidates + ? retainSlackDataTablesWithinCompactFallback(chunkedBlockSiblingCandidates, textLimit) + : undefined; + const separateSiblingBlocks = opts.separateTextAndBlocks + ? requiresSeparateNativeDataFallbackChunks && blocks + ? retainSlackDataTablesWithinCompactFallback(blocks, textLimit) + : blocks + : chunkedBlockSiblingBlocks; + const chunkedBlockFallbackText = requiresChunkedBlockFallback + ? removeSlackBlocksFallbackParagraphs( + blockFallbackText ?? "", + chunkedBlockSiblingBlocks?.filter((block) => !hasSlackDataTableBlock([block])) ?? [], + ) + : undefined; + if (blocks && opts.mediaUrl) { + throw new Error("Slack send does not support blocks with mediaUrl"); + } + if (blocks && !requiresChunkedBlockFallback && !opts.separateTextAndBlocks) { + const fallbackText = truncateSlackText(blockFallbackText ?? "", textLimit); await opts.onPlatformSendDispatch?.(); const { response } = await postSlackMessageBestEffort({ client, @@ -1112,11 +1199,31 @@ async function sendMessageSlackQueuedInner(params: { }), }); } + const separateAuthoredText = + requiresSeparateNativeDataFallbackChunks && !opts.textIsSlackMrkdwn + ? markdownToSlackMrkdwn(trimmedMessage, { + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + ...(account.accountId ? { accountId: account.accountId } : {}), + }), + }) + : trimmedMessage; + const chunkSourceText = requiresChunkedBlockFallback + ? (chunkedBlockFallbackText ?? blockFallbackText ?? trimmedMessage) + : requiresSeparateNativeDataFallbackChunks && blocks + ? appendSlackNativeDataFallbackText(separateAuthoredText, blocks) + : trimmedMessage; const resolvedChunks = resolveSlackTextChunks({ cfg, accountId: account.accountId, - text: trimmedMessage, - ...(opts.textLimit !== undefined ? { textLimit: opts.textLimit } : {}), + text: chunkSourceText, + textLimit, + ...(opts.textIsSlackMrkdwn || + requiresChunkedBlockFallback || + requiresSeparateNativeDataFallbackChunks + ? { textIsSlackMrkdwn: true } + : {}), }); const mediaMaxBytes = opts.mediaMaxBytes ?? @@ -1125,10 +1232,19 @@ async function sendMessageSlackQueuedInner(params: { : undefined); const sentMessageIds: string[] = []; + const sentPartReceipts: MessageReceipt[] = []; let lastMessageId = ""; let deliveredChannelId = channelId; let canonicalDeliveredThreadTs: string | undefined; - let chunksToPost: string[]; + let replyBroadcastPartIndex = 0; + let partsToPost: Array<{ + text: string; + blocks?: (Block | KnownBlock)[]; + nativeDataRejectionFallback?: { + text: string; + blocks?: (Block | KnownBlock)[]; + }; + }>; if (opts.mediaUrl) { if (enterpriseDelivery && !enterpriseDelivery.uploadCompletionClient) { throw new Error("missing_enterprise_slack_upload_completion_client"); @@ -1153,24 +1269,72 @@ async function sendMessageSlackQueuedInner(params: { ...(enterpriseDelivery ? { auditContext: "slack-enterprise-immediate-upload" } : {}), }); sentMessageIds.push(lastMessageId); + const mediaReceipt = createSlackSendReceipt({ + platformMessageIds: [lastMessageId], + channelId, + kind: "media", + threadTs: normalizeSlackThreadTsCandidate(opts.threadTs), + }); + sentPartReceipts.push(mediaReceipt); await reportDelivery({ messageId: lastMessageId, channelId, threadTs: normalizeSlackThreadTsCandidate(opts.threadTs), - receipt: createSlackSendReceipt({ - platformMessageIds: [lastMessageId], - channelId, - kind: "media", - threadTs: normalizeSlackThreadTsCandidate(opts.threadTs), - }), + receipt: mediaReceipt, }); - chunksToPost = rest; + partsToPost = rest.map((text) => ({ text })); } else { - chunksToPost = resolvedChunks.length ? resolvedChunks : [""]; + const textParts = (resolvedChunks.length ? resolvedChunks : [""]).map((text) => ({ text })); + if (separateSiblingBlocks?.length) { + // Top-level text is hidden when blocks render. Keep authored siblings in + // their own message so every native-data fallback chunk remains visible. + let siblingText = buildSlackBlocksAccessibleFallbackText(separateSiblingBlocks); + const ownsLaterNativeDataFallback = + hasSlackNativeDataBlock(separateSiblingBlocks) && + hasCompleteSlackNativeDataFallbackText(chunkSourceText, separateSiblingBlocks); + if (ownsLaterNativeDataFallback) { + // The complete native-data fallback belongs to following text chunks. + // Keep its native blocks and non-data siblings in one compact post. + siblingText = buildSlackBlocksCompactAccessibleFallbackText(separateSiblingBlocks); + } + if (siblingText.length > textLimit) { + throw new Error( + `Slack retained-block accessibility fallback exceeds OpenClaw's ${String(textLimit)}-character limit`, + ); + } + const deferredRejection = ownsLaterNativeDataFallback + ? buildSlackDeferredNativeDataRejectionFallback(separateSiblingBlocks) + : undefined; + if (deferredRejection && deferredRejection.text.length > textLimit) { + throw new Error( + `Slack retained-block accessibility fallback exceeds OpenClaw's ${String(textLimit)}-character limit`, + ); + } + partsToPost = [ + { + text: siblingText, + blocks: separateSiblingBlocks, + ...(deferredRejection + ? { + nativeDataRejectionFallback: { + text: deferredRejection.text, + ...(deferredRejection.blocks.length > 0 + ? { blocks: deferredRejection.blocks } + : {}), + }, + } + : {}), + }, + ...textParts, + ]; + replyBroadcastPartIndex = 1; + } else { + partsToPost = textParts; + } } let sendIdentity = identity; - for (const [partIndex, chunk] of chunksToPost.entries()) { + for (const [partIndex, part] of partsToPost.entries()) { const baseMetadata = sentMessageIds.length === 0 ? opts.metadata : undefined; // Every post carries its index/count so reconciliation proves the complete // logical text send and never mistakes a partial chunk fanout for success. @@ -1181,7 +1345,7 @@ async function sendMessageSlackQueuedInner(params: { channelId, threadTs: opts.threadTs, partIndex, - partCount: chunksToPost.length, + partCount: partsToPost.length, }); if (partIndex === 0 && !opts.mediaUrl) { await opts.onPlatformSendDispatch?.(); @@ -1189,12 +1353,17 @@ async function sendMessageSlackQueuedInner(params: { const posted = await postSlackMessageBestEffort({ client, channelId, - text: chunk, + text: part.text, threadTs: opts.threadTs, - replyBroadcast: sentMessageIds.length === 0 ? opts.replyBroadcast : undefined, + replyBroadcast: + !opts.mediaUrl && partIndex === replyBroadcastPartIndex ? opts.replyBroadcast : undefined, identity: sendIdentity, metadata, unfurl, + ...(part.blocks?.length ? { blocks: part.blocks } : {}), + ...(part.nativeDataRejectionFallback + ? { nativeDataRejectionFallback: part.nativeDataRejectionFallback } + : {}), }); const response = posted.response; if (enterpriseDelivery && (!response.ok || !response.ts)) { @@ -1210,19 +1379,20 @@ async function sendMessageSlackQueuedInner(params: { canonicalDeliveredThreadTs ??= resolvePostedMessageThreadTs(response); if (response.ts) { sentMessageIds.push(response.ts); + const partThreadTs = + resolvePostedMessageThreadTs(response) ?? normalizeSlackThreadTsCandidate(opts.threadTs); + const partReceipt = createSlackSendReceipt({ + platformMessageIds: [response.ts], + channelId: deliveredChannelId, + kind: part.blocks?.length ? "card" : "text", + threadTs: partThreadTs, + }); + sentPartReceipts.push(partReceipt); await reportDelivery({ messageId: response.ts, channelId: deliveredChannelId, - threadTs: - resolvePostedMessageThreadTs(response) ?? normalizeSlackThreadTsCandidate(opts.threadTs), - receipt: createSlackSendReceipt({ - platformMessageIds: [response.ts], - channelId: deliveredChannelId, - kind: "text", - threadTs: - resolvePostedMessageThreadTs(response) ?? - normalizeSlackThreadTsCandidate(opts.threadTs), - }), + threadTs: partThreadTs, + receipt: partReceipt, }); } } @@ -1234,11 +1404,14 @@ async function sendMessageSlackQueuedInner(params: { messageId, channelId: deliveredChannelId, threadTs: deliveredThreadTs, - receipt: createSlackSendReceipt({ - platformMessageIds: sentMessageIds.length ? sentMessageIds : [messageId], - channelId: deliveredChannelId, - kind: opts.mediaUrl ? "media" : "text", - threadTs: deliveredThreadTs, - }), + receipt: + sentPartReceipts.length > 0 + ? createCombinedSlackSendReceipt(sentPartReceipts, deliveredThreadTs) + : createSlackSendReceipt({ + platformMessageIds: sentMessageIds.length ? sentMessageIds : [messageId], + channelId: deliveredChannelId, + kind: opts.mediaUrl ? "media" : "text", + threadTs: deliveredThreadTs, + }), }; } diff --git a/extensions/slack/src/shared-interactive.test.ts b/extensions/slack/src/shared-interactive.test.ts index e83ac63ebdf8..f7c25ebddb4f 100644 --- a/extensions/slack/src/shared-interactive.test.ts +++ b/extensions/slack/src/shared-interactive.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { buildSlackInteractiveBlocks, buildSlackPresentationBlocks, + canRenderSlackPresentation, + canRenderSlackPresentationTables, resolveSlackBlockOffsets, type SlackBlock, } from "./blocks-render.js"; @@ -451,7 +453,7 @@ describe("buildSlackPresentationBlocks", () => { ).toEqual([ { type: "context", - elements: [{ type: "mrkdwn", text: `${title} (pie chart)\n- Product: 60` }], + elements: [{ type: "mrkdwn", text: `${title} (pie chart)\n- Product: 60`, verbatim: true }], }, ]); }); @@ -493,6 +495,7 @@ describe("buildSlackPresentationBlocks", () => { { type: "mrkdwn", text: "Active sessions (area chart)\n- Sessions: 09:00: 3", + verbatim: true, }, ], }); @@ -527,11 +530,94 @@ describe("buildSlackPresentationBlocks", () => { { type: "mrkdwn", text: "Presentation chart (pie chart)\n- Closed: 8", + verbatim: true, }, ], }, ]); }); + + it("renders portable tables as native data tables with typed numeric cells", () => { + expect( + buildSlackPresentationBlocks({ + blocks: [ + { + type: "table", + caption: "Pipeline report", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000], + ], + rowHeaderColumnIndex: 0, + }, + ], + }), + ).toEqual([ + { + type: "data_table", + caption: "Pipeline report", + row_header_column_index: 0, + rows: [ + [ + { type: "raw_text", text: "Account" }, + { type: "raw_text", text: "Stage" }, + { type: "raw_text", text: "ARR" }, + ], + [ + { type: "raw_text", text: "Acme" }, + { type: "raw_text", text: "Won" }, + { type: "raw_number", value: 125000, text: "125000" }, + ], + [ + { type: "raw_text", text: "Globex" }, + { type: "raw_text", text: "Review" }, + { type: "raw_number", value: 82000, text: "82000" }, + ], + ], + }, + ]); + }); + + it("keeps over-budget tables out of the native block renderer", () => { + const table = (caption: string, value: string) => ({ + type: "table" as const, + caption, + headers: ["Account"], + rows: Array.from({ length: 100 }, (_entry, index) => [`${String(index)}-${value}`]), + }); + const presentation = { + blocks: [table("First", "a".repeat(45)), table("Second", "b".repeat(55))], + }; + + expect(canRenderSlackPresentation(presentation)).toBe(false); + expect(canRenderSlackPresentationTables(presentation)).toBe(false); + expect(buildSlackPresentationBlocks(presentation)).toEqual([]); + }); + + it("counts raw native tables against the aggregate Slack table budget", () => { + const nativeTable = { + type: "data_table", + caption: "Existing", + rows: [[{ type: "raw_text", text: "Name" }], [{ type: "raw_text", text: "x".repeat(9995) }]], + } as SlackBlock; + + const blocks = buildSlackPresentationBlocks( + { + blocks: [ + { + type: "table", + caption: "Portable", + headers: ["Name"], + rows: [["Acme"]], + }, + ], + }, + resolveSlackBlockOffsets([nativeTable]), + ); + + expect(blocks).toEqual([]); + }); }); describe("resolveSlackReplyBlocks", () => { diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index af0696504638..9bc8085411c0 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -107,6 +107,7 @@ export function createSlackPluginBase(params: { ] ).concat([ "- For line, bar, area, or pie data, use a `presentation` chart block; Slack renders it as a native chart and retains a text data summary for accessibility.", + "- For row-and-column data, use an explicit `presentation` table block; Slack renders it as a native table and retains a linear text summary for accessibility. Markdown pipe tables are not auto-promoted.", "- Slack plain text sends: write standard Markdown; OpenClaw converts it to Slack mrkdwn, including `**bold**`, headings, lists, and `[label](url)` links.", "- When mentioning Slack users, use the stable `<@USER_ID>` token from Slack context instead of plain `@name` text so Slack notifies and links the user.", "- Slack Block Kit or presentation text fields are sent as Slack mrkdwn directly; use `*bold*`, `_italic_`, `~strike~`, `` links, and avoid Markdown headings or pipe tables there.", diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index ea5f94b6b1b9..64f5a653db3c 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -1453,6 +1453,39 @@ describe("handleTelegramAction", () => { expect(requireRecord(call[2], "mixed message and chart options").token).toBe("tok"); }); + it("appends complete table data when explicit message content is present", async () => { + await handleTelegramAction( + { + action: "sendMessage", + to: "123456", + message: "Quarterly pipeline", + presentation: { + title: "FY25 outlook", + blocks: [ + { type: "text", text: "Do not duplicate this block" }, + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000], + ], + }, + ], + }, + }, + telegramConfig(), + ); + + const call = mockCall(sendMessageTelegram, 0, "mixed message and table"); + expect(call[0]).toBe("123456"); + expect(call[1]).toBe( + "Quarterly pipeline\n\nFY25 outlook\n\nPipeline (table)\n- Account: Acme; Stage: Won; ARR: 125000\n- Account: Globex; Stage: Review; ARR: 82000", + ); + expect(requireRecord(call[2], "mixed message and table options").token).toBe("tok"); + }); + it("uses presentation fallback text for button-only sends", async () => { await handleTelegramAction( { diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index ca3d14154baf..e84683cbf16e 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -228,14 +228,17 @@ function readTelegramSendContent(params: { readStringParam(params.args, "content", { allowEmpty: true }) ?? readStringParam(params.args, "message", { allowEmpty: true }) ?? readStringParam(params.args, "caption", { allowEmpty: true }); - const charts = params.presentation?.blocks.filter((block) => block.type === "chart") ?? []; + const unsupportedBlocks = + params.presentation?.blocks.filter( + (block) => block.type === "chart" || block.type === "table", + ) ?? []; const presentationText = explicitContent == null && params.presentation ? renderMessagePresentationFallbackText({ presentation: params.presentation }) - : explicitContent != null && charts.length > 0 + : explicitContent != null && unsupportedBlocks.length > 0 ? renderMessagePresentationFallbackText({ text: explicitContent, - presentation: { ...params.presentation, blocks: charts }, + presentation: { ...params.presentation, blocks: unsupportedBlocks }, }) : undefined; const interactiveText = diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 0b8b0414a438..8c207c5475ea 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1461,6 +1461,101 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("canonicalizes mixed presentation finals before durable stream-off delivery", async () => { + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { messageIds: ["1002"], visibleReplySent: true }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { + text: "Quarterly results", + presentation: { + title: "FY25 outlook", + blocks: [ + { type: "text", text: "Executive summary" }, + { type: "context", text: "Unaudited" }, + { + type: "chart", + chartType: "pie", + title: "Revenue mix", + segments: [ + { label: "Product", value: 60 }, + { label: "Services", value: 40 }, + ], + }, + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: [["Acme", "Won"]], + }, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext(), + streamMode: "off", + telegramDeps: telegramDepsForTest, + }); + + const outbound = expectRecordFields(mockCallArg(deliverInboundReplyWithMessageSendContext), {}); + const payload = expectRecordFields(outbound.payload, { + text: [ + "Quarterly results", + "FY25 outlook", + "Executive summary", + "Unaudited", + "Revenue mix (pie chart)\n- Product: 60\n- Services: 40", + "Pipeline (table)\n- Account: Acme; Stage: Won", + ].join("\n\n"), + }); + expect(payload.presentation).toBeUndefined(); + expect(payload.text).not.toContain("Refresh"); + expectRecordFields(payload.channelData, { + telegram: { buttons: [[{ text: "Refresh", callback_data: "refresh" }]] }, + }); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("keeps control-only finals deliverable through durable stream-off delivery", async () => { + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { messageIds: ["1003"], visibleReplySent: true }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }], + }, + }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext(), + streamMode: "off", + telegramDeps: telegramDepsForTest, + }); + + const outbound = expectRecordFields(mockCallArg(deliverInboundReplyWithMessageSendContext), {}); + const payload = expectRecordFields(outbound.payload, { text: "Choose an option." }); + expect(payload.presentation).toBeUndefined(); + expectRecordFields(payload.channelData, { + telegram: { buttons: [[{ text: "Retry", callback_data: "retry" }]] }, + }); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it("queues media-only final Telegram replies through outbound delivery when available", async () => { deliverInboundReplyWithMessageSendContext.mockResolvedValue({ status: "handled_visible", @@ -2714,6 +2809,40 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverInboundReplyWithMessageSendContext).not.toHaveBeenCalled(); }); + it("materializes table-only finals into the active answer preview", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { + presentation: { + title: "FY25 outlook", + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000], + ], + }, + ], + }, + }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.update).toHaveBeenCalledWith( + "FY25 outlook\n\nPipeline (table)\n- Account: Acme; Stage: Won; ARR: 125000\n- Account: Globex; Stage: Review; ARR: 82000", + ); + expect(deliverReplies).not.toHaveBeenCalled(); + expect(deliverInboundReplyWithMessageSendContext).not.toHaveBeenCalled(); + }); + it("appends chart data to final text before active preview finalization", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { @@ -2742,10 +2871,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext() }); expect(answerDraftStream.update).toHaveBeenCalledWith( - "Quarterly results\n\nFY25 outlook\n\nRevenue (bar chart)\n- USD: Q1: 12; Q2: 18", - ); - expect(answerDraftStream.update).not.toHaveBeenCalledWith( - expect.stringContaining("Do not duplicate this block"), + "Quarterly results\n\nFY25 outlook\n\nDo not duplicate this block\n\nRevenue (bar chart)\n- USD: Q1: 12; Q2: 18", ); expect(deliverReplies).not.toHaveBeenCalled(); expect(deliverInboundReplyWithMessageSendContext).not.toHaveBeenCalled(); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index c781effc3f17..95fba349baac 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -112,7 +112,7 @@ import { selectTelegramGroupHistoryAfterLastSelf, } from "./group-history-window.js"; import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js"; -import { materializeTelegramChartFallback } from "./interactive-fallback.js"; +import { canonicalizeTelegramPresentationPayload } from "./interactive-fallback.js"; import { createLaneDeliveryStateTracker, createLaneTextDeliverer, @@ -1743,7 +1743,7 @@ export const dispatchTelegramMessage = async ({ delete payloadForPlan.isReasoning; } const normalized = projectPayloadForDelivery(payloadForPlan); - return normalized ? materializeTelegramChartFallback(normalized) : undefined; + return normalized ? canonicalizeTelegramPresentationPayload(normalized) : undefined; }; const usesNativeTelegramQuote = (payload: ReplyPayload): boolean => { if (replyQuoteText != null) { diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 3277fd4a76d9..cc38436846ad 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -464,6 +464,66 @@ describe("registerTelegramNativeCommands", () => { expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); }); + it("delivers presentation-only tables returned by plugin commands", async () => { + const presentation = { + title: "FY25 outlook", + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: [["Acme", "Won"]], + }, + ], + }; + const { handler } = registerPlugCommand({ result: { presentation } }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ presentation }); + expect(replyAt(firstDeliverRepliesParams()).text).toBeUndefined(); + }); + + it("delivers Telegram button-only plugin command replies", async () => { + const buttons = [[{ text: "Retry", callback_data: "retry" }]]; + const { handler } = registerPlugCommand({ + result: { channelData: { telegram: { buttons } } }, + }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + channelData: { telegram: { buttons } }, + }); + }); + + it("targets reaction-only plugin replies at the invoking command message", async () => { + const { handler } = registerPlugCommand({ + result: { channelData: { telegram: { reaction: { emoji: "šŸ”„" } } } }, + }); + + await handler(createPrivateCommandContext({ messageId: 321 })); + + const deliveryParams = firstDeliverRepliesParams(); + expect(replyAt(deliveryParams)).toEqual({ + replyToId: "321", + channelData: { telegram: { reaction: { emoji: "šŸ”„" } } }, + }); + expect(deliveryParams.replyToMode).toBe("all"); + }); + + it("uses the empty-response fallback for unrelated metadata-only plugin results", async () => { + const { handler } = registerPlugCommand({ + result: { channelData: { plugin: { traceId: "trace-1" } } }, + }); + + await handler(createPrivateCommandContext()); + + expect(replyAt(firstDeliverRepliesParams())).toEqual({ + text: "No response generated. Please try again.", + }); + }); + it("replies to unmatched plugin commands in the originating forum topic", async () => { const { handler, sendMessage } = registerPlugCommand(); pluginCommandMocks.matchPluginCommand.mockReturnValue(null as never); @@ -587,6 +647,32 @@ describe("registerTelegramNativeCommands", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("delivers reactions after cleaning up a metadata-driven progress placeholder", async () => { + const { handler, sendMessage, deleteMessage } = registerPlugCommand({ + args: "now", + command: { + nativeProgressMessages: { telegram: "Working on it..." }, + }, + result: { + text: "Command completed successfully", + channelData: { telegram: { reaction: { emoji: "šŸ”„" } } }, + }, + }); + + await handler(createPrivateCommandContext({ match: "now", messageId: 321 })); + + expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith(100, 999); + const deliveryParams = firstDeliverRepliesParams(); + expect(deliveryParams.replyToMode).toBe("all"); + expect(replyAt(deliveryParams)).toEqual({ + text: "Command completed successfully", + replyToId: "321", + channelData: { telegram: { reaction: { emoji: "šŸ”„" } } }, + }); + }); + it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => { const { handler, sendMessage, deleteMessage } = registerPlugCommand({ args: "now", diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 39efb9cedcc6..ccb386365a0d 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -35,7 +35,7 @@ import type { import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; @@ -125,6 +125,9 @@ type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").Plu type TelegramNativeReplyChannelData = { buttons?: TelegramInlineButtons; pin?: boolean; + reaction?: { + emoji?: unknown; + }; }; type FastModeState = ReturnType; type TelegramResolvedGroupConfig = { @@ -491,8 +494,20 @@ function isSuppressedTelegramNativeReplyPayload(result: TelegramNativeReplyPaylo return result.suppressReply === true; } +function hasTelegramNativeReplyReaction(result: TelegramNativeReplyPayload): boolean { + const reactionEmoji = resolveTelegramNativeReplyChannelData(result)?.reaction?.emoji; + return typeof reactionEmoji === "string" && reactionEmoji.trim().length > 0; +} + function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { - return resolveSendableOutboundReplyParts(result).hasContent; + const { channelData: _channelData, ...portableContent } = result; + if (hasOutboundReplyContent(portableContent, { trimText: true })) { + return true; + } + const telegramData = resolveTelegramNativeReplyChannelData(result); + return Boolean( + buildInlineKeyboard(telegramData?.buttons) || hasTelegramNativeReplyReaction(result), + ); } function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean { @@ -505,6 +520,7 @@ function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): b !result.presentation && !result.interactive && !result.btw && + !hasTelegramNativeReplyReaction(result) && telegramData?.pin !== true, ); } @@ -1797,9 +1813,13 @@ export const registerTelegramNativeCommands = ({ return; } - const deliverableResult = hasRenderableTelegramNativeReplyPayload(result) - ? result - : { text: EMPTY_RESPONSE_FALLBACK }; + const hasReaction = hasTelegramNativeReplyReaction(result); + const deliverableResult: TelegramNativeReplyPayload = + hasRenderableTelegramNativeReplyPayload(result) + ? hasReaction && !normalizeOptionalString(result.replyToId) + ? { ...result, replyToId: String(msg.message_id) } + : result + : { text: EMPTY_RESPONSE_FALLBACK }; const progressResultText = typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0 ? deliverableResult.text @@ -1844,6 +1864,7 @@ export const registerTelegramNativeCommands = ({ await deliverReplies({ replies: [deliverableResult], ...deliveryBaseOptions, + ...(hasReaction ? { replyToMode: "all" as const } : {}), silent: runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true, }); diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 60b390f4467f..0394a74505a7 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -40,7 +40,10 @@ import { telegramHtmlToPlainTextFallback, wrapFileReferencesInHtml, } from "../format.js"; -import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js"; +import { + canonicalizeTelegramPresentationPayload, + resolveTelegramInteractiveTextFallback, +} from "../interactive-fallback.js"; import type { TelegramPromptContextProjectionSequence } from "../prompt-context-projection.js"; import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js"; import { isTelegramHtmlParseError } from "../rich-plain-fallback.js"; @@ -815,7 +818,7 @@ export async function deliverReplies(params: { }), ); for (const originalReply of normalizedReplies) { - let reply = originalReply; + let reply = canonicalizeTelegramPresentationPayload(originalReply); const mediaList = reply?.mediaUrls?.length ? reply.mediaUrls : reply?.mediaUrl diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 05ed3cb532d3..05327fa7091e 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -516,7 +516,7 @@ describe("deliverReplies", () => { }); }); - it("uses presentation button labels as fallback text for presentation-only replies", async () => { + it("keeps presentation-only controls deliverable without duplicating button labels", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 4, chat: { id: "123" } }); const bot = createBot({ sendMessage }); @@ -535,7 +535,7 @@ describe("deliverReplies", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(firstMockCallArg(sendMessage, 0)).toBe("123"); - expect(firstMockCallArg(sendMessage, 1)).toContain("Retry"); + expect(firstMockCallArg(sendMessage, 1)).toBe("Choose an option."); expectRecordFields(mockCallArg(sendMessage, 0, 2), { reply_markup: { inline_keyboard: [[{ text: "Retry", callback_data: "cmd:retry" }]], @@ -543,6 +543,66 @@ describe("deliverReplies", () => { }); }); + it("keeps native Telegram button-only replies deliverable", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 5, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [ + { + channelData: { + telegram: { + buttons: [[{ text: "Retry", callback_data: "cmd:retry" }]], + }, + }, + }, + ], + runtime, + bot, + }); + + expect(runtime.error).not.toHaveBeenCalled(); + expect(firstMockCallArg(sendMessage, 0)).toBe("123"); + expect(firstMockCallArg(sendMessage, 1)).toBe("Choose an option."); + expectRecordFields(mockCallArg(sendMessage, 0, 2), { + reply_markup: { + inline_keyboard: [[{ text: "Retry", callback_data: "cmd:retry" }]], + }, + }); + }); + + it("appends presentation tables to top-level text exactly once", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 5, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [ + { + text: "Quarterly results", + presentation: { + title: "FY25 outlook", + blocks: [ + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: [["Acme", "Won"]], + }, + ], + }, + }, + ], + runtime, + bot, + }); + + expect(firstMockCallArg(sendMessage, 1)).toBe( + "Quarterly results\n\nFY25 outlook\n\nPipeline (table)\n\n• Account: Acme; Stage: Won", + ); + }); + it("reports message_sent success=false when hooks blank out a text-only reply", async () => { messageHookRunner.hasHooks.mockImplementation( (name: string) => name === "message_sending" || name === "message_sent", diff --git a/extensions/telegram/src/interactive-fallback.test.ts b/extensions/telegram/src/interactive-fallback.test.ts new file mode 100644 index 000000000000..5860fd446a42 --- /dev/null +++ b/extensions/telegram/src/interactive-fallback.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { canonicalizeTelegramPresentationPayload } from "./interactive-fallback.js"; + +describe("canonicalizeTelegramPresentationPayload", () => { + it("preserves mixed presentation order while moving controls to Telegram buttons", () => { + const result = canonicalizeTelegramPresentationPayload({ + text: "Top-level summary", + presentation: { + title: "FY25 outlook", + blocks: [ + { type: "text", text: "Before table" }, + { + type: "table", + caption: "Pipeline", + headers: ["Account", "Stage"], + rows: [ + ["Acme", "Won"], + ["Globex", "Review"], + ], + }, + { type: "context", text: "After table" }, + { type: "buttons", buttons: [{ label: "Refresh", value: "refresh" }] }, + ], + }, + }); + + const text = result.text ?? ""; + const orderedMarkers = [ + "Top-level summary", + "FY25 outlook", + "Before table", + "Pipeline (table)", + "- Account: Acme; Stage: Won", + "- Account: Globex; Stage: Review", + "After table", + ]; + for (const [index, marker] of orderedMarkers.entries()) { + expect(text.indexOf(marker)).toBeGreaterThan( + index === 0 ? -1 : text.indexOf(orderedMarkers[index - 1]!), + ); + } + expect(text).not.toContain("Refresh"); + expect(result.presentation).toBeUndefined(); + expect(result.channelData?.telegram).toEqual({ + buttons: [[{ text: "Refresh", callback_data: "refresh" }]], + }); + }); + + it("keeps control-only payloads deliverable without duplicating their labels", () => { + const result = canonicalizeTelegramPresentationPayload({ + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }], + }, + }); + + expect(result).toMatchObject({ + text: "Choose an option.", + channelData: { + telegram: { buttons: [[{ text: "Retry", callback_data: "retry" }]] }, + }, + }); + expect(result.text).not.toContain("Retry"); + expect(result.presentation).toBeUndefined(); + }); + + it("keeps native Telegram button-only payloads deliverable", () => { + const buttons = [[{ text: "Retry", callback_data: "retry" }]]; + const result = canonicalizeTelegramPresentationPayload({ + channelData: { telegram: { buttons } }, + }); + + expect(result).toEqual({ + text: "Choose an option.", + channelData: { telegram: { buttons } }, + }); + }); + + it("preserves select prompts and maps option labels only to native buttons", () => { + const result = canonicalizeTelegramPresentationPayload({ + presentation: { + blocks: [ + { + type: "select", + placeholder: "Choose an environment", + options: [ + { label: "Production", value: "prod" }, + { label: "Staging", value: "staging" }, + ], + }, + ], + }, + }); + + expect(result.text).toBe("Choose an environment"); + expect(result.text).not.toContain("Production"); + expect(result.channelData?.telegram).toEqual({ + buttons: [ + [ + { text: "Production", callback_data: "prod" }, + { text: "Staging", callback_data: "staging" }, + ], + ], + }); + }); + + it("falls back only controls that Telegram cannot encode", () => { + const result = canonicalizeTelegramPresentationPayload({ + presentation: { + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Retry", value: "retry" }, + { label: "Copy manually", value: "x".repeat(65) }, + ], + }, + ], + }, + }); + + expect(result.text).toBe("- Copy manually"); + expect(result.text).not.toContain("Retry"); + expect(result.channelData?.telegram).toEqual({ + buttons: [[{ text: "Retry", callback_data: "retry" }]], + }); + }); + + it("falls back presentation controls when explicit Telegram buttons take precedence", () => { + const nativeButtons = [[{ text: "Native", callback_data: "native" }]]; + const result = canonicalizeTelegramPresentationPayload({ + text: "Use the available action", + channelData: { telegram: { buttons: nativeButtons } }, + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Generic", value: "generic" }] }], + }, + }); + + expect(result.text).toBe("Use the available action\n\n- Generic"); + expect(result.channelData?.telegram).toEqual({ buttons: nativeButtons }); + expect(result.presentation).toBeUndefined(); + }); + + it("does not duplicate an already-materialized full fallback", () => { + const presentation = { + blocks: [ + { type: "text" as const, text: "Summary" }, + { + type: "table" as const, + caption: "Pipeline", + headers: ["Account"], + rows: [["Acme"]], + }, + ], + }; + const first = canonicalizeTelegramPresentationPayload({ presentation }); + const second = canonicalizeTelegramPresentationPayload({ + text: first.text, + presentation, + }); + + expect(second.text).toBe(first.text); + }); +}); diff --git a/extensions/telegram/src/interactive-fallback.ts b/extensions/telegram/src/interactive-fallback.ts index aa047e51d47f..c637ab19fbd2 100644 --- a/extensions/telegram/src/interactive-fallback.ts +++ b/extensions/telegram/src/interactive-fallback.ts @@ -1,39 +1,166 @@ // Telegram plugin module implements interactive fallback behavior. import { + adaptMessagePresentationForChannel, interactiveReplyToPresentation, + isMessagePresentationInteractiveBlock, normalizeMessagePresentation, normalizeInteractiveReply, renderMessagePresentationFallbackText, resolveInteractiveTextFallback, + type MessagePresentation, + type MessagePresentationInteractiveBlock, } from "openclaw/plugin-sdk/interactive-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { buildTelegramPresentationButtons, resolveTelegramInlineButtons } from "./button-types.js"; +import { buildInlineKeyboard } from "./inline-keyboard.js"; -/** Materialize unsupported charts before Telegram's local preview consumes the payload. */ -export function materializeTelegramChartFallback(payload: ReplyPayload): ReplyPayload { - const presentation = normalizeMessagePresentation(payload.presentation); - const charts = presentation?.blocks.filter((block) => block.type === "chart") ?? []; - if (!presentation || charts.length === 0) { - return payload; +const TELEGRAM_CONTROL_ONLY_FALLBACK = "Choose an option."; + +export const TELEGRAM_PRESENTATION_CAPABILITIES = { + supported: true, + buttons: true, + selects: true, + context: true, + divider: false, + limits: { + actions: { + maxActions: 100, + maxActionsPerRow: 3, + maxLabelLength: 64, + supportsStyles: false, + supportsDisabled: false, + }, + selects: { + maxOptions: 100, + maxLabelLength: 64, + }, + text: { + markdownDialect: "markdown" as const, + }, + }, +}; + +function canEncodeTelegramPresentationControl(block: MessagePresentationInteractiveBlock): boolean { + return Boolean(buildTelegramPresentationButtons({ blocks: [block] })?.length); +} + +function partitionTelegramPresentationBlocks(params: { + presentation: MessagePresentation; + presentationControlsSelected: boolean; +}): { + fallbackBlocks: MessagePresentation["blocks"]; + nativeControlBlocks: MessagePresentationInteractiveBlock[]; +} { + const fallbackBlocks: MessagePresentation["blocks"] = []; + const nativeControlBlocks: MessagePresentationInteractiveBlock[] = []; + for (const block of params.presentation.blocks) { + if (!isMessagePresentationInteractiveBlock(block)) { + fallbackBlocks.push(block); + continue; + } + if (!params.presentationControlsSelected) { + fallbackBlocks.push(block); + continue; + } + if (block.type === "buttons") { + const nativeButtons: typeof block.buttons = []; + const fallbackButtons: typeof block.buttons = []; + for (const button of block.buttons) { + const target = canEncodeTelegramPresentationControl({ type: "buttons", buttons: [button] }) + ? nativeButtons + : fallbackButtons; + target.push(button); + } + if (nativeButtons.length > 0) { + nativeControlBlocks.push({ type: "buttons", buttons: nativeButtons }); + } + if (fallbackButtons.length > 0) { + fallbackBlocks.push({ type: "buttons", buttons: fallbackButtons }); + } + continue; + } + + const nativeOptions: typeof block.options = []; + const fallbackOptions: typeof block.options = []; + for (const option of block.options) { + const target = canEncodeTelegramPresentationControl({ type: "select", options: [option] }) + ? nativeOptions + : fallbackOptions; + target.push(option); + } + if (nativeOptions.length > 0) { + nativeControlBlocks.push({ ...block, options: nativeOptions }); + } + if (fallbackOptions.length > 0) { + fallbackBlocks.push({ ...block, options: fallbackOptions }); + } else if (block.placeholder) { + // Telegram maps selects to buttons, so retain the select prompt in message text. + fallbackBlocks.push({ type: "text", text: block.placeholder }); + } } + return { fallbackBlocks, nativeControlBlocks }; +} - const chartText = renderMessagePresentationFallbackText({ - presentation: { ...presentation, blocks: charts }, +/** Convert portable presentation into the one Telegram payload shape used by every send funnel. */ +export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload): ReplyPayload { + const normalizedPresentation = normalizeMessagePresentation(payload.presentation); + const telegramData = payload.channelData?.telegram as + | (Record & { + buttons?: Parameters[0]["buttons"]; + }) + | undefined; + if (!normalizedPresentation) { + const nativeButtons = resolveTelegramInlineButtons({ buttons: telegramData?.buttons }); + if (!buildInlineKeyboard(nativeButtons) || payload.text?.trim()) { + return payload; + } + // Native-only controls need the same visible message anchor as portable controls. + return { ...payload, text: TELEGRAM_CONTROL_ONLY_FALLBACK }; + } + const presentation = adaptMessagePresentationForChannel({ + presentation: normalizedPresentation, + capabilities: TELEGRAM_PRESENTATION_CAPABILITIES, }); - const currentText = payload.text?.trim(); - const text = currentText?.includes(chartText) - ? currentText - : [currentText, chartText].filter(Boolean).join("\n\n"); - const remainingBlocks = presentation.blocks.filter((block) => block.type !== "chart"); - const materialized: ReplyPayload = { ...payload, text }; - if (remainingBlocks.length > 0) { - // The title moved into text with the charts; retaining it on the remaining - // presentation would render the same heading twice. - const { title: _materializedTitle, ...remainingPresentation } = presentation; - materialized.presentation = { ...remainingPresentation, blocks: remainingBlocks }; - } else { - delete materialized.presentation; + + const interactive = normalizeInteractiveReply(payload.interactive); + const existingButtons = resolveTelegramInlineButtons({ + buttons: telegramData?.buttons, + interactive, + }); + const presentationControlsSelected = existingButtons === undefined; + const { fallbackBlocks, nativeControlBlocks } = partitionTelegramPresentationBlocks({ + presentation, + presentationControlsSelected, + }); + const presentationButtons = buildTelegramPresentationButtons({ + blocks: nativeControlBlocks, + }); + const buttons = existingButtons ?? presentationButtons; + + const fallbackText = renderMessagePresentationFallbackText({ + presentation: { ...presentation, blocks: fallbackBlocks }, + }); + const currentText = + resolveInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? ""; + const hasFallback = + fallbackText.length > 0 && + (currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`)); + const text = hasFallback ? currentText : [currentText, fallbackText].filter(Boolean).join("\n\n"); + const { presentation: _presentation, ...withoutPresentation } = payload; + const canonical: ReplyPayload = { + ...withoutPresentation, + text: text || (buttons ? TELEGRAM_CONTROL_ONLY_FALLBACK : ""), + }; + if (buttons) { + canonical.channelData = { + ...payload.channelData, + telegram: { + ...telegramData, + buttons, + }, + }; } - return materialized; + return canonical; } export function resolveTelegramInteractiveTextFallback(params: { diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 370ea18f6d60..1854ac36ee19 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -436,7 +436,7 @@ describe("telegramOutbound", () => { expect(sendMessageTelegramMock).not.toHaveBeenCalled(); }); - it("uses presentation button labels as fallback text for presentation-only payloads", async () => { + it("keeps presentation-only controls deliverable without duplicating labels", async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-presentation-buttons", chatId: "12345", @@ -454,7 +454,7 @@ describe("telegramOutbound", () => { deps: { sendTelegram: sendMessageTelegramMock }, }); - const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "- Retry"); + const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "Choose an option."); expect(options.buttons).toEqual([[{ text: "Retry", callback_data: "cmd:retry" }]]); expect(result).toEqual({ channel: "telegram", @@ -490,12 +490,7 @@ describe("telegramOutbound", () => { deps: { sendTelegram: sendMessageTelegramMock }, }); - const options = callOptionsAt( - sendMessageTelegramMock, - 0, - "12345", - "Open app:\n\n- Launch: https://example.com/app", - ); + const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "Open app:"); expect(options.buttons).toEqual([ [{ text: "Launch", web_app: { url: "https://example.com/app" } }], ]); @@ -525,6 +520,7 @@ describe("telegramOutbound", () => { expect((rendered?.channelData?.telegram as { buttons?: unknown })?.buttons).toEqual([ [{ text: "Native", callback_data: "native" }], ]); + expect(rendered?.text).toBe("Use native buttons:\n\n- Generic"); }); it("preserves legacy interactive buttons when rendering mixed presentation payloads", async () => { @@ -553,9 +549,9 @@ describe("telegramOutbound", () => { throw new Error("expected rendered Telegram presentation"); } - expect((rendered.channelData?.telegram as { buttons?: unknown } | undefined)?.buttons).toBe( - undefined, - ); + expect((rendered.channelData?.telegram as { buttons?: unknown } | undefined)?.buttons).toEqual([ + [{ text: "Legacy", callback_data: "legacy" }], + ]); await telegramOutbound.sendPayload!({ cfg: {} as never, @@ -609,12 +605,7 @@ describe("telegramOutbound", () => { deps: { sendTelegram: sendMessageTelegramMock }, }); - const options = callOptionsAt( - sendMessageTelegramMock, - 0, - "12345", - "Approve?\n\n- Allow Always", - ); + const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "Approve?"); expect(options.buttons).toEqual([ [{ text: "Allow Always", callback_data: `/approve ${approvalId} always` }], ]); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 75b1a905183e..6b187718ee51 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -10,10 +10,6 @@ import { attachChannelToResult, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; -import { - normalizeMessagePresentation, - renderMessagePresentationFallbackText, -} from "openclaw/plugin-sdk/interactive-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking"; import { resolveSendableOutboundReplyParts, @@ -25,7 +21,11 @@ import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking" import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { splitTelegramHtmlChunks } from "./format.js"; -import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; +import { + canonicalizeTelegramPresentationPayload, + resolveTelegramInteractiveTextFallback, + TELEGRAM_PRESENTATION_CAPABILITIES, +} from "./interactive-fallback.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; import { createTelegramPromptContextProjectionCursor, @@ -141,7 +141,8 @@ export async function sendTelegramPayloadMessages(params: { payload: ReplyPayload; baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">; }): Promise>> { - const telegramData = params.payload.channelData?.telegram as + const payload = canonicalizeTelegramPresentationPayload(params.payload); + const telegramData = payload.channelData?.telegram as | { buttons?: TelegramInlineButtons; quoteText?: string; @@ -152,18 +153,17 @@ export async function sendTelegramPayloadMessages(params: { typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const reactionEmoji = typeof telegramData?.reaction?.emoji === "string" ? telegramData.reaction.emoji : undefined; - const presentation = normalizeMessagePresentation(params.payload.presentation); const text = resolveTelegramInteractiveTextFallback({ - text: params.payload.text, - interactive: params.payload.interactive, - presentation, + text: payload.text, + interactive: payload.interactive, + presentation: payload.presentation, }) ?? ""; - const mediaUrls = resolveSendableOutboundReplyParts(params.payload).mediaUrls; + const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls; const buttons = resolveTelegramInlineButtons({ buttons: telegramData?.buttons, - presentation, - interactive: params.payload.interactive, + presentation: payload.presentation, + interactive: payload.interactive, }); const replyToMessageId = params.baseOpts.replyToMessageId; const promptContextSource = resolveTelegramPromptContextSource(params.payload); @@ -177,7 +177,7 @@ export async function sendTelegramPayloadMessages(params: { const payloadOpts = { ...params.baseOpts, quoteText, - ...(params.payload.audioAsVoice === true ? { asVoice: true } : {}), + ...(payload.audioAsVoice === true ? { asVoice: true } : {}), }; const shouldConsumeImplicitReplyTarget = payloadOpts.replyToIdSource === "implicit" && @@ -255,28 +255,7 @@ export function createTelegramOutboundAdapter( shouldTreatDeliveredTextAsVisible: options.shouldTreatDeliveredTextAsVisible, targetsMatchForReplySuppression: options.targetsMatchForReplySuppression, preferFinalAssistantVisibleText: options.preferFinalAssistantVisibleText, - presentationCapabilities: { - supported: true, - buttons: true, - selects: true, - context: true, - divider: false, - limits: { - actions: { - maxActions: 100, - maxActionsPerRow: 3, - maxLabelLength: 64, - supportsStyles: false, - }, - selects: { - maxOptions: 100, - maxLabelLength: 64, - }, - text: { - markdownDialect: "markdown", - }, - }, - }, + presentationCapabilities: TELEGRAM_PRESENTATION_CAPABILITIES, deliveryCapabilities: { pin: true, durableFinal: { @@ -291,24 +270,8 @@ export function createTelegramOutboundAdapter( batch: true, }, }, - renderPresentation: ({ payload, presentation }) => { - const telegramData = payload.channelData?.telegram as Record | undefined; - const hasExplicitButtons = (telegramData && "buttons" in telegramData) || payload.interactive; - const buttons = hasExplicitButtons - ? undefined - : resolveTelegramInlineButtons({ presentation }); - return { - ...payload, - text: renderMessagePresentationFallbackText({ text: payload.text, presentation }), - channelData: { - ...payload.channelData, - telegram: { - ...telegramData, - ...(buttons ? { buttons } : {}), - }, - }, - }; - }, + renderPresentation: ({ payload, presentation }) => + canonicalizeTelegramPresentationPayload({ ...payload, presentation }), pinDeliveredMessage: async ({ cfg, target, messageId, pin, gatewayClientScopes }) => { const { pinMessageTelegram } = await loadSendModule(); const outboundTo = normalizeTelegramOutboundTarget(target.to); diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 0b3060a0ad76..32544185469d 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10494, + 10497, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5237, + 5238, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index 5340f19070ee..75abd38f6765 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -197,6 +197,16 @@ export type AgentRuntimeMessagePresentationChartBlock = yLabel?: string; }; +export type AgentRuntimeMessagePresentationTableCell = string | number; + +export type AgentRuntimeMessagePresentationTableBlock = { + type: "table"; + caption: string; + headers: string[]; + rows: AgentRuntimeMessagePresentationTableCell[][]; + rowHeaderColumnIndex?: number; +}; + /** Portable structured reply block rendered or downgraded by channels. */ export type AgentRuntimeMessagePresentationBlock = | { @@ -219,7 +229,8 @@ export type AgentRuntimeMessagePresentationBlock = placeholder?: string; options: AgentRuntimeMessagePresentationOption[]; } - | AgentRuntimeMessagePresentationChartBlock; + | AgentRuntimeMessagePresentationChartBlock + | AgentRuntimeMessagePresentationTableBlock; /** Portable structured reply presentation for channel adapters. */ export type AgentRuntimeMessagePresentation = { diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index ed2cc9695a88..bda946d51ce5 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1940,11 +1940,23 @@ describe("message tool schema scoping", () => { expect(presentationSchemaJson).toContain('"command"'); expect(presentationSchemaJson).toContain('"chartType"'); expect(presentationSchemaJson).toContain('"pie"'); + expect(presentationSchemaJson).toContain('"table"'); + expect(presentationSchemaJson).toContain('"caption"'); + expect(presentationSchemaJson).toContain('"headers"'); + expect(presentationSchemaJson).toContain('"rows"'); + expect(presentationSchemaJson).toContain('"rowHeaderColumnIndex"'); expect(presentationSchemaJson).not.toContain('"maxItems"'); expect(presentationSchemaJson).not.toContain('"maxLength"'); expect(presentationSchemaJson).not.toContain('"exclusiveMinimum"'); expect(presentationBlockItemSchema).toMatchObject({ type: "object" }); expect(presentationBlockItemSchema).not.toHaveProperty("anyOf"); + expect( + ( + presentationBlockItemSchema as { + properties?: { rows?: { items?: { items?: unknown } } }; + } + ).properties?.rows?.items?.items, + ).toEqual({ type: ["string", "number"] }); expect(properties.components).toBeUndefined(); expect(properties.blocks).toBeUndefined(); expect(properties.buttons).toBeUndefined(); @@ -2804,6 +2816,45 @@ describe("message tool reasoning tag sanitization", () => { ], }); }); + + it("sanitizes mixed-case table captions, headers, and string cells", async () => { + mockSendResult({ channel: "slack", to: "slack:C123" }); + + const call = await executeSend({ + action: { + target: "slack:C123", + presentation: { + blocks: [ + { + type: "Table", + caption: " caption rationalePipeline report ", + headers: [" header rationaleAccount ", " ARR "], + rows: [ + [" cell rationaleAcme ", 125000], + [" Globex ", 82000], + ], + rowHeaderColumnIndex: 0, + }, + ], + }, + }, + }); + + expect(call?.params?.presentation).toEqual({ + blocks: [ + { + type: "Table", + caption: "Pipeline report", + headers: ["Account", "ARR"], + rows: [ + ["Acme", 125000], + ["Globex", 82000], + ], + rowHeaderColumnIndex: 0, + }, + ], + }); + }); }); describe("message tool boot-echo guard", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 107af2555912..0b1b77b2bebb 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -4,6 +4,7 @@ * Sends, edits, reacts to, polls, and routes messages through channel plugins and Gateway-backed actions. */ import { + normalizeOptionalLowercaseString, normalizeOptionalString, normalizeOptionalStringifiedId, } from "@openclaw/normalization-core/string-coerce"; @@ -327,6 +328,38 @@ function sanitizePresentationTextFieldsResult( suppressionReason ??= sanitized.suppressionReason; } } + if (normalizeOptionalLowercaseString(sanitizedBlock.type) === "table") { + if (typeof sanitizedBlock.caption === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedBlock.caption, bootPrompt); + sanitizedBlock.caption = sanitized.text.trim(); + suppressionReason ??= sanitized.suppressionReason; + } + if (Array.isArray(sanitizedBlock.headers)) { + sanitizedBlock.headers = sanitizedBlock.headers.map((header) => { + if (typeof header !== "string") { + return header; + } + const sanitized = sanitizeUserVisibleToolTextResult(header, bootPrompt); + suppressionReason ??= sanitized.suppressionReason; + return sanitized.text.trim(); + }); + } + if (Array.isArray(sanitizedBlock.rows)) { + sanitizedBlock.rows = sanitizedBlock.rows.map((row) => { + if (!Array.isArray(row)) { + return row; + } + return row.map((cell) => { + if (typeof cell !== "string") { + return cell; + } + const sanitized = sanitizeUserVisibleToolTextResult(cell, bootPrompt); + suppressionReason ??= sanitized.suppressionReason; + return sanitized.text.trim(); + }); + }); + } + } if (Array.isArray(sanitizedBlock.buttons)) { sanitizedBlock.buttons = sanitizedBlock.buttons.map((button) => { if (!button || typeof button !== "object" || Array.isArray(button)) { @@ -529,7 +562,7 @@ const presentationChartSeriesSchema = Type.Object({ // Keep this flat: some provider tool-schema validators reject an anyOf nested // under presentation.blocks.items. Runtime normalization enforces block shapes. const presentationBlockSchema = Type.Object({ - type: stringEnum(["text", "context", "divider", "buttons", "select", "chart"]), + type: stringEnum(["text", "context", "divider", "buttons", "select", "chart", "table"]), text: Type.Optional(Type.String()), buttons: Type.Optional(Type.Array(presentationButtonSchema)), placeholder: Type.Optional(Type.String()), @@ -541,6 +574,15 @@ const presentationBlockSchema = Type.Object({ series: Type.Optional(Type.Array(presentationChartSeriesSchema, { minItems: 1 })), xLabel: Type.Optional(Type.String()), yLabel: Type.Optional(Type.String()), + caption: Type.Optional(Type.String()), + headers: Type.Optional(Type.Array(Type.String(), { minItems: 1 })), + rows: Type.Optional( + Type.Array( + Type.Array(Type.Unsafe({ type: ["string", "number"] }), { minItems: 1 }), + { minItems: 1 }, + ), + ), + rowHeaderColumnIndex: Type.Optional(Type.Integer({ minimum: 0 })), }); const presentationMessageSchema = Type.Object( @@ -551,7 +593,7 @@ const presentationMessageSchema = Type.Object( }, { description: - "Rich message payload: text, charts, buttons, selects, and context. Unsupported blocks degrade to text.", + "Rich message payload: text, charts, tables, buttons, selects, and context. Unsupported blocks degrade to text.", }, ); diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index e839b916f7f1..4f33bd997ef2 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -65,6 +65,8 @@ export type ChannelPresentationCapabilities = { divider?: boolean; /** Whether the channel can render chart blocks natively. */ charts?: boolean; + /** Whether the channel can render table blocks natively. */ + tables?: boolean; /** Per-channel limits used to adapt portable presentation blocks before rendering. */ limits?: { actions?: { diff --git a/src/channels/plugins/outbound/interactive.test.ts b/src/channels/plugins/outbound/interactive.test.ts index 1b011a812490..7dddcf83b2bd 100644 --- a/src/channels/plugins/outbound/interactive.test.ts +++ b/src/channels/plugins/outbound/interactive.test.ts @@ -825,4 +825,85 @@ describe("presentation capability limits", () => { }).blocks[0]?.type, ).toBe("text"); }); + + it("keeps tables only for channels that explicitly advertise native support", () => { + const table = { + type: "table" as const, + caption: "Pipeline report", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000], + ], + rowHeaderColumnIndex: 0, + }; + + expect( + adaptMessagePresentationForChannel({ + presentation: { blocks: [table] }, + capabilities: { tables: true }, + }).blocks, + ).toEqual([table]); + expect( + adaptMessagePresentationForChannel({ + presentation: { blocks: [table] }, + capabilities: { context: true }, + }).blocks, + ).toEqual([ + { + type: "context", + text: [ + "Pipeline report (table)", + "- Account: Acme; Stage: Won; ARR: 125000", + "- Account: Globex; Stage: Review; ARR: 82000", + ].join("\n"), + }, + ]); + expect( + adaptMessagePresentationForChannel({ + presentation: { blocks: [table] }, + capabilities: { context: false }, + }).blocks[0]?.type, + ).toBe("text"); + }); + + it("splits unsupported table fallback blocks without dropping tail rows", () => { + const presentation = adaptMessagePresentationForChannel({ + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline report", + headers: ["Account", "Stage"], + rows: [ + ["Acme", "Won"], + ["Globex", "Review"], + ["Initech", "Discovery"], + ], + }, + ], + }, + capabilities: { + tables: false, + context: true, + limits: { + text: { maxLength: 40, encoding: "characters" }, + }, + }, + }); + + expect(presentation.blocks.length).toBeGreaterThan(1); + expect( + presentation.blocks.every( + (block) => + (block.type === "text" || block.type === "context") && + Array.from(block.text).length <= 40, + ), + ).toBe(true); + const fallback = presentation.blocks + .map((block) => (block.type === "text" || block.type === "context" ? block.text : "")) + .join(""); + expect(fallback).toContain("- Account: Acme; Stage: Won"); + expect(fallback).toContain("- Account: Initech; Stage: Discovery"); + }); }); diff --git a/src/channels/plugins/outbound/presentation-limits.ts b/src/channels/plugins/outbound/presentation-limits.ts index 9321b126358b..43be429f4971 100644 --- a/src/channels/plugins/outbound/presentation-limits.ts +++ b/src/channels/plugins/outbound/presentation-limits.ts @@ -6,6 +6,7 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { renderMessagePresentationChartFallbackText, + renderMessagePresentationTableFallbackText, resolveMessagePresentationActionValue, } from "../../../interactive/payload.js"; import type { @@ -76,6 +77,40 @@ function truncatePresentationText(value: string, limits: TextLimits | undefined) return chars.length > limit ? chars.slice(0, limit).join("") : value; } +function splitPresentationText(value: string, limits: TextLimits | undefined): string[] { + const limit = positiveInteger(limits?.maxLength); + if (!limit || truncatePresentationText(value, limits) === value) { + return [value]; + } + const chunks: string[] = []; + let remaining = value; + while (remaining) { + const prefix = truncatePresentationText(remaining, limits); + if (!prefix || prefix === remaining) { + chunks.push(remaining); + break; + } + // Keep complete fallback lines together when possible. Retain the newline + // itself so concatenating the blocks reconstructs every authored character. + const newlineIndex = prefix.lastIndexOf("\n"); + const splitIndex = newlineIndex > 0 ? newlineIndex + 1 : prefix.length; + chunks.push(remaining.slice(0, splitIndex)); + remaining = remaining.slice(splitIndex); + } + return chunks; +} + +function fallbackTextBlocks(params: { + blockType: "context" | "text"; + text: string; + limits?: TextLimits; +}): MessagePresentationBlock[] { + return splitPresentationText(params.text, params.limits).map((text) => ({ + type: params.blockType, + text, + })); +} + function utf8ByteLength(value: string): number { return Buffer.byteLength(value, "utf8"); } @@ -486,10 +521,23 @@ export function adaptMessagePresentationForChannel(params: { const blocks: MessagePresentationBlock[] = []; for (const block of params.presentation.blocks) { if (block.type === "chart" && capabilities?.charts !== true) { - blocks.push({ - type: fallbackBlockType, - text: renderMessagePresentationChartFallbackText(block), - }); + blocks.push( + ...fallbackTextBlocks({ + blockType: fallbackBlockType, + text: renderMessagePresentationChartFallbackText(block), + limits: limits?.text, + }), + ); + continue; + } + if (block.type === "table" && capabilities?.tables !== true) { + blocks.push( + ...fallbackTextBlocks({ + blockType: fallbackBlockType, + text: renderMessagePresentationTableFallbackText(block), + limits: limits?.text, + }), + ); continue; } if (block.type === "buttons") { diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index 7918950ee743..cd7683f014ed 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -22,7 +22,7 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli ) .option( "--presentation ", - "Shared presentation payload as JSON (text, context, dividers, charts, buttons, selects)", + "Shared presentation payload as JSON (text, context, dividers, charts, tables, buttons, selects)", ) .option("--delivery ", "Shared delivery preferences as JSON") .option("--pin", "Request that the delivered message be pinned when supported", false) diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index 091122a69381..4e912ffd74e2 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -630,6 +630,59 @@ describe("OutboundPayloadPlan projections", () => { }); }); + it("mirrors table captions and cells when no plain reply text exists", () => { + const plan = createOutboundPayloadPlan([ + { + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline report", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme", "Won", 125000], + ["Globex", "Review", 82000], + ], + rowHeaderColumnIndex: 0, + }, + ], + }, + }, + ]); + + expect(projectOutboundPayloadPlanForMirror(plan)).toEqual({ + text: "Pipeline report (table)\n- Account: Acme; Stage: Won; ARR: 125000\n- Account: Globex; Stage: Review; ARR: 82000", + mediaUrls: [], + }); + }); + + it("mirrors table data alongside plain reply text", () => { + const plan = createOutboundPayloadPlan([ + { + text: "Quarterly pipeline", + presentation: { + blocks: [ + { + type: "table", + caption: "Pipeline report", + headers: ["Account", "ARR"], + rows: [ + ["Acme", 125000], + ["Globex", 82000], + ], + }, + { type: "context", text: "Internal presentation context" }, + ], + }, + }, + ]); + + expect(projectOutboundPayloadPlanForMirror(plan)).toEqual({ + text: "Quarterly pipeline\nPipeline report (table)\n- Account: Acme; ARR: 125000\n- Account: Globex; ARR: 82000", + mediaUrls: [], + }); + }); + it("keeps markdown images as text unless extraction is enabled", () => { const input = "Tech: ![Node.js](https://img.shields.io/badge/Node.js-339933)"; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index e691ca2101d6..5f936371606e 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -20,6 +20,7 @@ import { normalizeInteractiveReply, normalizeMessagePresentation, renderMessagePresentationChartFallbackText, + renderMessagePresentationTableFallbackText, type InteractiveReply, type MessagePresentation, type ReplyPayloadDelivery, @@ -103,6 +104,10 @@ function collectBlockMirrorText( lines.push(renderMessagePresentationChartFallbackText(block)); continue; } + if (block.type === "table") { + lines.push(renderMessagePresentationTableFallbackText(block)); + continue; + } if (block.type === "select") { if (block.placeholder?.trim()) { lines.push(block.placeholder.trim()); @@ -140,10 +145,12 @@ function resolveOutboundMirrorText(entry: OutboundPayloadPlan): string { const text = entry.parts.text.trim() ? entry.parts.text : entry.payload.text; const presentation = normalizeMessagePresentation(entry.payload.presentation); if (text?.trim()) { - const chartText = presentation - ? collectBlockMirrorText(presentation.blocks.filter((block) => block.type === "chart")) + const structuredDataText = presentation + ? collectBlockMirrorText( + presentation.blocks.filter((block) => block.type === "chart" || block.type === "table"), + ) : []; - return [text, ...chartText].join("\n"); + return [text, ...structuredDataText].join("\n"); } const interactive = normalizeInteractiveReply(entry.payload.interactive); return [ diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts index 1562dc63f9b4..6938a6fe8140 100644 --- a/src/interactive/payload.test.ts +++ b/src/interactive/payload.test.ts @@ -456,4 +456,102 @@ describe("interactive payload helpers", () => { ])("drops chart blocks with $name instead of changing their data", ({ block }) => { expect(normalizeMessagePresentation({ blocks: [block] })).toBeUndefined(); }); + + it("normalizes tables and renders deterministic linear fallback text", () => { + const presentation = normalizeMessagePresentation({ + blocks: [ + { + type: "table", + caption: " Pipeline report ", + headers: [" Account ", "Stage", "ARR"], + rows: [ + [" Acme\nCorp ", "Won", 125000], + ["Globex", "Review", 82000], + ], + rowHeaderColumnIndex: 0, + }, + ], + }); + + expect(presentation).toEqual({ + blocks: [ + { + type: "table", + caption: "Pipeline report", + headers: ["Account", "Stage", "ARR"], + rows: [ + ["Acme\nCorp", "Won", 125000], + ["Globex", "Review", 82000], + ], + rowHeaderColumnIndex: 0, + }, + ], + }); + const fallback = [ + "Pipeline report (table)", + "- Account: Acme Corp; Stage: Won; ARR: 125000", + "- Account: Globex; Stage: Review; ARR: 82000", + ].join("\n"); + expect(renderMessagePresentationFallbackText({ presentation })).toBe(fallback); + expect(presentationToInteractiveReply(presentation!)).toEqual({ + blocks: [{ type: "text", text: fallback }], + }); + }); + + it.each([ + { + name: "missing caption", + block: { type: "table", headers: ["Name"], rows: [["Acme"]] }, + }, + { + name: "empty headers", + block: { type: "table", caption: "Report", headers: [], rows: [["Acme"]] }, + }, + { + name: "duplicate headers", + block: { + type: "table", + caption: "Report", + headers: ["Name", "Name"], + rows: [["Acme", "Won"]], + }, + }, + { + name: "empty rows", + block: { type: "table", caption: "Report", headers: ["Name"], rows: [] }, + }, + { + name: "mismatched row width", + block: { + type: "table", + caption: "Report", + headers: ["Name", "Stage"], + rows: [["Acme"]], + }, + }, + { + name: "empty string cell", + block: { type: "table", caption: "Report", headers: ["Name"], rows: [[" "]] }, + }, + { + name: "non-finite numeric cell", + block: { type: "table", caption: "Report", headers: ["ARR"], rows: [[Infinity]] }, + }, + { + name: "non-scalar cell", + block: { type: "table", caption: "Report", headers: ["Name"], rows: [[true]] }, + }, + { + name: "out-of-range row header column", + block: { + type: "table", + caption: "Report", + headers: ["Name"], + rows: [["Acme"]], + rowHeaderColumnIndex: 1, + }, + }, + ])("drops table blocks with $name instead of repairing their data", ({ block }) => { + expect(normalizeMessagePresentation({ blocks: [block] })).toBeUndefined(); + }); }); diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 837bbab46f51..d57a4892a319 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -202,6 +202,22 @@ export type MessagePresentationChartBlock = yLabel?: string; }; +/** Scalar cell value supported by portable table presentations. */ +export type MessagePresentationTableCell = string | number; + +/** Portable table rendered natively where supported and linearly elsewhere. */ +export type MessagePresentationTableBlock = { + type: "table"; + /** Short table heading used by native renderers and fallback text. */ + caption: string; + /** Unique ordered column labels shared by every row. */ + headers: string[]; + /** Rows whose width exactly matches the header count. */ + rows: MessagePresentationTableCell[][]; + /** Optional column whose cells should be rendered as row headers. */ + rowHeaderColumnIndex?: number; +}; + export type MessagePresentationInteractiveBlock = | MessagePresentationButtonsBlock | MessagePresentationSelectBlock; @@ -212,7 +228,8 @@ export type MessagePresentationBlock = | MessagePresentationDividerBlock | MessagePresentationButtonsBlock | MessagePresentationSelectBlock - | MessagePresentationChartBlock; + | MessagePresentationChartBlock + | MessagePresentationTableBlock; export type MessagePresentation = { /** Optional short heading rendered before blocks when the channel supports it. */ @@ -447,6 +464,58 @@ function normalizeChartBlock( }; } +function normalizeTableBlock( + record: Record, +): MessagePresentationTableBlock | undefined { + const caption = normalizeOptionalString(record.caption); + if (!caption || !Array.isArray(record.headers) || record.headers.length === 0) { + return undefined; + } + const headers = record.headers.map((header) => normalizeOptionalString(header)); + if ( + !headers.every((header): header is string => Boolean(header)) || + new Set(headers).size !== headers.length || + !Array.isArray(record.rows) || + record.rows.length === 0 + ) { + return undefined; + } + const rows = record.rows.map((row) => { + if (!Array.isArray(row) || row.length !== headers.length) { + return undefined; + } + const cells = row.map((cell) => { + if (typeof cell === "number") { + return Number.isFinite(cell) ? cell : undefined; + } + return normalizeOptionalString(cell); + }); + return cells.every((cell): cell is MessagePresentationTableCell => cell !== undefined) + ? cells + : undefined; + }); + if (!rows.every((row): row is MessagePresentationTableCell[] => Boolean(row))) { + return undefined; + } + const rowHeaderColumnIndex = record.rowHeaderColumnIndex; + if ( + rowHeaderColumnIndex !== undefined && + (typeof rowHeaderColumnIndex !== "number" || + !Number.isInteger(rowHeaderColumnIndex) || + rowHeaderColumnIndex < 0 || + rowHeaderColumnIndex >= headers.length) + ) { + return undefined; + } + return { + type: "table", + caption, + headers, + rows, + ...(typeof rowHeaderColumnIndex === "number" ? { rowHeaderColumnIndex } : {}), + }; +} + /** * @deprecated Use normalizeMessagePresentation. */ @@ -489,6 +558,9 @@ function normalizePresentationBlock(raw: unknown): MessagePresentationBlock | un if (type === "chart") { return normalizeChartBlock(record); } + if (type === "table") { + return normalizeTableBlock(record); + } return undefined; } @@ -583,6 +655,10 @@ export function presentationToInteractiveReply( blocks.push({ type: "text", text: renderMessagePresentationChartFallbackText(block) }); continue; } + if (block.type === "table") { + blocks.push({ type: "text", text: renderMessagePresentationTableFallbackText(block) }); + continue; + } if (block.type === "select") { blocks.push({ type: "select", @@ -682,6 +758,26 @@ export function renderMessagePresentationChartFallbackText( return lines.join("\n"); } +function renderTableFallbackValue(value: MessagePresentationTableCell): string { + return String(value).replace(/\s+/g, " ").trim(); +} + +export function renderMessagePresentationTableFallbackText( + block: MessagePresentationTableBlock, +): string { + const headers = block.headers.map(renderTableFallbackValue); + const lines = [`${renderTableFallbackValue(block.caption)} (table)`]; + lines.push( + ...block.rows.map( + (row) => + `- ${row + .map((cell, index) => `${headers[index]}: ${renderTableFallbackValue(cell)}`) + .join("; ")}`, + ), + ); + return lines.join("\n"); +} + export function renderMessagePresentationFallbackText(params: { presentation?: MessagePresentation; emptyFallback?: string | null; @@ -730,6 +826,10 @@ export function renderMessagePresentationFallbackText(params: { lines.push(renderMessagePresentationChartFallbackText(block)); continue; } + if (block.type === "table") { + lines.push(renderMessagePresentationTableFallbackText(block)); + continue; + } if (block.type === "select") { const labels = block.options.map((option) => option.label).filter(Boolean); if (labels.length > 0) { diff --git a/src/plugin-sdk/interactive-runtime.ts b/src/plugin-sdk/interactive-runtime.ts index 27b9cbb8f4ab..4c72bf2a8b8c 100644 --- a/src/plugin-sdk/interactive-runtime.ts +++ b/src/plugin-sdk/interactive-runtime.ts @@ -29,6 +29,8 @@ export type { MessagePresentationInteractiveBlock, MessagePresentationOption, MessagePresentationSelectBlock, + MessagePresentationTableBlock, + MessagePresentationTableCell, MessagePresentationTextBlock, MessagePresentationTone, ReplyPayloadDelivery, @@ -47,6 +49,7 @@ export { presentationToInteractiveReply, renderMessagePresentationChartFallbackText, renderMessagePresentationFallbackText, + renderMessagePresentationTableFallbackText, resolveMessagePresentationActionValue, resolveMessagePresentationControlValue, resolveInteractiveTextFallback,