Skip to content

Commit 39432c3

Browse files
committed
feat(chat): add callback_url to buttons and modals (vercel/chat#454)
https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 20a5fd10a2dd46094fdf812ff47628a12bb9d362)
1 parent c59e6cd commit 39432c3

15 files changed

Lines changed: 1990 additions & 12 deletions
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
"""Discord adapter for chat-sdk."""
22

33
from chat_sdk.adapters.discord.adapter import DiscordAdapter, create_discord_adapter
4+
from chat_sdk.adapters.discord.cards import (
5+
decode_discord_custom_id,
6+
encode_discord_custom_id,
7+
)
48

5-
__all__ = ["DiscordAdapter", "create_discord_adapter"]
9+
__all__ = [
10+
"DiscordAdapter",
11+
"create_discord_adapter",
12+
"decode_discord_custom_id",
13+
"encode_discord_custom_id",
14+
]

src/chat_sdk/adapters/discord/adapter.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from chat_sdk.adapters.discord.cards import (
2222
card_to_discord_payload,
23+
decode_discord_custom_id,
2324
)
2425
from chat_sdk.adapters.discord.format_converter import DiscordFormatConverter
2526
from chat_sdk.adapters.discord.types import (
@@ -385,10 +386,11 @@ def _handle_component_interaction(
385386
},
386387
)
387388

