Skip to content

Commit 36495eb

Browse files
feat(core): stable LinkButton id (chat@4.31 171657a) (#153)
Port the core half of upstream stable-id-for-link-buttons (chat@4.31.0, commit 171657a). Adds an optional stable identifier to link buttons that platforms reporting link clicks can emit back. - cards.py: add optional `id` to the `LinkButtonElement` TypedDict and a keyword-only `id_: str | None = None` param to the `LinkButton()` factory. Upstream sets `id: options.id` unconditionally and relies on JSON.stringify dropping `undefined`; Python writes the `id` key ONLY when `id_ is not None` so an unset id never serializes as a literal null (and an explicit empty string is preserved — no `id_ or ...`). The wire key stays the lowercase string `id` (builtin-shadowing avoided by naming the param `id_`); this is a new optional outbound key, so old persisted cards deserialize unchanged (no migration). - types.py: sync the `Author.is_me` field comment to upstream wording ("Whether this message was sent by this bot/runtime"). - jsx-runtime half (`LinkButtonProps.id` / `resolveJSXElement`) is a documented non-port — Python has no JSX runtime; noted in docs/UPSTREAM_SYNC.md. Tests (cards.test.ts is byte-identical 4.30->4.31, so these are Python-only regressions): id written when provided; NO id key when omitted (emit/parse symmetry); empty string emitted (explicit empty distinct from unset); id survives wire serialization.
1 parent 34314dd commit 36495eb

4 files changed

Lines changed: 49 additions & 0 deletions

File tree

docs/UPSTREAM_SYNC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ stay explicit instead of being rediscovered in code review.
646646
| `@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). |
647647
| 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. |
648648
| 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. |
649+
| 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. |
649650
| 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. |
650651
| 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. |
651652
| 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`. |

src/chat_sdk/cards.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class _LinkButtonRequired(TypedDict):
4444
class LinkButtonElement(_LinkButtonRequired, total=False):
4545
"""Link button element that opens a URL."""
4646

47+
# Optional action identifier emitted by platforms that report link clicks
48+
id: str
4749
style: ButtonStyle
4850

4951

@@ -288,14 +290,22 @@ def LinkButton(
288290
url: str,
289291
label: str,
290292
style: ButtonStyle | None = None,
293+
id_: str | None = None,
291294
) -> LinkButtonElement:
292295
"""Create a LinkButton element that opens a URL when clicked.
293296
294297
Example::
295298
296299
LinkButton(url="https://example.com", label="View Docs")
300+
301+
``id_`` is an optional action identifier emitted by platforms that report
302+
link clicks. Upstream sets ``id`` unconditionally and relies on
303+
``JSON.stringify`` dropping ``undefined``; in Python we only write the
304+
key when it is provided so an unset id never serializes as ``null``.
297305
"""
298306
element: LinkButtonElement = {"type": "link-button", "url": url, "label": label}
307+
if id_ is not None:
308+
element["id"] = id_
299309
if style is not None:
300310
element["style"] = style
301311
return element

src/chat_sdk/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ class Author:
242242

243243
full_name: str
244244
is_bot: bool | Literal["unknown"]
245+
# Whether this message was sent by this bot/runtime
245246
is_me: bool
246247
user_id: str
247248
user_name: str

tests/test_cards.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from __future__ import annotations
44

5+
import json
6+
57
from chat_sdk.cards import (
68
CardElement,
9+
LinkButton,
710
card_child_to_fallback_text,
811
is_card_element,
912
table_element_to_ascii,
@@ -208,3 +211,37 @@ def test_empty_rows(self):
208211
# No data rows — only header + separator.
209212
lines = render_gfm_table(["only"], [])
210213
assert lines == ["| only |", "| --- |"]
214+
215+
216+
class TestLinkButtonId:
217+
"""Regression tests for the optional stable LinkButton ``id`` field.
218+
219+
Port of upstream stable-id-for-link-buttons (chat@4.31.0, commit 171657a).
220+
cards.test.ts is byte-identical 4.30->4.31, so upstream ships no test for
221+
this; these are Python-only regressions that pin our emit/parse behavior.
222+
Upstream sets ``id: options.id`` unconditionally and lets ``JSON.stringify``
223+
drop ``undefined`` — Python must only write the key when ``id_`` is given.
224+
"""
225+
226+
def test_id_written_when_provided(self):
227+
btn = LinkButton(url="https://example.com/docs", label="Docs", id_="open-docs")
228+
assert btn["id"] == "open-docs"
229+
230+
def test_no_id_key_when_omitted(self):
231+
# Emit/parse symmetry guard: an unset id must NOT serialize as a key
232+
# (no literal None/null), so old persisted cards round-trip unchanged.
233+
btn = LinkButton(url="https://example.com/docs", label="Docs")
234+
assert "id" not in btn
235+
236+
def test_empty_string_id_is_emitted(self):
237+
# Explicit empty string is distinct from unset and must survive
238+
# (this is exactly why we use ``is not None`` and not ``id_ or ...``).
239+
btn = LinkButton(url="https://example.com/docs", label="Docs", id_="")
240+
assert "id" in btn
241+
assert btn["id"] == ""
242+
243+
def test_id_survives_wire_serialization(self):
244+
btn = LinkButton(url="https://example.com/docs", label="Docs", id_="open-docs")
245+
round_tripped = json.loads(json.dumps(btn))
246+
assert round_tripped["id"] == "open-docs"
247+
assert round_tripped["type"] == "link-button"

0 commit comments

Comments
 (0)