Skip to content

Commit aced743

Browse files
feat(slack): primitives subpaths (4.30 — vercel/chat#538,547,548,555,559) (#139)
* feat(slack): webhook primitives subpath (vercel/chat#538) Port of upstream b332a03 — adds chat_sdk.adapters.slack.webhook, a lightweight runtime-free subpath for lower-level Slack webhook handling: verifying Slack requests (HMAC v0 + custom verifiers), reading signed webhook bodies, parsing Events API callbacks, slash commands, and interactive payloads into typed dataclasses with provider-native continuation data. The adapter now verifies through the shared verify_slack_request / verify_slack_signature primitives (single implementation — the inline _verify_signature method is removed, matching upstream), reads bodies via the shared read_slack_request_body helper, and sources SlackWebhookVerifier from webhook/types.py. The slack package __init__ is now lazy (PEP 562) so importing the webhook subpath does not pull in the full adapter runtime — the Python analog of upstream's package subpath export boundary. Python-specific notes: verify_slack_signature is sync (no WebCrypto); verify helpers accept a pre-read body= for non-re-readable framework requests; now() returns epoch seconds. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * feat(slack): format primitives subpath (vercel/chat#547) Port of upstream 4c46c26 — adds chat_sdk.adapters.slack.format, a runtime-free subpath for Slack formatting helpers: plain_text/mrkdwn text objects, mrkdwn escaping/unescaping, user/channel/user-group/special mentions, links, localized date tokens, mrkdwn-to-Markdown normalization, Markdown-bold conversion, and ID-based bare-mention linking — without the full Slack adapter, slack_sdk, or the chat runtime. Python-specific notes: option objects (SlackTextOptions, SlackDateOptions) become keyword-only arguments; format_slack_date accepts datetime or an integer unix timestamp (TS Date | number) and the TypeError message says 'datetime' instead of 'Date'. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * feat(slack): api primitives subpath (vercel/chat#548, #559) Port of upstream aba6aa9 (api/client.ts) and 6ed4a43 (api/extra.ts) — adds chat_sdk.adapters.slack.api, a runtime-free subpath exposed upstream as @chat-adapter/slack/api. Provides fetch-based primitives for calling Slack Web API methods (call_slack_api), posting/updating/deleting messages (post_slack_message, post_slack_ephemeral, update_slack_message, delete_slack_message), sending interaction response_url payloads (send_slack_response_url), uploading files through Slack's external upload flow (upload_slack_files), fetching private Slack file URLs with bearer auth (fetch_slack_file), fetching thread replies with cursor pagination (fetch_slack_thread_replies), and opening modal views (open_slack_view) — without the full Slack adapter, slack_sdk, Socket Mode, or the chat runtime. Importing this subpath never imports an HTTP client: the default fetch lazily imports httpx only when a request is actually made (matching the high-level adapter's optional-httpx pattern), and any async HTTP stack can be injected via the fetch= parameter. SlackBotToken is declared locally rather than imported from the adapter's types module, so the subpath stays self-contained and runtime-free — mirroring upstream's independent declaration in api/client.ts. Python-specific notes: option objects become keyword-only arguments; camelCase request fields are emitted at the Slack serialization boundary (markdown_text, reply_broadcast, thread_ts, ...) while the API is snake_case. Python-specific hardening (divergences, see docs/UPSTREAM_SYNC.md Known Non-Parity): send_slack_response_url requires an https://*.slack.com URL and fetch_slack_file requires a Slack-owned file host before forwarding the bearer token (SSRF / token-leak guards mirroring the high-level adapter); upstream validates neither. Tests port api/index.test.ts and api/boundary.test.ts with injected AsyncMock fetches (no network), plus the two divergence guards. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * feat(slack): block kit primitives subpath (vercel/chat#555, #559) Port of upstream dbd8dc5 (blocks/index.ts, types.ts, limits.ts, errors.ts) and 6ed4a43 (blocks/input.ts) — adds chat_sdk.adapters.slack.blocks, a runtime-free subpath exposed upstream as @chat-adapter/slack/blocks. Converts Chat SDK-style card objects into Slack Block Kit blocks (card_to_slack_blocks / card_to_block_kit), a Markdown fallback (card_to_slack_fallback_text / card_to_fallback_text), and emoji-placeholder codes (convert_slack_emoji_placeholders), enforcing docs-backed Slack size limits (LIMITS) for headers, images, actions, select options, fields, and tables. Also ports the generic input-request helpers: input_request_to_slack_blocks (buttons / select / radio / freeform), parse_slack_input_response, build_slack_freeform_view, parse_slack_freeform_value, and answered_slack_input_blocks — without the full Slack adapter, slack_sdk, or the chat runtime. The only cross-module dependency is the sibling format subpath (markdown_bold_to_slack_mrkdwn), which is itself runtime-free. Python-specific notes: the card-input shapes (SlackCardElement and children) are self-contained TypedDicts declared here rather than imported from chat_sdk.cards, keeping the subpath runtime-free. Input field names are snake_case (image_url, initial_option, request_id, allow_freeform, selected_option_value, action_id, block_id), matching chat_sdk.cards and the Python port convention — upstream uses camelCase. Emitted Block Kit dicts keep Slack's API field names verbatim (alt_text, action_id, block_id, static_select, column_settings, raw_text, private_metadata), which is the Slack serialization boundary. Discriminated-union dispatch on the literal type key uses per-branch casts / arg-type ignores, mirroring the high-level adapter's cards.py. Tests port blocks/index.test.ts and blocks/boundary.test.ts with full output equality, plus extra coverage of the parse-None and string-metadata paths. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(slack): port dropped parse fields + fix select-action label (4.30 fidelity) Port the SlackFile/SlackUser/SlackViewStateValue primitives and the helpers (parseFiles/inferFileType/parseUser/findPromptBlock/ readPromptText/parseViewValues) that the webhook parse.py port dropped vs upstream chat@4.30.0 packages/adapter-slack/src/webhook/parse.ts: - files[]: populated on every message-like event via _parse_files (mimetype-inferred type, raw retained). - SlackAction.label: now prefers the selected option's text and falls back to the element text (parse.ts:276); surface selected_option_label. - SlackUser: attached as the user object on block_actions / view_submission / view_closed payloads and on each parsed action. - block_actions: restore message_blocks / message_prompt_block / message_prompt_text; view_submission: restore callback_id / private_metadata / values (parseViewValues). Extend webhook primitive tests to lock in every newly-ported field; add Known-Non-Parity rows for the slack/api SSRF + token-leak guards. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d7f7d8 commit aced743

22 files changed

Lines changed: 5103 additions & 188 deletions

docs/UPSTREAM_SYNC.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,8 @@ stay explicit instead of being rediscovered in code review.
646646
| 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. |
647647
| 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. |
648648
| 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. |
649+
| 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`. |
650+
| Slack `api` primitives `fetch_slack_file` host allowlist (vercel/chat#548) | `fetch_slack_file` (`slack/api/__init__.py`) gates `url` through `is_trusted_slack_file_url` before forwarding the bot token, raising `ValueError` for untrusted hosts | Upstream `api/client.ts` `fetchFile` GETs the supplied URL with `Authorization: Bearer <token>` unconditionally | Token-leak guard. `fetch_slack_file` attaches the workspace bot token; a crafted `url_private` from a parsed file object could otherwise exfiltrate that token to an arbitrary host. `is_trusted_slack_file_url` requires scheme `https` and host in `{files.slack.com, slack.com, *.slack.com, *.slack-edge.com}` — the same allowlist the high-level adapter's `rehydrate_attachment` row uses for Slack. Enforces `CLAUDE.md`'s SSRF/URL-validation rule. |
649651

650652
### Platform-specific gaps
651653

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
1-
"""Slack adapter for chat-sdk."""
1+
"""Slack adapter for chat-sdk.
22
3-
from chat_sdk.adapters.slack.adapter import SlackAdapter, create_slack_adapter
3+
The high-level adapter is loaded lazily (PEP 562) so that the low-level
4+
primitive subpaths (``chat_sdk.adapters.slack.webhook``) can be imported
5+
without pulling in the full adapter runtime — mirroring upstream's
6+
``@chat-adapter/slack/webhook`` subpath export boundary (vercel/chat#538).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import importlib
12+
from typing import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
from chat_sdk.adapters.slack.adapter import SlackAdapter as SlackAdapter
16+
from chat_sdk.adapters.slack.adapter import create_slack_adapter as create_slack_adapter
417

518
__all__ = ["SlackAdapter", "create_slack_adapter"]
19+
20+
21+
def __getattr__(name: str) -> object:
22+
if name in __all__:
23+
module = importlib.import_module("chat_sdk.adapters.slack.adapter")
24+
return getattr(module, name)
25+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 26 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import base64
1313
import contextlib
1414
import contextvars
15-
import hashlib
1615
import hmac
1716
import inspect
1817
import json
@@ -58,6 +57,10 @@
5857
SlackThreadId,
5958
SlackWebhookVerifier,
6059
)
60+
from chat_sdk.adapters.slack.webhook import (
61+
read_slack_request_body,
62+
verify_slack_request,
63+
)
6164
from chat_sdk.emoji import emoji_to_slack, resolve_emoji_from_slack
6265
from chat_sdk.logger import ConsoleLogger, Logger
6366
from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement
@@ -1392,33 +1395,11 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
13921395
13931396
Returns a dict with ``body`` and ``status`` keys.
13941397
"""
1395-
# Read the raw body. `hasattr` narrows `Any` → `object` (not
1396-
# awaitable), so we use `getattr(..., None)` to preserve the
1397-
# `Any` type across the duck-typed framework branches.
1398-
# Handle both callable (`async def text(self)`) and non-callable
1399-
# (`text: str` attribute) forms of `request.text`. Gating entry
1400-
# on callability would drop populated string attributes.
1401-
text_attr = getattr(request, "text", None)
1402-
body: str
1403-
if text_attr is not None:
1404-
if callable(text_attr):
1405-
result = text_attr()
1406-
text_attr = await result if inspect.isawaitable(result) else result
1407-
body = text_attr.decode("utf-8") if isinstance(text_attr, (bytes, bytearray)) else str(text_attr)
1408-
else:
1409-
raw = getattr(request, "body", None)
1410-
if raw is not None:
1411-
# Some frameworks expose `body` as an async method (e.g.
1412-
# `async def body(self)`) — call it, then await if the
1413-
# result is awaitable. Previously we only handled the
1414-
# coroutine-as-attribute case, not the async-method case.
1415-
if callable(raw):
1416-
raw = raw()
1417-
if asyncio.iscoroutine(raw) or asyncio.isfuture(raw) or inspect.isawaitable(raw):
1418-
raw = await raw
1419-
body = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
1420-
else:
1421-
body = str(request)
1398+
# Read the raw body via the shared webhook primitive (the Python
1399+
# stand-in for the Fetch API's ``await request.text()``) so the
1400+
# adapter and the low-level ``webhook`` subpath use one
1401+
# implementation for duck-typed framework requests.
1402+
body: str = await read_slack_request_body(request)
14221403

14231404
self._logger.debug("Slack webhook raw body", {"body": body[:500]})
14241405

@@ -1455,7 +1436,7 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
14551436
# Hazard #12 (replay): the shared bearer alone is not enough —
14561437
# without a freshness check, an old captured forwarded event
14571438
# could be replayed indefinitely. Mirror the 5-minute window
1458-
# ``_verify_signature`` enforces on signed webhook traffic.
1439+
# ``verify_slack_signature`` enforces on signed webhook traffic.
14591440
#
14601441
# Wire format: upstream's ``forwardSocketEvent`` always emits
14611442
# ``timestamp: Date.now()`` — milliseconds since the Unix epoch
@@ -1484,31 +1465,22 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
14841465
if self._mode == "socket":
14851466
return {"body": "Webhooks are disabled in socket mode", "status": 405}
14861467

1487-
# Verify the request — when a custom ``webhook_verifier`` is configured
1488-
# it takes precedence over ``signing_secret`` / ``SLACK_SIGNING_SECRET``
1489-
# (matches upstream vercel/chat#468). The verifier may also return a
1490-
# string that replaces the body for downstream parsing (e.g.
1491-
# canonicalization).
1492-
if self._webhook_verifier is not None:
1493-
try:
1494-
verifier_result = self._webhook_verifier(request, body)
1495-
if inspect.isawaitable(verifier_result):
1496-
verifier_result = await verifier_result
1497-
except Exception as exc:
1498-
self._logger.warn("Webhook verifier rejected request", {"error": exc})
1499-
return {"body": "Invalid signature", "status": 401}
1500-
if not verifier_result:
1501-
self._logger.warn("Webhook verifier rejected request")
1502-
return {"body": "Invalid signature", "status": 401}
1503-
if isinstance(verifier_result, str):
1504-
# Substitute the verifier-supplied canonical body before
1505-
# parsing. Other truthy returns are pure verification.
1506-
body = verifier_result
1507-
else:
1508-
timestamp = headers.get("x-slack-request-timestamp") or headers.get("X-Slack-Request-Timestamp")
1509-
signature = headers.get("x-slack-signature") or headers.get("X-Slack-Signature")
1510-
if not self._verify_signature(body, timestamp, signature):
1511-
return {"body": "Invalid signature", "status": 401}
1468+
# Verify the request via the shared webhook primitive (vercel/chat#538
1469+
# extracted this from the adapter) — when a custom ``webhook_verifier``
1470+
# is configured it takes precedence over ``signing_secret`` /
1471+
# ``SLACK_SIGNING_SECRET`` (matches upstream vercel/chat#468). The
1472+
# verifier may also return a string that replaces the body for
1473+
# downstream parsing (e.g. canonicalization).
1474+
try:
1475+
body = await verify_slack_request(
1476+
request,
1477+
body=body,
1478+
signing_secret=self._signing_secret,
1479+
webhook_verifier=self._webhook_verifier,
1480+
)
1481+
except Exception as exc:
1482+
self._logger.warn("Webhook verifier rejected request", {"error": exc})
1483+
return {"body": "Invalid signature", "status": 401}
15121484

15131485
# URL verification is special: Slack sends a JSON ``url_verification``
15141486
# ping at app-install / event-subscription time and only expects the
@@ -1658,52 +1630,6 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
16581630
self._process_event_payload(payload, options)
16591631
return {"body": "ok", "status": 200}
16601632

1661-
# ==================================================================
1662-
# Signature verification
1663-
# ==================================================================
1664-
1665-
def _verify_signature(self, body: str, timestamp: str | None, signature: str | None) -> bool:
1666-
# Defensive: ``_verify_signature`` should never be reached when only a
1667-
# ``webhook_verifier`` is configured (handle_webhook gates on that),
1668-
# but if a subclass calls this directly without a signing_secret,
1669-
# fail closed. This also covers the socket-mode case where
1670-
# ``signing_secret`` is legitimately ``None`` — without this guard a
1671-
# caller could call ``handle_webhook`` while in socket mode and
1672-
# silently pass verification with an empty secret.
1673-
if not self._signing_secret:
1674-
return False
1675-
1676-
if not (timestamp and signature):
1677-
return False
1678-
1679-
# Check timestamp is recent (within 5 minutes)
1680-
now = int(time.time())
1681-
try:
1682-
ts_int = int(timestamp)
1683-
except (ValueError, TypeError):
1684-
return False
1685-
if abs(now - ts_int) > 300:
1686-
return False
1687-
1688-
sig_basestring = f"v0:{timestamp}:{body}"
1689-
expected = (
1690-
"v0="
1691-
+ hmac.new(
1692-
self._signing_secret.encode("utf-8"),
1693-
sig_basestring.encode("utf-8"),
1694-
hashlib.sha256,
1695-
).hexdigest()
1696-
)
1697-
1698-
try:
1699-
# ``hmac.compare_digest`` is the canonical constant-time comparison.
1700-
# Custom verifiers passed via ``webhook_verifier`` MUST do the
1701-
# same — a regression to ``==`` would leak signature bytes via
1702-
# timing.
1703-
return hmac.compare_digest(signature, expected)
1704-
except Exception:
1705-
return False
1706-
17071633
# ==================================================================
17081634
# Event dispatch
17091635
# ==================================================================

0 commit comments

Comments
 (0)