#466

chat: buttons repeated in one Actions share an id, and Slack rejects the card with invalid_blocks

Openjadibdevopened 4d ago

Version

@json-render/chat 0.22.0 (with chat 4.44.0 and @chat-adapter/slack 4.44.0)

Steps to reproduce

An Actions element with repeat renders one Button per item, but every copy keeps the literal id. toCard accepts the spec, and the Slack adapter turns Button.id into action_id, so one actions block ends up with duplicate action_ids.

import { toCard } from "@json-render/chat";
import { cardToBlockKit } from "@chat-adapter/slack";

const spec = {
  root: "card",
  state: {
    requests: [
      { id: "req-1", name: "Ada" },
      { id: "req-2", name: "Grace" },
      { id: "req-3", name: "Linus" },
    ],
  },
  elements: {
    card: { type: "Card", props: { title: "Access requests" }, children: ["actions"] },
    actions: { type: "Actions", props: {}, repeat: { statePath: "/requests" }, children: ["approve"] },
    approve: {
      type: "Button",
      props: { id: "approve", label: { $item: "name" }, value: { $item: "id" } },
      children: [],
    },
  },
};

const blocks = cardToBlockKit(toCard(spec)); // no CardSpecError

await fetch("https://slack.com/api/chat.postMessage", {
  method: "POST",
  headers: {
    "Content-Type": "application/json; charset=utf-8",
    Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
  },
  body: JSON.stringify({ channel: process.env.SLACK_CHANNEL_ID, text: "Access requests", blocks }),
}).then((r) => r.json());

This is the shape the chat prompt invites: the catalog teaches repeat and $item, and the default rules suggest Actions for next steps.

Expected behavior

toCard reports the duplicate as a CardSpecError issue, like the other structural checks in validateStructure, so the model can correct the spec. For example: actions: button id "approve" is used 3 times in one Actions. toPartialCard should not hand streamCard a partial card that Slack will reject.

Possible fix

These are suggestions. I've kept the ownership choices for you.

  1. Validate per Actions element in validateStructure, after repeat and props are resolved. Per block is enough: in the same workspace, three separate actions blocks that each hold one action_id: "approve" posted fine. Only duplicates inside one block are rejected. #457 adds Select and RadioSelect to Actions, so the check could cover any child that sets an id.
  2. Drop later duplicates in toPartialCard, the same way prune already drops children a group can't hold (041de53). Streamed edits then stay valid, and the final toCard still reports the problem.
  3. Add a default rule that points at the shape that works, not at per-item IDs. onAction("approve") matches the action ID exactly, so a templated ID like approve-req-1 would post but never reach that handler. The repeated-Section shape keeps routing intact. For example: "Button ids must be unique within an Actions. For one button per item, repeat a Section that contains the Actions, keep a fixed id and put the item's id in value."

For context, the other adapters already carry value alongside a shared id: Teams puts both in Action.Submit.data, and Discord encodes custom_id as ${id}\n${value}. If you'd rather keep shared IDs inside one row, encoding in @chat-adapter/slack would be the other place to fix this.

Happy to send a PR if that's useful.

Actual behavior

toCard returns the card without an error, and the generated actions block holds three buttons with action_id: "approve" (values req-1, req-2, req-3). Slack rejects the whole message:

{
  "ok": false,
  "error": "invalid_blocks",
  "response_metadata": {
    "messages": [
      "[ERROR] `action_id` \"approve\" already exists [json-pointer:/blocks/1/elements/1/action_id]",
      "[ERROR] `action_id` \"approve\" already exists [json-pointer:/blocks/1/elements/2/action_id]"
    ]
  }
}

Slack's button reference says an action_id "should be unique among all other action_ids in the containing block," and the API enforces it.

Streaming: chat.update with the same blocks returns the same invalid_blocks error. toPartialCard doesn't run validateStructure, so streamCard posts the card while it has one button, then fails on the edit that adds the second. That happens before the final toCard could raise a CardSpecError, and it leaves a partial card in the thread.

LinkButton: a repeated LinkButton with the same URL fails the same way. The adapter falls back to action_id: "link-${url}", and Slack returns action_id "link-https://example.com/requests" already exists. The catalog's LinkButton has no id prop, so the model can't fix this one by itself. Passing an optional id through to linkButton() (the LinkButtonElement type already accepts one) would give validation something to ask for.

Environment

Node.js 22.14.0 on Windows 11. Tested against the Slack Web API in a free workspace, with a bot token that has only chat:write. A control payload with unique action_ids posted fine in the same run.

Activity

0

No activity yet.