Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/UPSTREAM_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
10 changes: 10 additions & 0 deletions src/chat_sdk/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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_

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate LinkButton ids to Slack action_id

When a Slack card uses LinkButton(..., id_="open-docs"), this stores the id on the card element, but the Slack renderers still ignore it: src/chat_sdk/adapters/slack/cards.py builds action_id as link-{url[:200]} and src/chat_sdk/adapters/slack/blocks/__init__.py does the same from the URL. Slack block-action inbound then copies that action_id into ActionEvent, and Chat.on_action("open-docs", ...) matches against event.action_id, so the stable id added here is never emitted back for Slack link clicks.

Useful? React with 👍 / 👎.

if style is not None:
element["style"] = style
return element
Expand Down
1 change: 1 addition & 0 deletions src/chat_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Loading