Skip to content

feat(cards): add shared adaptive card rendering for all channels#41565

Open
VikrantSingh01 wants to merge 5 commits intoopenclaw:mainfrom
VikrantSingh01:feat/adaptive-cards-channel-rendering
Open

feat(cards): add shared adaptive card rendering for all channels#41565
VikrantSingh01 wants to merge 5 commits intoopenclaw:mainfrom
VikrantSingh01:feat/adaptive-cards-channel-rendering

Conversation

@VikrantSingh01
Copy link
Copy Markdown

@VikrantSingh01 VikrantSingh01 commented Mar 10, 2026

Summary

Adds a shared card rendering layer that enables any channel to consume Adaptive Cards natively. Works with any plugin that emits <!--adaptive-card--> markers in tool result text, including the standalone @vikrantsingh01/openclaw-adaptive-cards plugin (v4.0.0).

  • Shared parser: Extracts card JSON, fallback text, and template data from <!--adaptive-card--> markers embedded in tool result text
  • Per-channel strategies: Telegram (HTML + inline keyboards), Slack (Block Kit), Discord (embeds + button components), Teams/Webex (native AC attachment pass-through)
  • Outbound adapter wiring: Each channel's outbound adapter detects markers and renders natively before sending — no gateway or hook system changes needed
  • Unit tests covering parser + all strategy edge cases