389+
decoded = decode_discord_custom_id(custom_id)
388390
self._chat.process_action(
389391
ActionEvent(
390-
action_id=custom_id,
391-
value=custom_id,
392+
action_id=decoded.action_id,
393+
value=decoded.value if decoded.value is not None else decoded.action_id,
392394
user=Author(
393395
user_id=user.get("id", ""),
394396
user_name=user.get("username", ""),

src/chat_sdk/adapters/discord/cards.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
from dataclasses import dataclass
1011
from typing import Any, cast
1112

1213
from chat_sdk.adapters.discord.types import DiscordActionRow, DiscordButton
@@ -24,6 +25,7 @@
2425
)
2526
from chat_sdk.emoji import convert_emoji_placeholders
2627
from chat_sdk.shared.card_utils import render_gfm_table
28+
from chat_sdk.shared.errors import ValidationError
2729

2830
# Discord button styles (discord-api-types/v10 ButtonStyle)
2931
BUTTON_STYLE_PRIMARY = 1
@@ -34,6 +36,46 @@
3436
# Discord blurple color
3537
DISCORD_BLURPLE = 0x5865F2
3638

39+
# Discord packs the button value into custom_id (max 100 chars). The
40+
# delimiter is a newline -- it can't appear in a Button id and survives
41+
# Discord's round-trip intact.
42+
DISCORD_CUSTOM_ID_DELIMITER = "\n"
43+
DISCORD_CUSTOM_ID_MAX_LENGTH = 100
44+
45+
46+
@dataclass(frozen=True)
47+
class DecodedDiscordCustomId:
48+
"""Result of :func:`decode_discord_custom_id`."""
49+
50+
action_id: str
51+
value: str | None
52+
53+
54+
def _validate_discord_custom_id(custom_id: str) -> None:
55+
if len(custom_id) == 0 or len(custom_id) > DISCORD_CUSTOM_ID_MAX_LENGTH:
56+
raise ValidationError(
57+
"discord",
58+
f"Discord custom_id must be 1-{DISCORD_CUSTOM_ID_MAX_LENGTH} characters. Shorten the button id or value.",
59+
)
60+
61+
62+
def encode_discord_custom_id(action_id: str, value: str | None = None) -> str:
63+
"""Encode a button's action ID and optional value into a custom_id."""
64+
if value is None or value == "":
65+
_validate_discord_custom_id(action_id)
66+
return action_id
67+
encoded = f"{action_id}{DISCORD_CUSTOM_ID_DELIMITER}{value}"
68+
_validate_discord_custom_id(encoded)
69+
return encoded
70+
71+
72+
def decode_discord_custom_id(custom_id: str) -> DecodedDiscordCustomId:
73+
"""Split a custom_id back into (action_id, value). Splits on the first delimiter only."""
74+
idx = custom_id.find(DISCORD_CUSTOM_ID_DELIMITER)
75+
if idx == -1:
76+
return DecodedDiscordCustomId(action_id=custom_id, value=None)
77+
return DecodedDiscordCustomId(action_id=custom_id[:idx], value=custom_id[idx + 1 :])
78+
3779

3880
def _convert_emoji(text: str) -> str:
3981
"""Convert emoji placeholders to Discord format."""
@@ -172,7 +214,7 @@ def _convert_button_element(button: ButtonElement) -> DiscordButton:
172214
"type": 2, # Button
173215
"style": _get_button_style(button.get("style")),
174216
"label": button.get("label", ""),
175-
"custom_id": button.get("id", ""),
217+
"custom_id": encode_discord_custom_id(button.get("id", ""), button.get("value")),
176218
}
177219

178220
if button.get("disabled"):

src/chat_sdk/callback_url.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Callback URL handling for buttons and modals.
2+
3+
Python port of callback-url.ts (vercel/chat#454).
4+
5+
When a button (or modal) carries a ``callback_url``, the SDK stores the URL
6+
in the state adapter under a short random token at post time and rewrites
7+
the button's ``value`` to an encoded token (``__cb:<token>``). When the
8+
button is clicked, :meth:`Chat.process_action` decodes the token, restores
9+
the original value for handlers, and POSTs the action payload to the stored
10+
URL. Modal callback URLs are stored in the modal context and POSTed on
11+
submit.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import secrets
18+
from dataclasses import dataclass
19+
from typing import Any
20+
21+
from chat_sdk.cards import ActionsElement, ButtonElement, CardChild, CardElement
22+
from chat_sdk.errors import ChatError
23+
from chat_sdk.types import StateAdapter
24+
25+
CALLBACK_TOKEN_PREFIX = "__cb:"
26+
CALLBACK_CACHE_KEY_PREFIX = "chat:callback:"
27+
CALLBACK_TTL_MS = 30 * 24 * 60 * 60 * 1000 # 30 days
28+
29+
30+
# ---------------------------------------------------------------------------
31+
# Result types (TS uses inline object literals; port rule #9: typed objects)
32+
# ---------------------------------------------------------------------------
33+
34+
35+
@dataclass(frozen=True)
36+
class DecodedCallbackValue:
37+
"""Result of :func:`decode_callback_value`."""
38+
39+
callback_token: str | None
40+
41+
42+
@dataclass(frozen=True)
43+
class ResolvedCallback:
44+
"""A stored callback resolved from the state adapter."""
45+
46+
url: str
47+
original_value: str | None = None
48+
49+
50+
@dataclass(frozen=True)
51+
class CallbackPostResult:
52+
"""Result of :func:`post_to_callback_url`."""
53+
54+
error: Exception | None = None
55+
status: int | None = None
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Token encoding
60+
# ---------------------------------------------------------------------------
61+
62+
63+
def encode_callback_value(token: str) -> str:
64+
"""Encode a callback token into a button value."""
65+
return f"{CALLBACK_TOKEN_PREFIX}{token}"
66+
67+
68+
def decode_callback_value(value: str | None) -> DecodedCallbackValue:
69+
"""Extract the callback token from an encoded button value, if any."""
70+
if not value or not value.startswith(CALLBACK_TOKEN_PREFIX):
71+
return DecodedCallbackValue(callback_token=None)
72+
return DecodedCallbackValue(callback_token=value[len(CALLBACK_TOKEN_PREFIX) :])
73+
74+
75+
def _generate_token() -> str:
76+
# Upstream: crypto.randomUUID().replace(/-/g, "").slice(0, 16).
77+
# Port rule #12: the token gates where action payloads are POSTed, so
78+
# use `secrets` (16 hex chars = 64 bits, same shape as upstream).
79+
return secrets.token_hex(8)
80+
81+
82+
# ---------------------------------------------------------------------------
83+
# Card processing (runs at post time)
84+
# ---------------------------------------------------------------------------
85+
86+
87+
async def _process_actions_element(
88+
actions: ActionsElement,
89+
state_adapter: StateAdapter,
90+
) -> ActionsElement:
91+
children: list[Any] = []
92+
for el in actions.get("children", []):
93+
if not isinstance(el, dict) or el.get("type") != "button" or not el.get("callback_url"):
94+
children.append(el)
95+
continue
96+
97+
token = _generate_token()
98+
# Stored shape matches the TS SDK (`{url, originalValue}`) so state
99+
# written by either SDK resolves in both. `originalValue` is omitted
100+
# (not None) when the button has no value — hazard #7.
101+
stored: dict[str, Any] = {"url": el["callback_url"]}
102+
original_value = el.get("value")
103+
if original_value is not None:
104+
stored["originalValue"] = original_value
105+
await state_adapter.set(f"{CALLBACK_CACHE_KEY_PREFIX}{token}", stored, CALLBACK_TTL_MS)
106+
107+
# Rebuild the button without `callback_url` (mirrors upstream's
108+
# explicit-key copy; keys absent on the source stay absent).
109+
processed: ButtonElement = {"type": "button", "id": el["id"], "label": el["label"]}
110+
if "style" in el:
111+
processed["style"] = el["style"]
112+
if "disabled" in el:
113+
processed["disabled"] = el["disabled"]
114+
processed["value"] = encode_callback_value(token)
115+
if "action_type" in el:
116+
processed["action_type"] = el["action_type"]
117+
children.append(processed)
118+
return {"type": "actions", "children": children}
119+
120+
121+
def _has_callback_buttons(children: list[CardChild]) -> bool:
122+
for child in children:
123+
if not isinstance(child, dict):
124+
continue
125+
if child.get("type") == "actions":
126+
for el in child.get("children", []):
127+
if isinstance(el, dict) and el.get("type") == "button" and el.get("callback_url"):
128+
return True
129+
if child.get("type") == "section" and "children" in child and _has_callback_buttons(child["children"]):
130+
return True
131+
return False
132+
133+
134+
async def _process_children(
135+
children: list[CardChild],
136+
state_adapter: StateAdapter,
137+
) -> list[CardChild]:
138+
result: list[CardChild] = []
139+
for child in children:
140+
if isinstance(child, dict) and child.get("type") == "actions":
141+
result.append(await _process_actions_element(child, state_adapter)) # type: ignore[arg-type]
142+
elif isinstance(child, dict) and child.get("type") == "section" and "children" in child:
143+
result.append({**child, "children": await _process_children(child["children"], state_adapter)}) # type: ignore[misc]
144+
else:
145+
result.append(child)
146+
return result
147+
148+
149+
async def process_card_callback_urls(
150+
card: CardElement,
151+
state_adapter: StateAdapter,
152+
) -> CardElement:
153+
"""Replace ``callback_url`` buttons with encoded token values.
154+
155+
Returns the *same* card object when no button carries a callback URL;
156+
otherwise returns a new card (the original is never mutated).
157+
"""
158+
if not _has_callback_buttons(card.get("children", [])):
159+
return card
160+
161+
return {**card, "children": await _process_children(card.get("children", []), state_adapter)}
162+
163+
164+
# ---------------------------------------------------------------------------
165+
# Resolution + POST (runs at click/submit time)
166+
# ---------------------------------------------------------------------------
167+
168+
169+
async def resolve_callback_url(
170+
token: str,
171+
state_adapter: StateAdapter,
172+
) -> ResolvedCallback | None:
173+
"""Look up a stored callback by token. Returns ``None`` when unknown."""
174+
stored = await state_adapter.get(f"{CALLBACK_CACHE_KEY_PREFIX}{token}")
175+
if not stored:
176+
return None
177+
if isinstance(stored, str):
178+
# Legacy format: the URL was stored as a bare string.
179+
return ResolvedCallback(url=stored)
180+
original_value = stored["originalValue"] if "originalValue" in stored else stored.get("original_value")
181+
return ResolvedCallback(url=stored.get("url"), original_value=original_value)
182+
183+
184+
async def _fetch(url: str, *, method: str, headers: dict[str, str], body: str) -> tuple[int, str]:
185+
"""POST ``body`` to ``url`` and return ``(status, text)``.
186+
187+
Thin seam over aiohttp so tests can stub the network the way upstream
188+
stubs global ``fetch``. aiohttp is an optional dependency, so it is
189+
imported lazily (hazard #10); only http(s) URLs are supported, matching
190+
the WHATWG ``fetch`` upstream relies on.
191+
"""
192+
import aiohttp
193+
194+
async with (
195+
aiohttp.ClientSession() as session,
196+
session.request(method, url, data=body.encode("utf-8"), headers=headers) as response,
197+
):
198+
try:
199+
text = await response.text()
200+
except Exception:
201+
# Mirrors upstream's `response.text().catch(() => "")`.
202+
text = ""
203+
return response.status, text
204+
205+
206+
async def post_to_callback_url(
207+
callback_url: str,
208+
payload: dict[str, Any],
209+
) -> CallbackPostResult:
210+
"""POST a JSON payload to a callback URL.
211+
212+
Never raises: network and HTTP errors are returned in
213+
:class:`CallbackPostResult` for the caller to log.
214+
"""
215+
try:
216+
status, text = await _fetch(
217+
callback_url,
218+
method="POST",
219+
headers={"Content-Type": "application/json"},
220+
body=json.dumps(payload),
221+
)
222+
if not 200 <= status < 300:
223+
return CallbackPostResult(
224+
error=ChatError(f"Callback URL returned {status}: {text}"),
225+
status=status,
226+
)
227+
return CallbackPostResult(status=status)
228+
except Exception as error:
229+
return CallbackPostResult(error=error)

src/chat_sdk/cards.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class ButtonElement(_ButtonRequired, total=False):
2929
value: str
3030
disabled: bool
3131
action_type: Literal["action", "modal"] | None
32+
# URL to POST action data to when this button is clicked
33+
callback_url: str
3234

3335

3436
class _LinkButtonRequired(TypedDict):
@@ -256,6 +258,7 @@ def Button(
256258
value: str | None = None,
257259
disabled: bool | None = None,
258260
action_type: Literal["action", "modal"] | None = None,
261+
callback_url: str | None = None,
259262
) -> ButtonElement:
260263
"""Create a Button element.
261264
@@ -264,6 +267,7 @@ def Button(
264267
Button(id="submit", label="Submit", style="primary")
265268
Button(id="delete", label="Delete", style="danger", value="item-123")
266269
Button(id="open", label="Open", action_type="modal")
270+
Button(id="approve", label="Approve", callback_url="https://example.com/hook")
267271
"""
268272
element: ButtonElement = {"type": "button", "id": id, "label": label}
269273
if style is not None:
@@ -274,6 +278,8 @@ def Button(
274278
element["disabled"] = disabled
275279
if action_type is not None:
276280
element["action_type"] = action_type
281+
if callback_url is not None:
282+
element["callback_url"] = callback_url
277283
return element
278284

279285

0 commit comments

Comments
 (0)