diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index ce35299..acd2044 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -646,6 +646,7 @@ stay explicit instead of being rediscovered in code review. | `@chat-adapter/tests` adapter test kit (vercel/chat#470) | Not ported | New TS package with test utilities for adapter authors | Python already ships `chat_sdk.testing` (`MockAdapter`, `MockStateAdapter`, `create_test_message()`) covering the same surface for this repo's adapter tests; mirroring the TS kit verbatim would duplicate it. Revisit if upstream's kit grows capabilities ours lacks (e.g. recorded replay fixtures for third-party adapter authors). | | Teams modal-submit webhook options (vercel/chat#454 adapter-teams slice) | Not ported — the Python Teams adapter has no task-module/modal-submit flow (`handleTaskSubmit`/`processModalSubmit` are absent), so upstream's change passing `bridgeAdapter.getWebhookOptions(activity.id)` into `processModalSubmit` has no landing site | `TeamsAdapter.handleTaskSubmit` forwards webhook options so modal callbackUrl POSTs are registered with `waitUntil` | Pre-existing gap: Teams modals are unported. The Slack adapter already forwards options to `process_modal_submit`, so the new waitUntil plumbing is exercised there. Add the Teams call when Teams modal support lands. | | jsx-runtime `callbackUrl` props (vercel/chat#454 slice) | Not ported | `ButtonProps`/`ModalProps` gain `callbackUrl`; `resolveJSXElement` forwards it | Covered by the existing "JSX Card/Modal elements" row — Python has no JSX runtime; `Button()`/`Modal()` builders accept `callback_url` directly. | +| jsx-runtime `id` prop for link buttons (stable-id-for-link-buttons, chat@4.31.0 commit `171657a`) | Not ported | `LinkButtonProps` gains `id?`; `resolveJSXElement` forwards `id: props.id` | Covered by the existing "JSX Card/Modal elements" row — Python has no JSX runtime. The core half of the same commit (`LinkButton()` factory + `LinkButtonElement` `id`) **is** ported: the `LinkButton(id_=…)` builder accepts the optional stable identifier directly. | | Transcripts API Python adaptations (vercel/chat#448) | `transcripts.delete()` returns a `DeleteResult` dataclass; misconfiguration raises `ValueError` (constructor/`AppendInput` guards, invalid duration) or `ChatError` (`chat.transcripts` accessor); guard messages name the Python kwarg (`options.user_key`); `DurationString` is a `str` alias validated at runtime by `_parse_duration` | Inline `{ deleted: number }`; generic `Error` for all of the above; template-literal `` `${number}${"s"\|"m"\|"h"\|"d"}` `` type | Port rules: typed dataclasses over raw dicts; repo error-type conventions (constructor misconfig → `ValueError`, runtime API misuse → `ChatError`) with upstream-matching message wording; Python has no template-literal types. Same shapes and values throughout. | | Slack legacy mrkdwn renderer (response_url surface only, post-#440) | `_node_to_mrkdwn` renders headings as `*bold*` and images as `{alt} ({url})` / bare URL | TS `nodeToMrkdwn` has no heading/image branches — both fall through to `defaultNodeToText`, dropping heading emphasis and image URLs | Pre-existing Python improvement; after vercel/chat#440 it affects only `to_response_url_text` (ephemeral edits via response_url). Preserves visual hierarchy and image URLs Slack would otherwise lose. | | Slack `api` primitives `send_slack_response_url` URL gate (vercel/chat#548) | `send_slack_response_url` (`slack/api/__init__.py`) calls `_assert_slack_response_url(url)` before POSTing — requires an `https://*.slack.com` URL (where Slack-issued `response_url`s always live) and raises `ValueError` for anything else | Upstream `api/client.ts` `sendResponseUrl` POSTs to whatever `response_url` it is handed, with no scheme/host validation | SSRF guard. The `response_url` reaching this primitive can originate from a parsed-but-unverified interaction payload; without a gate a crafted value could redirect the POST (which carries no bearer token but does echo SDK-controlled message content and trigger an arbitrary outbound request) to an attacker host. Enforces `CLAUDE.md`'s "Validate external URLs before requests (SSRF)" rule, mirroring the high-level adapter's `rehydrate_attachment` allowlist row above. Allowlist: scheme `https`, host `slack.com` or `*.slack.com`. | diff --git a/src/chat_sdk/cards.py b/src/chat_sdk/cards.py index 1939b91..22c70e8 100644 --- a/src/chat_sdk/cards.py +++ b/src/chat_sdk/cards.py @@ -44,6 +44,8 @@ class _LinkButtonRequired(TypedDict): class LinkButtonElement(_LinkButtonRequired, total=False): """Link button element that opens a URL.""" + # Optional action identifier emitted by platforms that report link clicks + id: str style: ButtonStyle @@ -288,14 +290,22 @@ def LinkButton( url: str, label: str, style: ButtonStyle | None = None, + id_: str | None = None, ) -> LinkButtonElement: """Create a LinkButton element that opens a URL when clicked. Example:: LinkButton(url="https://example.com", label="View Docs") + + ``id_`` is an optional action identifier emitted by platforms that report + link clicks. Upstream sets ``id`` unconditionally and relies on + ``JSON.stringify`` dropping ``undefined``; in Python we only write the + key when it is provided so an unset id never serializes as ``null``. """ element: LinkButtonElement = {"type": "link-button", "url": url, "label": label} + if id_ is not None: + element["id"] = id_ if style is not None: element["style"] = style return element diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index d329c96..db50388 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -242,6 +242,7 @@ class Author: full_name: str is_bot: bool | Literal["unknown"] + # Whether this message was sent by this bot/runtime is_me: bool user_id: str user_name: str diff --git a/tests/test_cards.py b/tests/test_cards.py index 2ba7c82..5d7e042 100644 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -2,8 +2,11 @@ from __future__ import annotations +import json + from chat_sdk.cards import ( CardElement, + LinkButton, card_child_to_fallback_text, is_card_element, table_element_to_ascii, @@ -208,3 +211,37 @@ def test_empty_rows(self): # No data rows — only header + separator. lines = render_gfm_table(["only"], []) assert lines == ["| only |", "| --- |"] + + +class TestLinkButtonId: + """Regression tests for the optional stable LinkButton ``id`` field. + + Port of upstream stable-id-for-link-buttons (chat@4.31.0, commit 171657a). + cards.test.ts is byte-identical 4.30->4.31, so upstream ships no test for + this; these are Python-only regressions that pin our emit/parse behavior. + Upstream sets ``id: options.id`` unconditionally and lets ``JSON.stringify`` + drop ``undefined`` — Python must only write the key when ``id_`` is given. + """ + + def test_id_written_when_provided(self): + btn = LinkButton(url="https://example.com/docs", label="Docs", id_="open-docs") + assert btn["id"] == "open-docs" + + def test_no_id_key_when_omitted(self): + # Emit/parse symmetry guard: an unset id must NOT serialize as a key + # (no literal None/null), so old persisted cards round-trip unchanged. + btn = LinkButton(url="https://example.com/docs", label="Docs") + assert "id" not in btn + + def test_empty_string_id_is_emitted(self): + # Explicit empty string is distinct from unset and must survive + # (this is exactly why we use ``is not None`` and not ``id_ or ...``). + btn = LinkButton(url="https://example.com/docs", label="Docs", id_="") + assert "id" in btn + assert btn["id"] == "" + + def test_id_survives_wire_serialization(self): + btn = LinkButton(url="https://example.com/docs", label="Docs", id_="open-docs") + round_tripped = json.loads(json.dumps(btn)) + assert round_tripped["id"] == "open-docs" + assert round_tripped["type"] == "link-button"