Note: After rebase on main, the outbound adapters now live in extensions/*/src/outbound-adapter.ts (previously src/channels/plugins/outbound/). Card rendering logic has been applied to the new extension locations with inline parsing (no cross-workspace imports).

Channel Rendering Matrix

Channel Strategy AC Elements Supported Actions
Teams / Webex Native pass-through All (AC v1.6) All native
Slack Block Kit translation TextBlock, FactSet, Image, ColumnSet, Container OpenUrl (button), Submit (action button)
Telegram HTML + inline keyboard TextBlock, FactSet, ColumnSet, Container OpenUrl (URL button), Submit (callback button)
Discord Embeds + components TextBlock, FactSet, Image, ColumnSet, Container OpenUrl (link button), Submit (primary button)
Other channels Fallback text only N/A — markers stripped, plain text shown N/A

Architecture

Tool result text (with <!--adaptive-card--> markers)
     |
     v
Channel outbound adapter
     |
     +-- parseAdaptiveCardMarkers(text)
     |       -> { card, fallbackText, templateData } | null
     |
     +-- If card found -> try { render(parsed) } catch -> fallback
     |       -> channel-native format (blocks/embeds/keyboard/attachment)
     |
     +-- If no card -> send text as normal

Each strategy is a pure function (ParsedAdaptiveCard -> CardRenderResult) with no side effects, making them trivially testable and independently deployable.

Production Guardrails

  • Empty URL guard: Action.OpenUrl with missing URL skipped in all strategies (prevents Discord/Slack/Telegram 400 errors)
  • Telegram: callback_data truncated by UTF-8 byte length (64B limit), not character count
  • Slack: FactSets split into chunks of 10 fields per section block; mrkdwn text capped at 3,000 chars
  • Discord: embed title 256 chars, description 4,096 chars, 25 fields max, field name/value 1,024 chars
  • All adapters: render wrapped in try/catch — strategy errors fall through to fallback text
  • All adapters: markers stripped from text when rendering fails or produces empty output (no raw JSON leak)

Ecosystem Context

Package Version Role
adaptive-cards-mcp v2.3.0 Shared core — v1.6 schema validation, 7 host profiles, WCAG a11y scoring, 21 patterns, 924 tests
openclaw-adaptive-cards v4.0.0 Plugin — adaptive_card tool, MCP bridge, marker transport, prompt guidance, action routing
This PR Channel-side rendering — parse markers, render natively per channel

Related PRs

Test Plan

  • pnpm check passes (lint + format)
  • Parser tests pass
  • Strategy tests pass
  • Existing outbound adapter tests pass
  • Telegram: /acard test renders as HTML with inline keyboard
  • Slack: /acard test renders as Block Kit with button
  • Discord: /acard test renders as embed with button component
  • Teams: /acard test renders as native Adaptive Card

@openclaw-barnacle openclaw-barnacle bot added channel: msteams Channel integration: msteams size: XL labels Mar 10, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR introduces a well-structured shared adaptive card rendering layer (src/cards/) that translates Adaptive Cards to native formats for Telegram, Slack, Discord, and Teams/Webex, wiring each channel's sendText path to detect and render card markers before falling back to plain text. The architecture is clean — pure render functions with no side effects, a clear discriminated-union result type, and 10 unit tests for the marker parser.

Issues found:

  • [Logic] Slack outbound leaks raw card JSON when blocks are empty (src/channels/plugins/outbound/slack.ts): When a card is present but all its body elements are unsupported types (e.g. Input.Text, ActionSet), rendered.blocks.length is 0, the guard fails, and the code falls through to sendSlackOutboundMessage with the original text — including the raw <!--adaptive-card--> HTML comment and JSON blob — exposed to users. The fallback text (rendered.fallback) should be substituted instead.

  • [Logic] Action.OpenUrl with a missing url causes a 400 API error (src/cards/strategies/discord.ts, src/cards/strategies/slack.ts, src/cards/strategies/telegram.ts): str(action.url) returns "" when url is absent. Discord (link button style 5), Slack (link button), and Telegram (URL inline button) all hard-reject an empty URL, causing the entire message delivery to fail. A continue guard for empty URLs is needed in all three strategies.

  • [Style] Telegram callback_data byte limit not respected (src/cards/strategies/telegram.ts): The comment notes a 64-byte limit but .slice(0, 64) truncates by character count. Multi-byte characters (emoji, CJK) can still push the payload over 64 bytes and trigger a Telegram 400 error.

  • [Style] Slack renderFactSet can exceed the 10-field API limit (src/cards/strategies/slack.ts): Slack's Block Kit limits a section block to 10 fields; FactSets with more will be rejected. The output should be capped or split.

Confidence Score: 2/5

  • Not safe to merge — two logic bugs can cause silent message delivery failures or expose raw card JSON to end users.
  • The Slack fallthrough bug will expose raw <!--adaptive-card--> markers and JSON to users for any card using unsupported element types. The empty-URL bug will cause a hard 400 API failure on Discord, Slack, and Telegram when an Action.OpenUrl action has no URL set — a scenario that is easy to hit since URL validation isn't enforced by the AC schema. Both are correctness issues that affect production message delivery. The parser, native strategy, and Teams wiring are solid.
  • src/channels/plugins/outbound/slack.ts (fallthrough with raw markers), src/cards/strategies/discord.ts, src/cards/strategies/slack.ts, and src/cards/strategies/telegram.ts (empty-URL guard missing in all three).

Comments Outside Diff (2)

  1. src/channels/plugins/outbound/slack.ts, line 116-127 (link)

    Raw card markers leaked to Slack when blocks are empty

    When parseAdaptiveCardMarkers returns a non-null result (markers are present) but rendered.blocks.length === 0 — which happens when every card body element uses an unsupported type such as Input.Text, Input.ChoiceSet, or ActionSet — the if guard falls through and sendSlackOutboundMessage is called with the original text value on line 126. That text still contains the raw <!--adaptive-card-->…<!--/adaptive-card--> HTML comment and the full JSON blob, so Slack users see all of that verbatim.

    The fix is to replace the raw text with the parsed fallback whenever markers were detected but no blocks were produced:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/channels/plugins/outbound/slack.ts
    Line: 116-127
    
    Comment:
    **Raw card markers leaked to Slack when blocks are empty**
    
    When `parseAdaptiveCardMarkers` returns a non-null result (markers are present) but `rendered.blocks.length === 0` — which happens when every card body element uses an unsupported type such as `Input.Text`, `Input.ChoiceSet`, or `ActionSet` — the `if` guard falls through and `sendSlackOutboundMessage` is called with the original `text` value on line 126. That text still contains the raw `<!--adaptive-card-->…<!--/adaptive-card-->` HTML comment and the full JSON blob, so Slack users see all of that verbatim.
    
    The fix is to replace the raw `text` with the parsed fallback whenever markers were detected but no blocks were produced:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/cards/strategies/slack.ts, line 769-779 (link)

    Slack section fields array exceeds API limit for large FactSets

    Slack's Block Kit API limits a section block to 10 fields. If a FactSet has more than 10 facts the rendered block will be rejected with a 400 error. Consider either splitting oversized FactSets into multiple section blocks or capping at 10 and appending remaining facts as text:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/cards/strategies/slack.ts
    Line: 769-779
    
    Comment:
    **Slack section `fields` array exceeds API limit for large FactSets**
    
    Slack's Block Kit API limits a `section` block to **10 fields**. If a `FactSet` has more than 10 facts the rendered block will be rejected with a 400 error. Consider either splitting oversized FactSets into multiple section blocks or capping at 10 and appending remaining facts as text:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 58e1f34

Comment on lines +85 to +93
if (!label) {
continue;
}
if (action.type === "Action.OpenUrl") {
buttons.push({ text: label, url: str(action.url) });
} else if (action.type === "Action.Submit") {
// Encode action data as callback_data (Telegram limit: 64 bytes)
const data = action.data != null ? JSON.stringify(action.data) : label;
buttons.push({ text: label, callback_data: data.slice(0, 64) });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callback_data truncated by character count, not byte count

The comment correctly states the Telegram limit is 64 bytes, but .slice(0, 64) counts UTF-16 code units (characters), not bytes. A JSON payload containing multi-byte characters (e.g. Chinese, emoji) can exceed 64 bytes even after slicing to 64 characters, which will cause Telegram's Bot API to return a 400 error (Bad Request: callback_data is too long).

Use a TextEncoder (or a simple UTF-8 byte-length check) to enforce the byte limit:

Suggested change
if (!label) {
continue;
}
if (action.type === "Action.OpenUrl") {
buttons.push({ text: label, url: str(action.url) });
} else if (action.type === "Action.Submit") {
// Encode action data as callback_data (Telegram limit: 64 bytes)
const data = action.data != null ? JSON.stringify(action.data) : label;
buttons.push({ text: label, callback_data: data.slice(0, 64) });
} else if (action.type === "Action.Submit") {
// Encode action data as callback_data (Telegram limit: 64 bytes, not chars)
const raw = action.data != null ? JSON.stringify(action.data) : label;
// Truncate by encoded byte length, not character count
const encoder = new TextEncoder();
let truncated = raw;
while (encoder.encode(truncated).byteLength > 64) {
truncated = truncated.slice(0, -1);
}
buttons.push({ text: label, callback_data: truncated });
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cards/strategies/telegram.ts
Line: 85-93

Comment:
**`callback_data` truncated by character count, not byte count**

The comment correctly states the Telegram limit is **64 bytes**, but `.slice(0, 64)` counts UTF-16 code units (characters), not bytes. A JSON payload containing multi-byte characters (e.g. Chinese, emoji) can exceed 64 bytes even after slicing to 64 characters, which will cause Telegram's Bot API to return a 400 error (`Bad Request: callback_data is too long`).

Use a `TextEncoder` (or a simple UTF-8 byte-length check) to enforce the byte limit:

```suggestion
    } else if (action.type === "Action.Submit") {
      // Encode action data as callback_data (Telegram limit: 64 bytes, not chars)
      const raw = action.data != null ? JSON.stringify(action.data) : label;
      // Truncate by encoded byte length, not character count
      const encoder = new TextEncoder();
      let truncated = raw;
      while (encoder.encode(truncated).byteLength > 64) {
        truncated = truncated.slice(0, -1);
      }
      buttons.push({ text: label, callback_data: truncated });
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 226b991. Now truncates by UTF-8 byte length using TextEncoder instead of character count. The loop slices one character at a time until encoder.encode(truncated).byteLength <= 64.

@VikrantSingh01
Copy link
Copy Markdown
Author

All 4 issues from the Greptile review addressed in 226b991:

1. Empty URL guard (Logic — all 3 strategies)
Added continue guard for empty URLs in Action.OpenUrl handling across Discord, Slack, and Telegram strategies. Buttons with missing/empty URLs are now silently skipped instead of causing 400 API errors.

2. Telegram callback_data byte limit (Style)
Replaced .slice(0, 64) (character count) with a TextEncoder-based loop that truncates by UTF-8 byte length. Multi-byte characters (emoji, CJK) no longer push the payload over 64 bytes.

3. Slack raw marker leak (Logic)
When parseAdaptiveCardMarkers returns non-null but blocks are empty (all unsupported element types), the outbound adapter now substitutes parsed.fallbackText instead of falling through with the raw marker-containing text. Same fix applied to Telegram, Discord, and MSTeams outbound adapters for consistency.

4. Slack FactSet 10-field limit (Style)
renderFactSet now splits large FactSets into chunks of 10 fields per section block, respecting Slack's Block Kit API limit.

@VikrantSingh01 VikrantSingh01 force-pushed the feat/adaptive-cards-channel-rendering branch from d7777ae to 09f3ace Compare March 10, 2026 04:45
VikrantSingh01 and others added 4 commits March 18, 2026 18:38
…uncation, marker leak prevention

- Skip Action.OpenUrl with empty URL in all 3 strategies (Discord, Slack,
  Telegram) to prevent 400 API errors
- Telegram: truncate callback_data by UTF-8 byte length (not char count)
  to respect the 64-byte Telegram limit with multi-byte characters
- Slack: split FactSets with >10 facts into multiple section blocks
  to respect Slack's 10-field-per-section API limit
- All outbound adapters: strip card markers from text when rendering
  fails or produces empty output, preventing raw JSON marker leak
  to end users on Slack, Telegram, Discord, and Teams

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…y tests

- Wrap strategy.render() in try/catch in all 4 outbound adapters to
  prevent message delivery crash on malformed card data
- Discord: enforce API limits (title 256 chars, description 4096 chars,
  25 fields max, field name/value 1024 chars)
- Slack: truncate mrkdwn text to 3000 chars per section block
- Add 12 strategy tests covering Telegram, Slack, Discord, and native
  strategies (empty URL guard, byte-limit truncation, FactSet splitting,
  Discord field caps, native pass-through)
- Total test count: 22 (10 parser + 12 strategy)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
BradGroux

This comment was marked as duplicate.

Copy link
Copy Markdown
Contributor

@BradGroux BradGroux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Shared Channel Rendering Infrastructure (Core Engine)

This is the foundation PR that all platform renderers depend on. 1,843 lines across 13 files covering the parser, strategy pattern, and per-channel rendering (Discord embeds+buttons, Slack Block Kit, Telegram HTML+inline keyboard, Teams native pass-through). Includes 35 tests. The architecture is solid and the strategy pattern is the right call for channel isolation.

Blocker

1. Discord action rows can exceed the 5-button API limit

src/cards/strategies/discord.ts (and mirrored in extensions/discord/src/outbound-adapter.ts) builds a single action row containing all card actions with no cap:

function buildActionRow(actions: unknown[]): unknown {
  const buttons: DiscordButton[] = [];
  // ... pushes all actions into one row
  return { type: 1, components: buttons };
}

Discord enforces a hard limit of 5 buttons per action row. If a card has more than 5 actions (including combined top-level + ActionSet actions), Discord will reject the entire payload and the message send fails silently. This needs to chunk actions into multiple rows of 5 or truncate with a warning.

Suggestions

2. Marker stripping inconsistency leaks meta payload in extension adapters

The core parser (src/cards/parse.ts) strips all three marker types: adaptive-card, adaptive-card-data, and adaptive-card-meta. However, each extension adapter defines its own AC_MARKERS_RE regex that only strips adaptive-card and adaptive-card-data, omitting adaptive-card-meta:

// In extensions/discord/src/outbound-adapter.ts (and slack, telegram, msteams):
const AC_MARKERS_RE = /<!--adaptive-card-->...|<!--adaptive-card-data-->.../g;

On fallback paths where the extension strips markers but does not use the core parser, raw <!--adaptive-card-meta--> JSON comments will leak into user-visible chat text. The extension regex should match all three markers, or better yet, import the regex from the core module to avoid drift.

3. Parser does not validate card.body is an array before returning

src/cards/parse.ts checks card.type === "AdaptiveCard" but never verifies Array.isArray(card.body):

if (card?.type !== "AdaptiveCard") {
  return null;
}
// ... returns { card, fallbackText, ... } without body validation

All downstream strategies iterate over parsed.card.body. A malformed card with body: "string" or body: null will throw at render time unless every strategy wraps iteration in try/catch. Adding a guard here would be defensive and cheap.

Architecture Notes (not blocking)

  • The strategy pattern with per-channel isolated renderers is clean. Each strategy wraps render in try/catch with text fallback, which is the right safety net.
  • The str() helper function is duplicated across multiple strategy files. Consider extracting to a shared utility, though this is a nit for a follow-up PR.
  • The marker protocol (<!--adaptive-card-->JSON<!--/adaptive-card--> + data + meta) is well-designed and extensible.

Summary

Strong foundation. Fix the Discord 5-button limit (this will cause real failures in production) and align the marker regex across core and extensions to prevent meta marker leakage. The body validation guard is cheap insurance against malformed payloads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord channel: msteams Channel integration: msteams channel: slack Channel integration: slack channel: telegram Channel integration: telegram size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants