From c64dead1a114dc39474b0704dc9ba328910305e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 23:00:48 +0000 Subject: [PATCH 1/5] feat(slack): webhook primitives subpath (vercel/chat#538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/chat_sdk/adapters/slack/__init__.py | 24 +- src/chat_sdk/adapters/slack/adapter.py | 126 +--- src/chat_sdk/adapters/slack/types.py | 27 +- .../adapters/slack/webhook/__init__.py | 66 +++ src/chat_sdk/adapters/slack/webhook/parse.py | 309 ++++++++++ src/chat_sdk/adapters/slack/webhook/types.py | 242 ++++++++ src/chat_sdk/adapters/slack/webhook/utils.py | 148 +++++ src/chat_sdk/adapters/slack/webhook/verify.py | 163 ++++++ tests/test_slack_adapter.py | 59 -- .../test_slack_dynamic_token_and_verifier.py | 19 +- tests/test_slack_webhook_primitives.py | 549 ++++++++++++++++++ 11 files changed, 1544 insertions(+), 188 deletions(-) create mode 100644 src/chat_sdk/adapters/slack/webhook/__init__.py create mode 100644 src/chat_sdk/adapters/slack/webhook/parse.py create mode 100644 src/chat_sdk/adapters/slack/webhook/types.py create mode 100644 src/chat_sdk/adapters/slack/webhook/utils.py create mode 100644 src/chat_sdk/adapters/slack/webhook/verify.py create mode 100644 tests/test_slack_webhook_primitives.py diff --git a/src/chat_sdk/adapters/slack/__init__.py b/src/chat_sdk/adapters/slack/__init__.py index de1c2482..95d39b50 100644 --- a/src/chat_sdk/adapters/slack/__init__.py +++ b/src/chat_sdk/adapters/slack/__init__.py @@ -1,5 +1,25 @@ -"""Slack adapter for chat-sdk.""" +"""Slack adapter for chat-sdk. -from chat_sdk.adapters.slack.adapter import SlackAdapter, create_slack_adapter +The high-level adapter is loaded lazily (PEP 562) so that the low-level +primitive subpaths (``chat_sdk.adapters.slack.webhook``) can be imported +without pulling in the full adapter runtime — mirroring upstream's +``@chat-adapter/slack/webhook`` subpath export boundary (vercel/chat#538). +""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chat_sdk.adapters.slack.adapter import SlackAdapter as SlackAdapter + from chat_sdk.adapters.slack.adapter import create_slack_adapter as create_slack_adapter __all__ = ["SlackAdapter", "create_slack_adapter"] + + +def __getattr__(name: str) -> object: + if name in __all__: + module = importlib.import_module("chat_sdk.adapters.slack.adapter") + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index 857574e8..a2920b47 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -12,7 +12,6 @@ import base64 import contextlib import contextvars -import hashlib import hmac import inspect import json @@ -58,6 +57,10 @@ SlackThreadId, SlackWebhookVerifier, ) +from chat_sdk.adapters.slack.webhook import ( + read_slack_request_body, + verify_slack_request, +) from chat_sdk.emoji import emoji_to_slack, resolve_emoji_from_slack from chat_sdk.logger import ConsoleLogger, Logger from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement @@ -1392,33 +1395,11 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No Returns a dict with ``body`` and ``status`` keys. """ - # Read the raw body. `hasattr` narrows `Any` → `object` (not - # awaitable), so we use `getattr(..., None)` to preserve the - # `Any` type across the duck-typed framework branches. - # Handle both callable (`async def text(self)`) and non-callable - # (`text: str` attribute) forms of `request.text`. Gating entry - # on callability would drop populated string attributes. - text_attr = getattr(request, "text", None) - body: str - if text_attr is not None: - if callable(text_attr): - result = text_attr() - text_attr = await result if inspect.isawaitable(result) else result - body = text_attr.decode("utf-8") if isinstance(text_attr, (bytes, bytearray)) else str(text_attr) - else: - raw = getattr(request, "body", None) - if raw is not None: - # Some frameworks expose `body` as an async method (e.g. - # `async def body(self)`) — call it, then await if the - # result is awaitable. Previously we only handled the - # coroutine-as-attribute case, not the async-method case. - if callable(raw): - raw = raw() - if asyncio.iscoroutine(raw) or asyncio.isfuture(raw) or inspect.isawaitable(raw): - raw = await raw - body = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw) - else: - body = str(request) + # Read the raw body via the shared webhook primitive (the Python + # stand-in for the Fetch API's ``await request.text()``) so the + # adapter and the low-level ``webhook`` subpath use one + # implementation for duck-typed framework requests. + body: str = await read_slack_request_body(request) self._logger.debug("Slack webhook raw body", {"body": body[:500]}) @@ -1455,7 +1436,7 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No # Hazard #12 (replay): the shared bearer alone is not enough — # without a freshness check, an old captured forwarded event # could be replayed indefinitely. Mirror the 5-minute window - # ``_verify_signature`` enforces on signed webhook traffic. + # ``verify_slack_signature`` enforces on signed webhook traffic. # # Wire format: upstream's ``forwardSocketEvent`` always emits # ``timestamp: Date.now()`` — milliseconds since the Unix epoch @@ -1484,31 +1465,22 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No if self._mode == "socket": return {"body": "Webhooks are disabled in socket mode", "status": 405} - # Verify the request — when a custom ``webhook_verifier`` is configured - # it takes precedence over ``signing_secret`` / ``SLACK_SIGNING_SECRET`` - # (matches upstream vercel/chat#468). The verifier may also return a - # string that replaces the body for downstream parsing (e.g. - # canonicalization). - if self._webhook_verifier is not None: - try: - verifier_result = self._webhook_verifier(request, body) - if inspect.isawaitable(verifier_result): - verifier_result = await verifier_result - except Exception as exc: - self._logger.warn("Webhook verifier rejected request", {"error": exc}) - return {"body": "Invalid signature", "status": 401} - if not verifier_result: - self._logger.warn("Webhook verifier rejected request") - return {"body": "Invalid signature", "status": 401} - if isinstance(verifier_result, str): - # Substitute the verifier-supplied canonical body before - # parsing. Other truthy returns are pure verification. - body = verifier_result - else: - timestamp = headers.get("x-slack-request-timestamp") or headers.get("X-Slack-Request-Timestamp") - signature = headers.get("x-slack-signature") or headers.get("X-Slack-Signature") - if not self._verify_signature(body, timestamp, signature): - return {"body": "Invalid signature", "status": 401} + # Verify the request via the shared webhook primitive (vercel/chat#538 + # extracted this from the adapter) — when a custom ``webhook_verifier`` + # is configured it takes precedence over ``signing_secret`` / + # ``SLACK_SIGNING_SECRET`` (matches upstream vercel/chat#468). The + # verifier may also return a string that replaces the body for + # downstream parsing (e.g. canonicalization). + try: + body = await verify_slack_request( + request, + body=body, + signing_secret=self._signing_secret, + webhook_verifier=self._webhook_verifier, + ) + except Exception as exc: + self._logger.warn("Webhook verifier rejected request", {"error": exc}) + return {"body": "Invalid signature", "status": 401} # URL verification is special: Slack sends a JSON ``url_verification`` # 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 self._process_event_payload(payload, options) return {"body": "ok", "status": 200} - # ================================================================== - # Signature verification - # ================================================================== - - def _verify_signature(self, body: str, timestamp: str | None, signature: str | None) -> bool: - # Defensive: ``_verify_signature`` should never be reached when only a - # ``webhook_verifier`` is configured (handle_webhook gates on that), - # but if a subclass calls this directly without a signing_secret, - # fail closed. This also covers the socket-mode case where - # ``signing_secret`` is legitimately ``None`` — without this guard a - # caller could call ``handle_webhook`` while in socket mode and - # silently pass verification with an empty secret. - if not self._signing_secret: - return False - - if not (timestamp and signature): - return False - - # Check timestamp is recent (within 5 minutes) - now = int(time.time()) - try: - ts_int = int(timestamp) - except (ValueError, TypeError): - return False - if abs(now - ts_int) > 300: - return False - - sig_basestring = f"v0:{timestamp}:{body}" - expected = ( - "v0=" - + hmac.new( - self._signing_secret.encode("utf-8"), - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - - try: - # ``hmac.compare_digest`` is the canonical constant-time comparison. - # Custom verifiers passed via ``webhook_verifier`` MUST do the - # same — a regression to ``==`` would leak signature bytes via - # timing. - return hmac.compare_digest(signature, expected) - except Exception: - return False - # ================================================================== # Event dispatch # ================================================================== diff --git a/src/chat_sdk/adapters/slack/types.py b/src/chat_sdk/adapters/slack/types.py index 51762568..d8fb5306 100644 --- a/src/chat_sdk/adapters/slack/types.py +++ b/src/chat_sdk/adapters/slack/types.py @@ -6,6 +6,11 @@ from dataclasses import dataclass from typing import Any, Literal, Protocol, TypeAlias, TypedDict +# Custom webhook verifier — defined in (and re-exported from) the low-level +# ``chat_sdk.adapters.slack.webhook`` subpath since vercel/chat#538. See +# ``webhook/types.py`` for the full SECURITY contract (constant-time +# comparison, replay protection, body-substitution safety). +from chat_sdk.adapters.slack.webhook.types import SlackWebhookVerifier from chat_sdk.logger import Logger # --------------------------------------------------------------------------- @@ -22,28 +27,6 @@ SlackBotTokenResolver = Callable[[], "str | Awaitable[str]"] SlackBotToken: TypeAlias = str | SlackBotTokenResolver -# Custom webhook verifier. Receives the original request object and the raw -# body string already consumed by the adapter. Return: -# - ``True`` (or any truthy non-string value) to accept the request as-is. -# - A ``str`` to accept *and* substitute the verified body for downstream -# parsing (useful when the verifier canonicalizes the payload). -# - ``False``/falsy or raise to reject (adapter responds with 401). -# -# May be sync or async. -# -# SECURITY: When a custom verifier replaces ``signing_secret``, the adapter's -# built-in HMAC + timestamp tolerance check is bypassed. The implementer is -# responsible for: -# - constant-time signature comparison (use ``hmac.compare_digest``, never ``==``) -# - replay protection (validate ``x-slack-request-timestamp`` freshness) -# - any other freshness/origin checks the platform requires -# - body-substitution safety: when returning a ``str`` to substitute the body -# for downstream parsing, the returned bytes MUST be derived from a -# verified payload. Returning attacker-controlled bytes (e.g. echoing the -# unverified raw body or splicing in untrusted fields) grants payload -# injection — downstream parsing trusts the substituted body unconditionally. -SlackWebhookVerifier = Callable[[Any, str], "bool | str | None | Awaitable[bool | str | None]"] - # Connection mode for the Slack adapter. ``"webhook"`` (default) consumes # events via signed HTTP POSTs from Slack. ``"socket"`` opens a long-lived # WebSocket via Slack's Socket Mode and ACKs each event over the socket. diff --git a/src/chat_sdk/adapters/slack/webhook/__init__.py b/src/chat_sdk/adapters/slack/webhook/__init__.py new file mode 100644 index 00000000..b0d82ad7 --- /dev/null +++ b/src/chat_sdk/adapters/slack/webhook/__init__.py @@ -0,0 +1,66 @@ +"""Slack webhook primitives — a lightweight, runtime-free subpath. + +Port of ``packages/adapter-slack/src/webhook`` (vercel/chat#538), exposed +upstream as ``@chat-adapter/slack/webhook``. Provides primitives for +verifying Slack requests, reading signed webhook bodies, parsing Events API +callbacks, slash commands, and interactive payloads, and returning +provider-native continuation data — without the full Slack adapter, +``slack_sdk``, chat state, dedupe, locks, or subscriptions. + +Importing this module never imports ``slack_sdk``, HTTP clients, or the +high-level :mod:`chat_sdk.adapters.slack.adapter`. +""" + +from chat_sdk.adapters.slack.webhook.parse import parse_slack_webhook_body +from chat_sdk.adapters.slack.webhook.types import ( + SlackAction, + SlackAppMentionPayload, + SlackBlockActionsPayload, + SlackBlockSuggestionPayload, + SlackContinuation, + SlackDirectMessagePayload, + SlackHeaders, + SlackRetry, + SlackSlashCommandPayload, + SlackUnsupportedPayload, + SlackUrlVerificationPayload, + SlackViewClosedPayload, + SlackViewSubmissionPayload, + SlackWebhookError, + SlackWebhookParseError, + SlackWebhookPayload, + SlackWebhookVerificationError, + SlackWebhookVerifier, +) +from chat_sdk.adapters.slack.webhook.utils import read_slack_request_body +from chat_sdk.adapters.slack.webhook.verify import ( + read_slack_webhook, + verify_slack_request, + verify_slack_signature, +) + +__all__ = [ + "SlackAction", + "SlackAppMentionPayload", + "SlackBlockActionsPayload", + "SlackBlockSuggestionPayload", + "SlackContinuation", + "SlackDirectMessagePayload", + "SlackHeaders", + "SlackRetry", + "SlackSlashCommandPayload", + "SlackUnsupportedPayload", + "SlackUrlVerificationPayload", + "SlackViewClosedPayload", + "SlackViewSubmissionPayload", + "SlackWebhookError", + "SlackWebhookParseError", + "SlackWebhookPayload", + "SlackWebhookVerificationError", + "SlackWebhookVerifier", + "parse_slack_webhook_body", + "read_slack_request_body", + "read_slack_webhook", + "verify_slack_request", + "verify_slack_signature", +] diff --git a/src/chat_sdk/adapters/slack/webhook/parse.py b/src/chat_sdk/adapters/slack/webhook/parse.py new file mode 100644 index 00000000..cec6154f --- /dev/null +++ b/src/chat_sdk/adapters/slack/webhook/parse.py @@ -0,0 +1,309 @@ +"""Parsing for the Slack webhook primitives subpath. + +Port of ``packages/adapter-slack/src/webhook/parse.ts`` (vercel/chat#538). + +Parses Events API callbacks, slash commands, and interactive payloads into +typed payload dataclasses with provider-native continuation data, without +the full Slack adapter runtime. +""" + +from __future__ import annotations + +from typing import Any, Literal +from urllib.parse import parse_qsl + +from chat_sdk.adapters.slack.webhook.types import ( + SlackAction, + SlackAppMentionPayload, + SlackBlockActionsPayload, + SlackBlockSuggestionPayload, + SlackContinuation, + SlackDirectMessagePayload, + SlackHeaders, + SlackRetry, + SlackSlashCommandPayload, + SlackUnsupportedPayload, + SlackUrlVerificationPayload, + SlackViewClosedPayload, + SlackViewSubmissionPayload, + SlackWebhookPayload, +) +from chat_sdk.adapters.slack.webhook.utils import ( + get_header, + get_retry, + is_form_body, + is_record, + optional_string, + parse_json_body, + record_value, + string_value, +) + + +def parse_slack_webhook_body( + body: str, + *, + content_type: str | None = None, + headers: SlackHeaders | None = None, +) -> SlackWebhookPayload: + """Parse a raw Slack webhook body into a typed payload. + + ``content_type`` wins over a ``content-type`` header when both are given; + with neither, the body shape decides (JSON object vs form-urlencoded). + """ + resolved_content_type = content_type if content_type is not None else get_header(headers, "content-type") + if resolved_content_type is None: + resolved_content_type = "" + retry = get_retry(headers) + + if is_form_body(body, resolved_content_type): + return _parse_form_body(body, retry) + + raw = parse_json_body(body) + return _classify_json_payload(raw, retry) + + +def _parse_form_body(body: str, retry: SlackRetry | None) -> SlackWebhookPayload: + params = dict(parse_qsl(body, keep_blank_values=True)) + payload = params.get("payload") + if payload is not None: + raw = parse_json_body(payload) + return _classify_interaction_payload(raw, retry) + if "command" in params: + return _parse_slash_command(params, retry) + return SlackUnsupportedPayload(raw=params, retry=retry, type="form") + + +def _classify_json_payload(raw: Any, retry: SlackRetry | None) -> SlackWebhookPayload: + if not is_record(raw): + return SlackUnsupportedPayload(raw=raw, retry=retry, type="unknown") + + if raw.get("type") == "url_verification" and isinstance(raw.get("challenge"), str): + return SlackUrlVerificationPayload(challenge=raw["challenge"], raw=raw, retry=retry) + + event = raw.get("event") + if raw.get("type") != "event_callback" or not is_record(event): + raw_type = raw.get("type") + return SlackUnsupportedPayload( + raw=raw, + retry=retry, + type=raw_type if isinstance(raw_type, str) else "unknown", + ) + + if event.get("type") == "app_mention": + return _parse_message_event("app_mention", raw, event, retry) + + if event.get("type") == "message" and event.get("channel_type") == "im": + return _parse_message_event("direct_message", raw, event, retry) + + event_type = event.get("type") + return SlackUnsupportedPayload( + raw=raw, + retry=retry, + type=event_type if isinstance(event_type, str) else "event_callback", + ) + + +def _classify_interaction_payload(raw: Any, retry: SlackRetry | None) -> SlackWebhookPayload: + if not is_record(raw): + return SlackUnsupportedPayload(raw=raw, retry=retry, type="interaction") + + payload_type = raw.get("type") + if payload_type == "block_actions": + return _parse_block_actions(raw, retry) + if payload_type == "block_suggestion": + return _parse_block_suggestion(raw, retry) + if payload_type == "view_submission": + return _parse_view_submission(raw, retry) + if payload_type == "view_closed": + return _parse_view_closed(raw, retry) + return SlackUnsupportedPayload( + raw=raw, + retry=retry, + type=payload_type if isinstance(payload_type, str) else "interaction", + ) + + +def _parse_message_event( + kind: Literal["app_mention", "direct_message"], + envelope: dict[str, Any], + event: dict[str, Any], + retry: SlackRetry | None, +) -> SlackAppMentionPayload | SlackDirectMessagePayload: + channel_id = string_value(event.get("channel")) + ts = string_value(event.get("ts")) + thread_ts = string_value(event.get("thread_ts")) or ts + team_id = optional_string(event.get("team_id")) or optional_string(envelope.get("team_id")) + enterprise_id = optional_string(envelope.get("enterprise_id")) or optional_string( + envelope.get("context_enterprise_id") + ) + continuation = SlackContinuation( + channel_id=channel_id, + enterprise_id=enterprise_id, + team_id=team_id, + thread_ts=thread_ts, + ) + event_time = envelope.get("event_time") + base: dict[str, Any] = { + "api_app_id": optional_string(envelope.get("api_app_id")), + "channel_id": channel_id, + "continuation": continuation, + "enterprise_id": enterprise_id, + "event_id": optional_string(envelope.get("event_id")), + "event_time": event_time if isinstance(event_time, (int, float)) and not isinstance(event_time, bool) else None, + "is_ext_shared_channel": ( + envelope.get("is_ext_shared_channel") if isinstance(envelope.get("is_ext_shared_channel"), bool) else None + ), + "raw": event, + "retry": retry, + "team_id": team_id, + "text": string_value(event.get("text")), + "thread_ts": thread_ts, + "ts": ts, + "user_id": optional_string(event.get("user")), + } + + if kind == "app_mention": + return SlackAppMentionPayload(**base) + + return SlackDirectMessagePayload( + **base, + bot_id=optional_string(event.get("bot_id")), + subtype=optional_string(event.get("subtype")), + ) + + +def _parse_slash_command(params: dict[str, str], retry: SlackRetry | None) -> SlackSlashCommandPayload: + return SlackSlashCommandPayload( + channel_id=params.get("channel_id", ""), + channel_name=params.get("channel_name") or None, + command=params.get("command", ""), + enterprise_id=params.get("enterprise_id") or None, + is_enterprise_install=params.get("is_enterprise_install") == "true", + raw=params, + response_url=params.get("response_url") or None, + retry=retry, + team_id=params.get("team_id") or None, + text=params.get("text", ""), + trigger_id=params.get("trigger_id") or None, + user_id=params.get("user_id", ""), + user_name=params.get("user_name") or None, + ) + + +def _parse_block_actions(raw: dict[str, Any], retry: SlackRetry | None) -> SlackBlockActionsPayload: + channel = record_value(raw.get("channel")) + container = record_value(raw.get("container")) + message = record_value(raw.get("message")) + user = record_value(raw.get("user")) + team = record_value(raw.get("team")) + enterprise = record_value(raw.get("enterprise")) + channel_id = optional_string(_get(channel, "id")) or optional_string(_get(container, "channel_id")) + message_ts = optional_string(_get(message, "ts")) or optional_string(_get(container, "message_ts")) + thread_ts = ( + optional_string(_get(message, "thread_ts")) or optional_string(_get(container, "thread_ts")) or message_ts + ) + team_id = optional_string(_get(team, "id")) or optional_string(_get(user, "team_id")) + enterprise_id = optional_string(_get(enterprise, "id")) or optional_string(_get(team, "enterprise_id")) + continuation = ( + SlackContinuation( + channel_id=channel_id, + enterprise_id=enterprise_id, + team_id=team_id, + thread_ts=thread_ts, + ) + if channel_id and thread_ts + else None + ) + actions = raw.get("actions") + + return SlackBlockActionsPayload( + actions=[_parse_action(action) for action in actions] if isinstance(actions, list) else [], + channel_id=channel_id, + continuation=continuation, + enterprise_id=enterprise_id, + is_enterprise_install=( + raw.get("is_enterprise_install") if isinstance(raw.get("is_enterprise_install"), bool) else None + ), + message_ts=message_ts, + raw=raw, + response_url=optional_string(raw.get("response_url")), + retry=retry, + team_id=team_id, + thread_ts=thread_ts, + trigger_id=optional_string(raw.get("trigger_id")), + user_id=string_value(_get(user, "id")), + user_name=optional_string(_get(user, "username")) or optional_string(_get(user, "name")), + ) + + +def _parse_action(action: Any) -> SlackAction: + raw = action if is_record(action) else {} + selected_option = record_value(raw.get("selected_option")) + text = record_value(raw.get("text")) + return SlackAction( + action_id=string_value(raw.get("action_id")), + block_id=optional_string(raw.get("block_id")), + label=optional_string(_get(text, "text")), + raw=raw, + selected_option_value=optional_string(_get(selected_option, "value")), + type=string_value(raw.get("type")), + value=optional_string(raw.get("value")), + ) + + +def _parse_block_suggestion(raw: dict[str, Any], retry: SlackRetry | None) -> SlackBlockSuggestionPayload: + channel = record_value(raw.get("channel")) + team = record_value(raw.get("team")) + enterprise = record_value(raw.get("enterprise")) + user = record_value(raw.get("user")) + return SlackBlockSuggestionPayload( + action_id=string_value(raw.get("action_id")), + block_id=string_value(raw.get("block_id")), + channel_id=optional_string(_get(channel, "id")), + enterprise_id=optional_string(_get(enterprise, "id")) or optional_string(_get(team, "enterprise_id")), + raw=raw, + retry=retry, + team_id=optional_string(_get(team, "id")), + user_id=string_value(_get(user, "id")), + value=string_value(raw.get("value")), + ) + + +def _parse_view_submission(raw: dict[str, Any], retry: SlackRetry | None) -> SlackViewSubmissionPayload: + team = record_value(raw.get("team")) + enterprise = record_value(raw.get("enterprise")) + user = record_value(raw.get("user")) + view = record_value(raw.get("view")) + if view is None: + view = {} + response_urls = view.get("response_urls") + return SlackViewSubmissionPayload( + enterprise_id=optional_string(_get(enterprise, "id")) or optional_string(_get(team, "enterprise_id")), + raw=raw, + response_urls=response_urls if isinstance(response_urls, list) else None, + retry=retry, + team_id=optional_string(_get(team, "id")), + user_id=string_value(_get(user, "id")), + view=view, + ) + + +def _parse_view_closed(raw: dict[str, Any], retry: SlackRetry | None) -> SlackViewClosedPayload: + team = record_value(raw.get("team")) + enterprise = record_value(raw.get("enterprise")) + user = record_value(raw.get("user")) + view = record_value(raw.get("view")) + return SlackViewClosedPayload( + enterprise_id=optional_string(_get(enterprise, "id")) or optional_string(_get(team, "enterprise_id")), + raw=raw, + retry=retry, + team_id=optional_string(_get(team, "id")), + user_id=string_value(_get(user, "id")), + view=view if view is not None else {}, + ) + + +def _get(record: dict[str, Any] | None, key: str) -> Any: + """Optional-chained lookup: ``record?.[key]``.""" + return record.get(key) if record is not None else None diff --git a/src/chat_sdk/adapters/slack/webhook/types.py b/src/chat_sdk/adapters/slack/webhook/types.py new file mode 100644 index 00000000..ddf4da91 --- /dev/null +++ b/src/chat_sdk/adapters/slack/webhook/types.py @@ -0,0 +1,242 @@ +"""Types for the Slack webhook primitives subpath. + +Port of ``packages/adapter-slack/src/webhook/types.ts`` (vercel/chat#538). + +These types describe the lightweight, runtime-free webhook surface: +verifying Slack requests, parsing Events API callbacks, slash commands and +interactive payloads — without the full Slack adapter, ``slack_sdk``, chat +state, dedupe, locks, or subscriptions. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import Any, Literal, TypeAlias + +# Headers as accepted by the webhook helpers. Either a mapping (plain dict, +# framework multidict — matched case-insensitively; multi-value entries use +# the first value) or an iterable of ``(name, value)`` pairs. +# +# Upstream: ``Headers | Iterable<[string, string]> | Record``. +SlackHeaders: TypeAlias = Mapping[str, Any] | Iterable[tuple[str, str]] + +# Custom webhook verifier. Receives the original request object and the raw +# body string already consumed by the caller. Return: +# - ``True`` (or any truthy non-string value) to accept the request as-is. +# - A ``str`` to accept *and* substitute the verified body for downstream +# parsing (useful when the verifier canonicalizes the payload). +# - ``False``/falsy or raise to reject. +# +# May be sync or async. +# +# SECURITY: When a custom verifier replaces ``signing_secret``, the built-in +# HMAC + timestamp tolerance check is bypassed. The implementer is +# responsible for: +# - constant-time signature comparison (use ``hmac.compare_digest``, never ``==``) +# - replay protection (validate ``x-slack-request-timestamp`` freshness) +# - any other freshness/origin checks the platform requires +# - body-substitution safety: when returning a ``str`` to substitute the body +# for downstream parsing, the returned bytes MUST be derived from a +# verified payload. Returning attacker-controlled bytes (e.g. echoing the +# unverified raw body or splicing in untrusted fields) grants payload +# injection — downstream parsing trusts the substituted body unconditionally. +SlackWebhookVerifier = Callable[[Any, str], "bool | str | None | Awaitable[bool | str | None]"] + + +@dataclass +class SlackRetry: + """Slack retry metadata from ``x-slack-retry-num`` / ``x-slack-retry-reason``.""" + + num: float + reason: str | None = None + + +@dataclass +class SlackContinuation: + """Provider-native continuation data for message-like payloads.""" + + channel_id: str + thread_ts: str + enterprise_id: str | None = None + team_id: str | None = None + + +@dataclass +class SlackUrlVerificationPayload: + """Slack ``url_verification`` handshake payload.""" + + challenge: str + raw: dict[str, Any] + retry: SlackRetry | None = None + kind: Literal["url_verification"] = "url_verification" + + +@dataclass +class _SlackEventBasePayload: + """Shared fields for Events API message-like payloads.""" + + channel_id: str + continuation: SlackContinuation + raw: dict[str, Any] + text: str + thread_ts: str + ts: str + api_app_id: str | None = None + enterprise_id: str | None = None + event_id: str | None = None + event_time: float | None = None + is_ext_shared_channel: bool | None = None + retry: SlackRetry | None = None + team_id: str | None = None + user_id: str | None = None + + +@dataclass +class SlackAppMentionPayload(_SlackEventBasePayload): + """An ``app_mention`` Events API callback.""" + + event_type: Literal["app_mention"] = "app_mention" + kind: Literal["app_mention"] = "app_mention" + + +@dataclass +class SlackDirectMessagePayload(_SlackEventBasePayload): + """A ``message`` Events API callback delivered in an IM channel.""" + + bot_id: str | None = None + subtype: str | None = None + event_type: Literal["message"] = "message" + kind: Literal["direct_message"] = "direct_message" + + +@dataclass +class SlackSlashCommandPayload: + """A slash-command form post.""" + + channel_id: str + command: str + is_enterprise_install: bool + raw: dict[str, str] + text: str + user_id: str + channel_name: str | None = None + enterprise_id: str | None = None + response_url: str | None = None + retry: SlackRetry | None = None + team_id: str | None = None + trigger_id: str | None = None + user_name: str | None = None + kind: Literal["slash_command"] = "slash_command" + + +@dataclass +class SlackAction: + """A single action inside a ``block_actions`` payload.""" + + action_id: str + raw: dict[str, Any] + type: str + block_id: str | None = None + label: str | None = None + selected_option_value: str | None = None + value: str | None = None + + +@dataclass +class SlackBlockActionsPayload: + """A ``block_actions`` interactive payload.""" + + actions: list[SlackAction] + raw: dict[str, Any] + user_id: str + channel_id: str | None = None + continuation: SlackContinuation | None = None + enterprise_id: str | None = None + is_enterprise_install: bool | None = None + message_ts: str | None = None + response_url: str | None = None + retry: SlackRetry | None = None + team_id: str | None = None + thread_ts: str | None = None + trigger_id: str | None = None + user_name: str | None = None + kind: Literal["block_actions"] = "block_actions" + + +@dataclass +class SlackBlockSuggestionPayload: + """A ``block_suggestion`` (external select options) payload.""" + + action_id: str + block_id: str + raw: dict[str, Any] + user_id: str + value: str + channel_id: str | None = None + enterprise_id: str | None = None + retry: SlackRetry | None = None + team_id: str | None = None + kind: Literal["block_suggestion"] = "block_suggestion" + + +@dataclass +class SlackViewSubmissionPayload: + """A ``view_submission`` interactive payload.""" + + raw: dict[str, Any] + user_id: str + view: dict[str, Any] + enterprise_id: str | None = None + response_urls: list[Any] | None = None + retry: SlackRetry | None = None + team_id: str | None = None + kind: Literal["view_submission"] = "view_submission" + + +@dataclass +class SlackViewClosedPayload: + """A ``view_closed`` interactive payload.""" + + raw: dict[str, Any] + user_id: str + view: dict[str, Any] + enterprise_id: str | None = None + retry: SlackRetry | None = None + team_id: str | None = None + kind: Literal["view_closed"] = "view_closed" + + +@dataclass +class SlackUnsupportedPayload: + """Any payload the primitives recognize but do not model.""" + + raw: Any + type: str + retry: SlackRetry | None = None + kind: Literal["unsupported"] = "unsupported" + + +SlackWebhookPayload: TypeAlias = ( + SlackAppMentionPayload + | SlackBlockActionsPayload + | SlackBlockSuggestionPayload + | SlackDirectMessagePayload + | SlackSlashCommandPayload + | SlackUnsupportedPayload + | SlackUrlVerificationPayload + | SlackViewClosedPayload + | SlackViewSubmissionPayload +) + + +class SlackWebhookError(Exception): + """Base error for the Slack webhook primitives.""" + + +class SlackWebhookVerificationError(SlackWebhookError): + """Raised when a Slack request fails verification.""" + + +class SlackWebhookParseError(SlackWebhookError): + """Raised when a Slack webhook body cannot be parsed.""" diff --git a/src/chat_sdk/adapters/slack/webhook/utils.py b/src/chat_sdk/adapters/slack/webhook/utils.py new file mode 100644 index 00000000..7faa9b00 --- /dev/null +++ b/src/chat_sdk/adapters/slack/webhook/utils.py @@ -0,0 +1,148 @@ +"""Helpers for the Slack webhook primitives subpath. + +Port of ``packages/adapter-slack/src/webhook/utils.ts`` (vercel/chat#538), +plus :func:`read_slack_request_body` — the Python stand-in for the Fetch +API's ``await request.text()`` used by the upstream verify helpers. Python +web frameworks expose request bodies through several duck-typed shapes, so +the extraction lives here as a shared primitive (the high-level adapter +uses the same implementation). +""" + +from __future__ import annotations + +import inspect +import json +import math +from collections.abc import Iterable +from typing import Any, cast + +from chat_sdk.adapters.slack.webhook.types import ( + SlackHeaders, + SlackRetry, + SlackWebhookParseError, +) + + +def get_header(headers: SlackHeaders | None, name: str) -> str | None: + """Read a header case-insensitively from a mapping or pair-iterable.""" + if headers is None: + return None + lower = name.lower() + items: Iterable[tuple[Any, Any]] + # Prefer ``.items()`` (dicts, framework multidicts, header objects) over + # bare iteration — most header containers iterate over *keys*, not pairs. + items_attr = getattr(headers, "items", None) + if callable(items_attr): + items = cast("Iterable[tuple[Any, Any]]", items_attr()) + elif isinstance(headers, Iterable) and not isinstance(headers, (str, bytes)): + items = cast("Iterable[tuple[Any, Any]]", headers) + else: + return None + for key, value in items: + if str(key).lower() == lower: + return _header_value(value) + return None + + +def get_retry(headers: SlackHeaders | None) -> SlackRetry | None: + """Parse Slack retry headers into a :class:`SlackRetry`, if present.""" + retry_num = get_header(headers, "x-slack-retry-num") + if not retry_num: + return None + num = _finite_number(retry_num) + if num is None: + return None + return SlackRetry(num=num, reason=get_header(headers, "x-slack-retry-reason")) + + +def is_form_body(body: str, content_type: str) -> bool: + """Decide whether a body should be parsed as form-urlencoded.""" + if "application/x-www-form-urlencoded" in content_type: + return True + if "application/json" in content_type: + return False + trimmed = body.lstrip() + return not trimmed.startswith("{") and "=" in body + + +def parse_json_body(body: str) -> Any: + """Parse a JSON body, raising :class:`SlackWebhookParseError` on failure.""" + try: + return json.loads(body) + except (json.JSONDecodeError, ValueError) as exc: + raise SlackWebhookParseError("Slack webhook body is invalid JSON") from exc + + +def is_record(value: Any) -> bool: + """True when ``value`` is a JSON object (dict).""" + return isinstance(value, dict) + + +def record_value(value: Any) -> dict[str, Any] | None: + """Return ``value`` when it is a dict, else ``None``.""" + return value if isinstance(value, dict) else None + + +def string_value(value: Any) -> str: + """Return ``value`` when it is a string, else ``""``.""" + return value if isinstance(value, str) else "" + + +def optional_string(value: Any) -> str | None: + """Return ``value`` when it is a non-empty string, else ``None``.""" + text = string_value(value) + return text if text else None + + +async def read_slack_request_body(request: Any) -> str: + """Read the raw body from a duck-typed request object. + + Python stand-in for the Fetch API's ``await request.text()``. Supports: + + - ``request.text`` as an (async or sync) method or plain attribute + - ``request.body`` as an (async or sync) method, awaitable, bytes, or str + - falling back to ``str(request)`` + + Bytes are decoded as UTF-8. + """ + text_attr = getattr(request, "text", None) + if text_attr is not None: + if callable(text_attr): + result = text_attr() + text_attr = await result if inspect.isawaitable(result) else result + return text_attr.decode("utf-8") if isinstance(text_attr, (bytes, bytearray)) else str(text_attr) + raw = getattr(request, "body", None) + if raw is not None: + # Some frameworks expose ``body`` as an async method (e.g. + # ``async def body(self)``) — call it, then await if the result is + # awaitable. Covers both the coroutine-as-attribute case and the + # async-method case. + if callable(raw): + raw = raw() + if inspect.isawaitable(raw): + raw = await raw + return raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw) + return str(request) + + +def _header_value(value: Any) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, (list, tuple)): + first = value[0] if value else None + return first if isinstance(first, str) else None + return None + + +def _finite_number(text: str) -> float | int | None: + """Parse a header numeric the way JS ``Number()`` would, or ``None``.""" + stripped = text.strip() + try: + return int(stripped) + except ValueError: + pass + try: + num = float(stripped) + except ValueError: + return None + return num if math.isfinite(num) else None diff --git a/src/chat_sdk/adapters/slack/webhook/verify.py b/src/chat_sdk/adapters/slack/webhook/verify.py new file mode 100644 index 00000000..6c2e4dc7 --- /dev/null +++ b/src/chat_sdk/adapters/slack/webhook/verify.py @@ -0,0 +1,163 @@ +"""Verification for the Slack webhook primitives subpath. + +Port of ``packages/adapter-slack/src/webhook/verify.ts`` (vercel/chat#538). + +Python-specific notes (intentional divergences from the TS surface): + +- ``verify_slack_signature`` is synchronous — Python's ``hmac`` needs no + WebCrypto-style async key import. +- ``verify_slack_request`` / ``read_slack_webhook`` accept a pre-read + ``body=`` because duck-typed Python request bodies are not always + re-readable (the Fetch API's ``request.text()`` has no universal Python + equivalent). When omitted, the body is read via + :func:`~chat_sdk.adapters.slack.webhook.utils.read_slack_request_body`. +- ``now`` returns epoch **seconds** (like ``time.time``), not milliseconds. +""" + +from __future__ import annotations + +import hashlib +import hmac +import inspect +import math +import re +import time +from collections.abc import Callable +from typing import Any + +from chat_sdk.adapters.slack.webhook.parse import parse_slack_webhook_body +from chat_sdk.adapters.slack.webhook.types import ( + SlackHeaders, + SlackWebhookPayload, + SlackWebhookVerificationError, + SlackWebhookVerifier, +) +from chat_sdk.adapters.slack.webhook.utils import get_header, read_slack_request_body + +_HEX_PATTERN = re.compile(r"^[0-9a-f]+$", re.IGNORECASE) +_DEFAULT_MAX_SKEW_SECONDS = 300 + + +async def read_slack_webhook( + request: Any, + *, + body: str | None = None, + content_type: str | None = None, + signing_secret: str | None = None, + webhook_verifier: SlackWebhookVerifier | None = None, + max_skew_seconds: float | None = None, + now: Callable[[], float] | None = None, +) -> SlackWebhookPayload: + """Verify a Slack request and parse its body into a typed payload.""" + verified_body = await verify_slack_request( + request, + body=body, + signing_secret=signing_secret, + webhook_verifier=webhook_verifier, + max_skew_seconds=max_skew_seconds, + now=now, + ) + return parse_slack_webhook_body( + verified_body, + content_type=content_type, + headers=getattr(request, "headers", None), + ) + + +async def verify_slack_request( + request: Any, + *, + body: str | None = None, + signing_secret: str | None = None, + webhook_verifier: SlackWebhookVerifier | None = None, + max_skew_seconds: float | None = None, + now: Callable[[], float] | None = None, +) -> str: + """Verify a Slack request and return its (possibly substituted) body. + + A configured ``webhook_verifier`` takes precedence over + ``signing_secret``; see + :data:`~chat_sdk.adapters.slack.webhook.types.SlackWebhookVerifier` + for the security contract. Raises + :class:`SlackWebhookVerificationError` on rejection. + """ + if body is None: + body = await read_slack_request_body(request) + if webhook_verifier is not None: + result = webhook_verifier(request, body) + if inspect.isawaitable(result): + result = await result + if not result: + raise SlackWebhookVerificationError("Slack webhook verifier rejected the request") + return result if isinstance(result, str) else body + + verify_slack_signature( + body, + getattr(request, "headers", None), + signing_secret=signing_secret, + max_skew_seconds=max_skew_seconds, + now=now, + ) + return body + + +def verify_slack_signature( + body: str, + headers: SlackHeaders | None, + *, + signing_secret: str | None, + max_skew_seconds: float | None = None, + now: Callable[[], float] | None = None, +) -> None: + """Verify Slack's ``v0`` HMAC-SHA256 request signature. + + Raises :class:`SlackWebhookVerificationError` when the secret is + missing, the signature headers are absent or malformed, the timestamp + is outside the skew window (default 300 seconds), or the digest does + not match. Returns ``None`` on success. + """ + if not signing_secret: + raise SlackWebhookVerificationError("Slack signing secret is required") + + timestamp = get_header(headers, "x-slack-request-timestamp") + signature = get_header(headers, "x-slack-signature") + if not (timestamp and signature): + raise SlackWebhookVerificationError("Slack signature headers are required") + + try: + timestamp_seconds = float(timestamp) + except ValueError as exc: + raise SlackWebhookVerificationError("Slack timestamp is invalid") from exc + if not math.isfinite(timestamp_seconds): + raise SlackWebhookVerificationError("Slack timestamp is invalid") + + now_seconds = math.floor(now() if now is not None else time.time()) + skew = max_skew_seconds if max_skew_seconds is not None else _DEFAULT_MAX_SKEW_SECONDS + if abs(now_seconds - timestamp_seconds) > skew: + raise SlackWebhookVerificationError("Slack timestamp is too old") + + if not _verify_slack_signature_value(body, signing_secret, timestamp, signature): + raise SlackWebhookVerificationError("Slack signature is invalid") + + +def _verify_slack_signature_value(body: str, signing_secret: str, timestamp: str, signature: str) -> bool: + provided = _parse_slack_signature(signature) + expected = hmac.new( + signing_secret.encode("utf-8"), + f"v0:{timestamp}:{body}".encode(), + hashlib.sha256, + ).digest() + # ``hmac.compare_digest`` is the canonical constant-time comparison. + # A regression to ``==`` would leak signature bytes via timing. + return hmac.compare_digest(provided, expected) + + +def _parse_slack_signature(signature: str) -> bytes: + if not signature.startswith("v0="): + raise SlackWebhookVerificationError("Slack signature is invalid") + + hex_part = signature[3:] + if len(hex_part) % 2 != 0 or not _HEX_PATTERN.match(hex_part): + raise SlackWebhookVerificationError("Slack signature is invalid") + + return bytes.fromhex(hex_part) diff --git a/tests/test_slack_adapter.py b/tests/test_slack_adapter.py index 4cf58be5..7395cee4 100644 --- a/tests/test_slack_adapter.py +++ b/tests/test_slack_adapter.py @@ -2,10 +2,7 @@ from __future__ import annotations -import hashlib -import hmac import os -import time import pytest @@ -118,62 +115,6 @@ def test_channel_visibility_unknown(self): assert vis == "unknown" -# --------------------------------------------------------------------------- -# verify_signature -# --------------------------------------------------------------------------- - - -class TestSlackVerifySignature: - """Tests for _verify_signature.""" - - def test_valid_signature(self): - adapter = _make_adapter(signing_secret="my-signing-secret") - body = "request body content" - timestamp = str(int(time.time())) - sig_basestring = f"v0:{timestamp}:{body}" - expected_sig = ( - "v0=" - + hmac.new( - b"my-signing-secret", - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - assert adapter._verify_signature(body, timestamp, expected_sig) is True - - def test_invalid_signature(self): - adapter = _make_adapter(signing_secret="my-signing-secret") - timestamp = str(int(time.time())) - assert adapter._verify_signature("body", timestamp, "v0=invalid") is False - - def test_none_timestamp(self): - adapter = _make_adapter() - assert adapter._verify_signature("body", None, "v0=sig") is False - - def test_none_signature(self): - adapter = _make_adapter() - assert adapter._verify_signature("body", str(int(time.time())), None) is False - - def test_old_timestamp_rejected(self): - adapter = _make_adapter(signing_secret="secret") - body = "test" - old_timestamp = str(int(time.time()) - 600) # 10 minutes ago - sig_basestring = f"v0:{old_timestamp}:{body}" - sig = ( - "v0=" - + hmac.new( - b"secret", - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - assert adapter._verify_signature(body, old_timestamp, sig) is False - - def test_non_numeric_timestamp(self): - adapter = _make_adapter() - assert adapter._verify_signature("body", "not-a-number", "v0=sig") is False - - # --------------------------------------------------------------------------- # create_slack_adapter factory # --------------------------------------------------------------------------- diff --git a/tests/test_slack_dynamic_token_and_verifier.py b/tests/test_slack_dynamic_token_and_verifier.py index 0c74f9af..369cec3e 100644 --- a/tests/test_slack_dynamic_token_and_verifier.py +++ b/tests/test_slack_dynamic_token_and_verifier.py @@ -1136,19 +1136,28 @@ def test_default_verifier_uses_constant_time_compare(self): """Spot-check: the default verifier path uses ``hmac.compare_digest``. A regression to ``==`` would leak signature bytes via timing. This - test inspects the source of ``_verify_signature`` to assert the - primitive has not been swapped out. The regex requires an actual - ``hmac.compare_digest(`` call — a passing mention in a comment or - docstring (e.g. ``# use hmac.compare_digest, never ==``) does not + test inspects the source of the shared ``verify_slack_signature`` + primitive (``chat_sdk.adapters.slack.webhook.verify`` — the single + implementation behind the adapter since the vercel/chat#538 port) to + assert the primitive has not been swapped out. The regex requires an + actual ``hmac.compare_digest(`` call — a passing mention in a comment + or docstring (e.g. ``# use hmac.compare_digest, never ==``) does not satisfy the assertion. """ import inspect as _inspect import re as _re - src = _inspect.getsource(SlackAdapter._verify_signature) + from chat_sdk.adapters.slack.webhook import verify as _verify_module + + src = _inspect.getsource(_verify_module._verify_slack_signature_value) assert _re.search(r"\bhmac\.compare_digest\s*\(", src), ( "default signature verifier must call hmac.compare_digest(...) for constant-time comparison" ) + # And the adapter must still route through the shared primitive. + adapter_src = _inspect.getsource(SlackAdapter.handle_webhook) + assert _re.search(r"\bverify_slack_request\s*\(", adapter_src), ( + "SlackAdapter.handle_webhook must verify via the shared verify_slack_request primitive" + ) @pytest.mark.asyncio async def test_custom_verifier_is_a_security_escape_hatch(self): diff --git a/tests/test_slack_webhook_primitives.py b/tests/test_slack_webhook_primitives.py new file mode 100644 index 00000000..1f9c2ac8 --- /dev/null +++ b/tests/test_slack_webhook_primitives.py @@ -0,0 +1,549 @@ +"""Tests for the Slack webhook primitives subpath. + +Port of ``packages/adapter-slack/src/webhook/index.test.ts`` and +``webhook/boundary.test.ts`` (vercel/chat#538). +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import subprocess +import sys +from typing import Any +from unittest.mock import Mock +from urllib.parse import parse_qsl, urlencode + +import pytest + +from chat_sdk.adapters.slack.webhook import ( + SlackContinuation, + SlackRetry, + SlackWebhookParseError, + SlackWebhookVerificationError, + parse_slack_webhook_body, + read_slack_webhook, + verify_slack_request, + verify_slack_signature, +) + +SECRET = "8f742231b10e8888abcd99yyyzzz85a5" +TIMESTAMP = 1_531_420_618 + + +def _now() -> float: + return TIMESTAMP + + +def _sign(body: str, time: int = TIMESTAMP) -> str: + digest = hmac.new(SECRET.encode(), f"v0:{time}:{body}".encode(), hashlib.sha256).hexdigest() + return f"v0={digest}" + + +def _headers(body: str, time: int = TIMESTAMP) -> dict[str, str]: + return { + "content-type": "application/json", + "x-slack-request-timestamp": str(time), + "x-slack-signature": _sign(body, time), + } + + +class _FakeRequest: + """Minimal request-like object (body + headers, async ``text()``).""" + + def __init__(self, body: str, headers: dict[str, str] | None = None): + self._body = body + self.headers = headers or {} + + async def text(self) -> str: + return self._body + + +def _request(body: str, *, content_type: str = "application/json", time: int = TIMESTAMP) -> _FakeRequest: + return _FakeRequest( + body, + { + "content-type": content_type, + "x-slack-request-timestamp": str(time), + "x-slack-signature": _sign(body, time), + }, + ) + + +class TestVerifySlackSignature: + def test_accepts_a_valid_slack_signature(self): + body = ( + "token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow" + "&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner" + "&command=%2Fwebhook-collect&text=" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN" + "&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c" + ) + + assert verify_slack_signature(body, _headers(body), signing_secret=SECRET, now=_now) is None + + def test_rejects_stale_timestamps(self): + body = "payload" + + with pytest.raises(SlackWebhookVerificationError): + verify_slack_signature(body, _headers(body, TIMESTAMP - 301), signing_secret=SECRET, now=_now) + + def test_rejects_invalid_signatures(self): + body = "payload" + signed_headers = _headers(body) + signed_headers["x-slack-signature"] = "v0=bad" + + with pytest.raises(SlackWebhookVerificationError): + verify_slack_signature(body, signed_headers, signing_secret=SECRET, now=_now) + + def test_rejects_well_formed_signatures_with_the_wrong_digest(self): + body = "payload" + signed_headers = _headers(body) + signed_headers["x-slack-signature"] = "v0=" + "0" * 64 + + with pytest.raises(SlackWebhookVerificationError): + verify_slack_signature(body, signed_headers, signing_secret=SECRET, now=_now) + + def test_accepts_plain_object_headers_case_insensitively(self): + body = "payload" + + result = verify_slack_signature( + body, + { + "Content-Type": "application/json", + "X-Slack-Request-Timestamp": str(TIMESTAMP), + "X-Slack-Signature": _sign(body), + }, + signing_secret=SECRET, + now=_now, + ) + + assert result is None + + def test_rejects_when_signing_secret_is_missing(self): + """Fail closed: no secret means no verification is possible.""" + body = "payload" + + with pytest.raises(SlackWebhookVerificationError, match="signing secret"): + verify_slack_signature(body, _headers(body), signing_secret=None, now=_now) + + def test_rejects_missing_signature_headers(self): + body = "payload" + + with pytest.raises(SlackWebhookVerificationError, match="headers are required"): + verify_slack_signature( + body, + {"x-slack-signature": _sign(body)}, + signing_secret=SECRET, + now=_now, + ) + with pytest.raises(SlackWebhookVerificationError, match="headers are required"): + verify_slack_signature( + body, + {"x-slack-request-timestamp": str(TIMESTAMP)}, + signing_secret=SECRET, + now=_now, + ) + + def test_rejects_non_numeric_timestamps(self): + body = "payload" + + with pytest.raises(SlackWebhookVerificationError, match="timestamp is invalid"): + verify_slack_signature( + body, + { + "x-slack-request-timestamp": "not-a-number", + "x-slack-signature": _sign(body), + }, + signing_secret=SECRET, + now=_now, + ) + + +class TestVerifySlackRequest: + @pytest.mark.asyncio + async def test_returns_the_verified_body(self): + body = json.dumps({"type": "event_callback"}) + + result = await verify_slack_request(_request(body), signing_secret=SECRET, now=_now) + + assert result == body + + @pytest.mark.asyncio + async def test_uses_a_custom_verifier(self): + verifier = Mock(return_value=True) + body = "payload" + request = _FakeRequest(body) + + result = await verify_slack_request(request, webhook_verifier=verifier) + + assert result == body + verifier.assert_called_once_with(request, body) + + @pytest.mark.asyncio + async def test_rejects_when_custom_verifier_returns_falsy(self): + async def verifier(_request: Any, _body: str) -> bool: + return False + + with pytest.raises(SlackWebhookVerificationError): + await verify_slack_request(_FakeRequest("payload"), webhook_verifier=verifier) + + @pytest.mark.asyncio + async def test_allows_a_custom_verifier_to_replace_the_body(self): + verified_body = json.dumps({"challenge": "challenge-value", "type": "url_verification"}) + + payload = await read_slack_webhook( + _FakeRequest("original"), + webhook_verifier=lambda _request, _body: verified_body, + ) + + assert payload.kind == "url_verification" + assert payload.challenge == "challenge-value" + assert payload.raw == {"challenge": "challenge-value", "type": "url_verification"} + assert payload.retry is None + + +class TestParseSlackWebhookBody: + def test_parses_url_verification_payloads(self): + payload = parse_slack_webhook_body( + json.dumps( + { + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "token": "deprecated", + "type": "url_verification", + } + ), + content_type="application/json", + ) + + assert payload.kind == "url_verification" + assert payload.challenge == "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P" + + def test_parses_app_mentions_with_provider_native_continuation(self): + payload = parse_slack_webhook_body( + json.dumps( + { + "api_app_id": "A123", + "event": { + "channel": "C123", + "text": "<@U999> hello", + "thread_ts": "1710000000.000001", + "ts": "1710000000.000002", + "type": "app_mention", + "user": "U123", + }, + "event_id": "Ev123", + "event_time": 1_710_000_000, + "is_ext_shared_channel": True, + "team_id": "T123", + "type": "event_callback", + } + ), + content_type="application/json", + headers={ + "x-slack-retry-num": "2", + "x-slack-retry-reason": "http_timeout", + }, + ) + + assert payload.kind == "app_mention" + assert payload.api_app_id == "A123" + assert payload.channel_id == "C123" + assert payload.continuation == SlackContinuation( + channel_id="C123", + team_id="T123", + thread_ts="1710000000.000001", + ) + assert payload.event_id == "Ev123" + assert payload.event_time == 1_710_000_000 + assert payload.is_ext_shared_channel is True + assert payload.retry == SlackRetry(num=2, reason="http_timeout") + assert payload.text == "<@U999> hello" + assert payload.thread_ts == "1710000000.000001" + assert payload.ts == "1710000000.000002" + assert payload.user_id == "U123" + + def test_uses_ts_as_thread_ts_when_app_mentions_are_top_level_messages(self): + payload = parse_slack_webhook_body( + json.dumps( + { + "event": { + "channel": "C123", + "text": "hello", + "ts": "1710000000.000002", + "type": "app_mention", + "user": "U123", + }, + "team_id": "T123", + "type": "event_callback", + } + ), + content_type="application/json", + ) + + assert payload.kind == "app_mention" + assert payload.continuation.channel_id == "C123" + assert payload.continuation.thread_ts == "1710000000.000002" + assert payload.thread_ts == "1710000000.000002" + + def test_parses_direct_message_events(self): + payload = parse_slack_webhook_body( + json.dumps( + { + "event": { + "bot_id": "B123", + "channel": "D123", + "channel_type": "im", + "subtype": "bot_message", + "text": "hello", + "ts": "1710000000.000002", + "type": "message", + "user": "U123", + }, + "team_id": "T123", + "type": "event_callback", + } + ) + ) + + assert payload.kind == "direct_message" + assert payload.bot_id == "B123" + assert payload.channel_id == "D123" + assert payload.subtype == "bot_message" + + def test_parses_slash_command_form_posts(self): + form = { + "channel_id": "C123", + "channel_name": "general", + "command": "/deploy", + "enterprise_id": "E123", + "is_enterprise_install": "true", + "response_url": "https://hooks.slack.com/commands/T123/1/abc", + "team_id": "T123", + "text": "prod", + "trigger_id": "123.456.abc", + "user_id": "U123", + "user_name": "josh", + } + body = urlencode(form) + + payload = parse_slack_webhook_body(body, content_type="application/x-www-form-urlencoded") + + assert payload.kind == "slash_command" + assert payload.channel_id == "C123" + assert payload.channel_name == "general" + assert payload.command == "/deploy" + assert payload.enterprise_id == "E123" + assert payload.is_enterprise_install is True + assert payload.raw == dict(parse_qsl(body)) + assert payload.response_url == "https://hooks.slack.com/commands/T123/1/abc" + assert payload.retry is None + assert payload.team_id == "T123" + assert payload.text == "prod" + assert payload.trigger_id == "123.456.abc" + assert payload.user_id == "U123" + assert payload.user_name == "josh" + + def test_parses_block_action_payloads(self): + raw = { + "actions": [ + { + "action_id": "approve", + "block_id": "actions", + "selected_option": {"value": "yes"}, + "text": {"text": "Approve", "type": "plain_text"}, + "type": "button", + "value": "approve-value", + } + ], + "channel": {"id": "C123", "name": "general"}, + "container": { + "channel_id": "C123", + "message_ts": "1710000000.000002", + "thread_ts": "1710000000.000001", + "type": "message", + }, + "message": { + "thread_ts": "1710000000.000001", + "ts": "1710000000.000002", + }, + "response_url": "https://hooks.slack.com/actions/T123/1/abc", + "team": {"enterprise_id": "E123", "id": "T123"}, + "trigger_id": "123.456.abc", + "type": "block_actions", + "user": {"id": "U123", "username": "josh"}, + } + body = urlencode({"payload": json.dumps(raw)}) + + payload = parse_slack_webhook_body(body, content_type="application/x-www-form-urlencoded") + + assert payload.kind == "block_actions" + assert len(payload.actions) == 1 + action = payload.actions[0] + assert action.action_id == "approve" + assert action.block_id == "actions" + assert action.label == "Approve" + assert action.selected_option_value == "yes" + assert action.type == "button" + assert action.value == "approve-value" + assert payload.channel_id == "C123" + assert payload.continuation == SlackContinuation( + channel_id="C123", + enterprise_id="E123", + team_id="T123", + thread_ts="1710000000.000001", + ) + assert payload.message_ts == "1710000000.000002" + assert payload.response_url == "https://hooks.slack.com/actions/T123/1/abc" + assert payload.team_id == "T123" + assert payload.thread_ts == "1710000000.000001" + assert payload.trigger_id == "123.456.abc" + assert payload.user_id == "U123" + assert payload.user_name == "josh" + + def test_parses_block_suggestion_payloads(self): + raw = { + "action_id": "external", + "block_id": "input", + "channel": {"id": "C123"}, + "enterprise": {"id": "E123"}, + "team": {"id": "T123"}, + "type": "block_suggestion", + "user": {"id": "U123"}, + "value": "hel", + } + + payload = parse_slack_webhook_body( + urlencode({"payload": json.dumps(raw)}), + content_type="application/x-www-form-urlencoded", + ) + + assert payload.kind == "block_suggestion" + assert payload.action_id == "external" + assert payload.block_id == "input" + assert payload.channel_id == "C123" + assert payload.enterprise_id == "E123" + assert payload.team_id == "T123" + assert payload.user_id == "U123" + assert payload.value == "hel" + + def test_parses_view_submissions(self): + raw = { + "team": {"id": "T123"}, + "type": "view_submission", + "user": {"id": "U123"}, + "view": { + "callback_id": "feedback", + "id": "V123", + "response_urls": [ + { + "action_id": "target", + "channel_id": "C123", + "response_url": "https://hooks.slack.com/app/1/2/3", + } + ], + }, + } + + payload = parse_slack_webhook_body( + urlencode({"payload": json.dumps(raw)}), + content_type="application/x-www-form-urlencoded", + ) + + assert payload.kind == "view_submission" + assert payload.response_urls == [ + { + "action_id": "target", + "channel_id": "C123", + "response_url": "https://hooks.slack.com/app/1/2/3", + } + ] + assert payload.team_id == "T123" + assert payload.user_id == "U123" + assert payload.view["callback_id"] == "feedback" + assert payload.view["id"] == "V123" + + def test_parses_view_closed_payloads(self): + raw = { + "enterprise": {"id": "E123"}, + "team": None, + "type": "view_closed", + "user": {"id": "U123"}, + "view": {"id": "V123"}, + } + + payload = parse_slack_webhook_body( + urlencode({"payload": json.dumps(raw)}), + content_type="application/x-www-form-urlencoded", + ) + + assert payload.kind == "view_closed" + assert payload.enterprise_id == "E123" + assert payload.team_id is None + assert payload.user_id == "U123" + assert payload.view == {"id": "V123"} + + def test_returns_unsupported_for_valid_but_unsupported_payloads(self): + payload = parse_slack_webhook_body( + json.dumps( + { + "event": {"type": "reaction_added"}, + "type": "event_callback", + } + ) + ) + + assert payload.kind == "unsupported" + assert payload.raw == {"event": {"type": "reaction_added"}, "type": "event_callback"} + assert payload.retry is None + assert payload.type == "reaction_added" + + def test_throws_a_parse_error_for_invalid_json(self): + with pytest.raises(SlackWebhookParseError): + parse_slack_webhook_body("{", content_type="application/json") + + +class TestReadSlackWebhook: + @pytest.mark.asyncio + async def test_verifies_and_parses_requests(self): + body = json.dumps({"challenge": "challenge-value", "type": "url_verification"}) + + payload = await read_slack_webhook(_request(body), signing_secret=SECRET, now=_now) + + assert payload.kind == "url_verification" + assert payload.challenge == "challenge-value" + + @pytest.mark.asyncio + async def test_rejects_tampered_request_bodies(self): + request = _request(json.dumps({"type": "url_verification", "challenge": "x"})) + request._body = json.dumps({"type": "url_verification", "challenge": "evil"}) + + with pytest.raises(SlackWebhookVerificationError): + await read_slack_webhook(request, signing_secret=SECRET, now=_now) + + +class TestWebhookImportBoundary: + def test_does_not_import_the_full_adapter_or_runtime_packages(self): + """Importing the webhook subpath must not pull in slack_sdk, HTTP + clients, or the high-level adapter module (port of upstream's + ``webhook/boundary.test.ts``).""" + code = ( + "import sys\n" + "import chat_sdk.adapters.slack.webhook\n" + "forbidden = [\n" + " 'slack_sdk',\n" + " 'httpx',\n" + " 'aiohttp',\n" + " 'chat_sdk.adapters.slack.adapter',\n" + "]\n" + "loaded = [name for name in forbidden if name in sys.modules]\n" + "assert not loaded, f'webhook subpath imported runtime modules: {loaded}'\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr From 852c1892c18192d701871b6338cfec415fb9f6fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 23:02:37 +0000 Subject: [PATCH 2/5] feat(slack): format primitives subpath (vercel/chat#547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/chat_sdk/adapters/slack/format.py | 190 ++++++++++++++++++++++++++ tests/test_slack_format_primitives.py | 154 +++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/chat_sdk/adapters/slack/format.py create mode 100644 tests/test_slack_format_primitives.py diff --git a/src/chat_sdk/adapters/slack/format.py b/src/chat_sdk/adapters/slack/format.py new file mode 100644 index 00000000..66bebb6e --- /dev/null +++ b/src/chat_sdk/adapters/slack/format.py @@ -0,0 +1,190 @@ +"""Slack format primitives — a lightweight, runtime-free subpath. + +Port of ``packages/adapter-slack/src/format/index.ts`` (vercel/chat#547), +exposed upstream as ``@chat-adapter/slack/format``. Provides runtime-free +primitives for Slack text objects, mrkdwn escaping, mentions, links, dates, +and basic mrkdwn normalization — without the full Slack adapter, +``slack_sdk``, or the chat runtime. + +Importing this module never imports ``slack_sdk``, HTTP clients, or the +high-level :mod:`chat_sdk.adapters.slack.adapter`. +""" + +from __future__ import annotations + +import math +import re +from datetime import datetime +from typing import Literal, NotRequired, TypedDict + + +class SlackPlainTextObject(TypedDict): + """A Slack ``plain_text`` composition object.""" + + emoji: NotRequired[bool] + text: str + type: Literal["plain_text"] + + +class SlackMrkdwnTextObject(TypedDict): + """A Slack ``mrkdwn`` composition object.""" + + text: str + type: Literal["mrkdwn"] + verbatim: NotRequired[bool] + + +SlackTextObject = SlackMrkdwnTextObject | SlackPlainTextObject + +_CONTROL_PATTERN = re.compile(r"[<>|]") +_DATE_CONTROL_PATTERN = re.compile(r"[\^|>]") +_SLACK_ID_PATTERN = re.compile(r"^[A-Z0-9_]+$") +_SLACK_USER_TOKEN_PATTERN = re.compile(r"(? str: + """Escape Slack mrkdwn control characters (``&``, ``<``, ``>``).""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def unescape_slack_text(text: str) -> str: + """Reverse :func:`escape_slack_text`.""" + return text.replace("<", "<").replace(">", ">").replace("&", "&") + + +def create_slack_plain_text(text: str, *, emoji: bool | None = None) -> SlackPlainTextObject: + """Create a ``plain_text`` text object (1–3000 characters).""" + _assert_slack_text_object_text(text) + obj: SlackPlainTextObject = {"text": text, "type": "plain_text"} + if emoji is not None: + obj = {"emoji": emoji, "text": text, "type": "plain_text"} + return obj + + +def create_slack_mrkdwn(text: str, *, verbatim: bool | None = None) -> SlackMrkdwnTextObject: + """Create a ``mrkdwn`` text object (1–3000 characters).""" + _assert_slack_text_object_text(text) + obj: SlackMrkdwnTextObject = {"text": text, "type": "mrkdwn"} + if verbatim is not None: + obj["verbatim"] = verbatim + return obj + + +def format_slack_user(user_id: str) -> str: + """Format a user mention: ``<@U123>``.""" + _assert_slack_id(user_id, "user_id") + return f"<@{user_id}>" + + +def format_slack_channel(channel_id: str) -> str: + """Format a channel mention: ``<#C123>``.""" + _assert_slack_id(channel_id, "channel_id") + return f"<#{channel_id}>" + + +def format_slack_user_group(user_group_id: str) -> str: + """Format a user-group mention: ````.""" + _assert_slack_id(user_group_id, "user_group_id") + return f"" + + +def format_slack_special_mention(mention: Literal["channel", "everyone", "here"]) -> str: + """Format a special mention: ```` / ```` / ````.""" + return f"" + + +def format_slack_link(url: str, label: str | None = None) -> str: + """Format a link, escaping the label: ```` or ````.""" + _assert_no_slack_control(url, "url") + return f"<{url}|{escape_slack_text(label)}>" if label else f"<{url}>" + + +def format_slack_date( + timestamp: datetime | int | float, + token: str, + fallback: str, + *, + link: str | None = None, +) -> str: + """Format a localized date token: ````. + + ``timestamp`` is an integer unix timestamp (seconds) or a + :class:`~datetime.datetime` (pass timezone-aware values; naive datetimes + are interpreted in local time by :meth:`datetime.timestamp`). + """ + _assert_no_slack_date_control(token, "token") + if isinstance(timestamp, datetime): + seconds = math.floor(timestamp.timestamp()) + elif isinstance(timestamp, bool) or not isinstance(timestamp, (int, float)): + raise TypeError("timestamp must be an integer unix timestamp or datetime") + elif isinstance(timestamp, float): + if not timestamp.is_integer(): + raise TypeError("timestamp must be an integer unix timestamp or datetime") + seconds = int(timestamp) + else: + seconds = timestamp + link_part = f"^{_assert_slack_date_link(link)}" if link else "" + return f"" + + +def slack_mrkdwn_to_markdown(mrkdwn: str) -> str: + """Normalize Slack mrkdwn to standard Markdown. + + Rewrites user/channel mentions, links, bold, and strikethrough, then + unescapes Slack's ``&``/``<``/``>`` entities. + """ + markdown = mrkdwn + # User mentions: <@U123|name> -> @name or <@U123> -> @U123 + markdown = re.sub(r"<@([A-Z0-9_]+)\|([^<>]+)>", r"@\2", markdown) + markdown = re.sub(r"<@([A-Z0-9_]+)>", r"@\1", markdown) + # Channel mentions: <#C123|name> -> #name + markdown = re.sub(r"<#[A-Z0-9_]+\|([^<>]+)>", r"#\1", markdown) + markdown = re.sub(r"<#([A-Z0-9_]+)>", r"#\1", markdown) + # Links: -> [text](url) + markdown = re.sub(r"<(https?://[^|<>]+)\|([^<>]+)>", r"[\2](\1)", markdown) + # Bare links: -> url + markdown = re.sub(r"<(https?://[^<>]+)>", r"\1", markdown) + # Bold: *text* -> **text** (Slack uses single * for bold) + markdown = re.sub(r"(? ~~text~~ + markdown = re.sub(r"(? str: + """Convert basic Markdown bold (``**text**``) to mrkdwn bold (``*text*``).""" + return re.sub(r"\*\*(.+?)\*\*", r"*\1*", markdown) + + +def link_bare_slack_mentions(text: str) -> str: + """Wrap bare Slack-ID-shaped mention tokens (``@U123``) as ``<@U123>``. + + ID-based to match Slack docs — emails and lowercase names are untouched. + """ + return _SLACK_USER_TOKEN_PATTERN.sub(r"<@\1>", text) + + +def _assert_slack_text_object_text(text: str) -> None: + if len(text) < 1 or len(text) > _TEXT_OBJECT_MAX_LENGTH: + raise TypeError(f"text must be between 1 and {_TEXT_OBJECT_MAX_LENGTH} characters") + + +def _assert_slack_id(value: str, name: str) -> None: + if not _SLACK_ID_PATTERN.match(value): + raise TypeError(f"{name} must be a Slack ID") + + +def _assert_no_slack_control(value: str, name: str) -> None: + if _CONTROL_PATTERN.search(value): + raise TypeError(f"{name} cannot contain Slack control characters") + + +def _assert_no_slack_date_control(value: str, name: str) -> None: + if _DATE_CONTROL_PATTERN.search(value): + raise TypeError(f"{name} cannot contain Slack date control characters") + + +def _assert_slack_date_link(value: str) -> str: + _assert_no_slack_date_control(value, "link") + return value diff --git a/tests/test_slack_format_primitives.py b/tests/test_slack_format_primitives.py new file mode 100644 index 00000000..24dcfff7 --- /dev/null +++ b/tests/test_slack_format_primitives.py @@ -0,0 +1,154 @@ +"""Tests for the Slack format primitives subpath. + +Port of ``packages/adapter-slack/src/format/index.test.ts`` and +``format/boundary.test.ts`` (vercel/chat#547). +""" + +from __future__ import annotations + +import subprocess +import sys +from datetime import datetime, timezone + +import pytest + +from chat_sdk.adapters.slack.format import ( + create_slack_mrkdwn, + create_slack_plain_text, + escape_slack_text, + format_slack_channel, + format_slack_date, + format_slack_link, + format_slack_special_mention, + format_slack_user, + format_slack_user_group, + link_bare_slack_mentions, + markdown_bold_to_slack_mrkdwn, + slack_mrkdwn_to_markdown, + unescape_slack_text, +) + + +class TestSlackFormatPrimitives: + def test_escapes_slack_mrkdwn_control_characters(self): + assert escape_slack_text("a & ") == "a & <b>" + + def test_unescapes_slack_mrkdwn_control_characters(self): + assert unescape_slack_text("a & <b>") == "a & " + + def test_creates_plain_text_objects(self): + assert create_slack_plain_text("hello", emoji=True) == { + "emoji": True, + "text": "hello", + "type": "plain_text", + } + + def test_omits_optional_text_object_flags_when_unset(self): + assert create_slack_plain_text("hello") == {"text": "hello", "type": "plain_text"} + assert create_slack_mrkdwn("hello") == {"text": "hello", "type": "mrkdwn"} + + def test_rejects_invalid_text_object_lengths(self): + with pytest.raises(TypeError): + create_slack_plain_text("") + with pytest.raises(TypeError): + create_slack_mrkdwn("x" * 3001) + + def test_creates_mrkdwn_objects(self): + assert create_slack_mrkdwn("*hello*", verbatim=True) == { + "text": "*hello*", + "type": "mrkdwn", + "verbatim": True, + } + + def test_formats_slack_user_mentions(self): + assert format_slack_user("U123") == "<@U123>" + + def test_formats_slack_channel_mentions(self): + assert format_slack_channel("C123") == "<#C123>" + + def test_formats_slack_user_group_mentions(self): + assert format_slack_user_group("S123") == "" + + def test_formats_slack_special_mentions(self): + assert format_slack_special_mention("here") == "" + + def test_rejects_non_slack_ids_in_mentions(self): + with pytest.raises(TypeError): + format_slack_user("u123 lowercase") + + def test_formats_slack_links(self): + assert format_slack_link("https://example.com?a=1&b=2") == "" + assert format_slack_link("https://example.com", "read ") == "" + + def test_rejects_unsafe_slack_link_control_characters(self): + with pytest.raises(TypeError): + format_slack_link("https://example.com|bad") + + def test_formats_slack_dates(self): + assert format_slack_date(1_710_000_000, "{date_short}", "Mar 9") == "" + assert ( + format_slack_date( + datetime(2024, 3, 9, 16, 0, 0, tzinfo=timezone.utc), + "{time}", + "4pm", + link="https://example.com", + ) + == "" + ) + + def test_rejects_non_integer_date_timestamps(self): + with pytest.raises(TypeError): + format_slack_date(1710000000.5, "{date_short}", "Mar 9") + with pytest.raises(TypeError): + format_slack_date("1710000000", "{date_short}", "Mar 9") # type: ignore[arg-type] + + def test_rejects_date_control_characters_in_tokens_and_links(self): + with pytest.raises(TypeError): + format_slack_date(1_710_000_000, "{date^short}", "Mar 9") + with pytest.raises(TypeError): + format_slack_date(1_710_000_000, "{date_short}", "Mar 9", link="https://example.com|x") + + def test_normalizes_slack_mrkdwn_to_markdown(self): + assert ( + slack_mrkdwn_to_markdown( + "Hey <@U123|jane> in <#C123|general>, see and *bold* ~done~" + ) + == "Hey @jane in #general, see [this](https://example.com) and **bold** ~~done~~" + ) + + def test_normalizes_bare_slack_links_to_markdown_urls(self): + assert slack_mrkdwn_to_markdown("See ") == "See https://example.com" + + def test_converts_basic_markdown_bold_to_slack_mrkdwn_bold(self): + assert markdown_bold_to_slack_mrkdwn("The **domain** is example.com") == "The *domain* is example.com" + + def test_links_bare_mention_like_tokens_without_touching_emails(self): + assert link_bare_slack_mentions("(cc @U123, @U456)") == "(cc <@U123>, <@U456>)" + assert link_bare_slack_mentions("@george") == "@george" + assert link_bare_slack_mentions("user@example.com") == "user@example.com" + + +class TestFormatImportBoundary: + def test_does_not_import_the_full_adapter_or_runtime_packages(self): + """Importing the format subpath must not pull in slack_sdk, HTTP + clients, or the high-level adapter module (port of upstream's + ``format/boundary.test.ts``).""" + code = ( + "import sys\n" + "import chat_sdk.adapters.slack.format\n" + "forbidden = [\n" + " 'slack_sdk',\n" + " 'httpx',\n" + " 'aiohttp',\n" + " 'chat_sdk.adapters.slack.adapter',\n" + "]\n" + "loaded = [name for name in forbidden if name in sys.modules]\n" + "assert not loaded, f'format subpath imported runtime modules: {loaded}'\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr From 1f4c2ce1de0c168e6111c4840ded7f732c391ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:20:22 +0000 Subject: [PATCH 3/5] feat(slack): api primitives subpath (vercel/chat#548, #559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/chat_sdk/adapters/slack/api/__init__.py | 749 ++++++++++++++++++++ tests/test_slack_api_primitives.py | 416 +++++++++++ 2 files changed, 1165 insertions(+) create mode 100644 src/chat_sdk/adapters/slack/api/__init__.py create mode 100644 tests/test_slack_api_primitives.py diff --git a/src/chat_sdk/adapters/slack/api/__init__.py b/src/chat_sdk/adapters/slack/api/__init__.py new file mode 100644 index 00000000..e2056ffe --- /dev/null +++ b/src/chat_sdk/adapters/slack/api/__init__.py @@ -0,0 +1,749 @@ +"""Slack Web API primitives — a lightweight, runtime-free subpath. + +Port of ``packages/adapter-slack/src/api/`` (``client.ts`` from +vercel/chat#548; ``extra.ts`` thread-reply and view helpers from +vercel/chat#559), exposed upstream as ``@chat-adapter/slack/api``. +Provides fetch-based primitives for calling Slack Web API methods, +posting and updating messages, sending response-URL payloads, uploading +files through Slack's external upload flow, fetching private Slack file +URLs with bearer auth, fetching thread replies, and opening modal views — +independent of the full Slack adapter, ``slack_sdk``, Socket Mode, and +the chat runtime. ``SlackBotToken`` is declared locally (rather than +imported from the adapter's ``types`` module) so importing this subpath +never pulls in the adapter, mirroring upstream's independent declaration +in ``api/client.ts``. + +Importing this module never imports ``slack_sdk`` or an HTTP client; the +default ``fetch`` lazily imports ``httpx`` only when a request is actually +made, and any HTTP stack can be injected via the ``fetch`` parameter. + +The injectable ``fetch`` is an async callable:: + + async def fetch(url: str, *, method: str = "GET", + headers: Mapping[str, str] | None = None, + body: bytes | str | None = None) -> response + +where ``response`` exposes an ``int`` ``status`` (or ``status_code``) and a +``json()`` method (sync or async). :class:`SlackHttpResponse` is the shape +returned by the default fetch. + +Python-specific hardening (divergences from upstream, see +``docs/UPSTREAM_SYNC.md``): ``send_slack_response_url`` requires an +``https://*.slack.com`` URL and ``fetch_slack_file`` requires a trusted +Slack file host before forwarding the bearer token (SSRF / token-leak +guards mirroring the high-level adapter). +""" + +from __future__ import annotations + +import inspect +import json as _json +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from typing import Any, Literal, TypeAlias +from urllib.parse import urlencode, urljoin, urlparse + +__all__ = [ + "SlackApiError", + "SlackApiResponse", + "SlackBotToken", + "SlackEncodedBody", + "SlackFetch", + "SlackFileUpload", + "SlackHttpResponse", + "SlackOpenViewResult", + "SlackPostedMessage", + "SlackThreadRepliesResult", + "SlackUploadResult", + "assert_slack_ok", + "call_slack_api", + "delete_slack_message", + "encode_slack_api_body", + "fetch_slack_file", + "fetch_slack_thread_replies", + "is_trusted_slack_file_url", + "open_slack_view", + "post_slack_ephemeral", + "post_slack_message", + "resolve_slack_bot_token", + "send_slack_response_url", + "update_slack_message", + "upload_slack_files", +] + +# Bot token configuration: a static string, or a zero-arg callable returning a +# ``str`` (sync) or an awaitable resolving to ``str``. The callable is invoked +# each time a token is needed, enabling rotation or lazy retrieval from a secret +# manager. Declared locally (rather than imported from the adapter's +# ``types`` module) so this subpath stays runtime-free — mirroring upstream's +# independent ``SlackBotToken`` declaration in ``api/client.ts``. Matches +# ``type SlackBotToken = string | (() => string | Promise)``. +SlackBotToken: TypeAlias = "str | Callable[[], str | Awaitable[str]]" + +# A parsed Slack Web API response body (``{"ok": bool, ...}``). +SlackApiResponse: TypeAlias = dict[str, Any] + +# Injectable HTTP transport — see the module docstring for the protocol. +SlackFetch: TypeAlias = Callable[..., Awaitable[Any]] + +_DEFAULT_API_URL = "https://slack.com/api/" + + +class SlackApiError(Exception): + """Raised when a Slack Web API call fails (HTTP or ``ok: false``).""" + + def __init__( + self, + message: str, + *, + method: str, + response: SlackApiResponse | None = None, + status: int | None = None, + ) -> None: + super().__init__(message) + self.method = method + self.response = response + self.status = status + + +@dataclass +class SlackHttpResponse: + """Response shape returned by the default ``fetch`` implementation.""" + + status: int + body: bytes = b"" + headers: dict[str, str] = field(default_factory=dict) + + @property + def ok(self) -> bool: + return 200 <= self.status < 300 + + def json(self) -> Any: + return _json.loads(self.body.decode("utf-8")) + + def text(self) -> str: + return self.body.decode("utf-8") + + +@dataclass +class SlackEncodedBody: + """An encoded Slack API request body and its content type.""" + + body: str + content_type: str + + +@dataclass +class SlackPostedMessage: + """Result of posting, updating, or ephemeral-posting a message.""" + + id: str + raw: SlackApiResponse + channel: str | None = None + + +@dataclass +class SlackFileUpload: + """One file for :func:`upload_slack_files`.""" + + data: bytes | bytearray | memoryview + filename: str + alt_text: str | None = None + snippet_type: str | None = None + title: str | None = None + + +@dataclass +class SlackUploadResult: + """Result of :func:`upload_slack_files`.""" + + file_ids: list[str] + raw: SlackApiResponse + + +@dataclass +class SlackThreadRepliesResult: + """Result of :func:`fetch_slack_thread_replies`.""" + + messages: list[Any] + raw: SlackApiResponse + next_cursor: str | None = None + + +@dataclass +class SlackOpenViewResult: + """Result of :func:`open_slack_view`.""" + + raw: SlackApiResponse + view: Any = None + + +async def resolve_slack_bot_token(token: SlackBotToken) -> str: + """Resolve a static or callable (sync/async) bot token to a string.""" + if callable(token): + resolved = token() + return await resolved if inspect.isawaitable(resolved) else resolved + return token + + +async def call_slack_api( + method: str, + body: Mapping[str, Any], + *, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, + content_type: Literal["form", "json"] = "form", +) -> SlackApiResponse: + """POST a Slack Web API method with bearer auth and return the payload. + + Raises :class:`SlackApiError` for non-2xx HTTP responses. ``ok: false`` + payloads are returned as-is — pair with :func:`assert_slack_ok`. + """ + resolved_token = await resolve_slack_bot_token(token) + encoded = encode_slack_api_body(body, content_type) + request = fetch if fetch is not None else _default_fetch + url = urljoin(api_url if api_url is not None else _DEFAULT_API_URL, method) + response = await request( + url, + method="POST", + headers={ + "authorization": f"Bearer {resolved_token}", + "content-type": encoded.content_type, + }, + body=encoded.body, + ) + payload = await _response_json(response) + status = _response_status(response) + if not 200 <= status < 300: + raise SlackApiError( + f"Slack {method} returned HTTP {status}", + method=method, + response=payload, + status=status, + ) + return payload + + +async def post_slack_message( + *, + channel: str, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, + blocks: list[Any] | None = None, + markdown_text: str | None = None, + metadata: Any = None, + reply_broadcast: bool | None = None, + text: str | None = None, + thread_ts: str | None = None, + unfurl_links: bool | None = None, + unfurl_media: bool | None = None, +) -> SlackPostedMessage: + """Post a message via ``chat.postMessage``.""" + raw = await call_slack_api( + "chat.postMessage", + _slack_message_body( + blocks=blocks, + channel=channel, + markdown_text=markdown_text, + metadata=metadata, + reply_broadcast=reply_broadcast, + text=text, + thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + ), + token=token, + api_url=api_url, + fetch=fetch, + ) + assert_slack_ok("chat.postMessage", raw) + return SlackPostedMessage( + channel=_optional_string(raw.get("channel")), + id=_string_value(raw.get("ts")), + raw=raw, + ) + + +async def post_slack_ephemeral( + *, + channel: str, + user: str, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, + blocks: list[Any] | None = None, + markdown_text: str | None = None, + metadata: Any = None, + reply_broadcast: bool | None = None, + text: str | None = None, + thread_ts: str | None = None, + unfurl_links: bool | None = None, + unfurl_media: bool | None = None, +) -> SlackPostedMessage: + """Post an ephemeral message via ``chat.postEphemeral``.""" + body = _slack_message_body( + blocks=blocks, + channel=channel, + markdown_text=markdown_text, + metadata=metadata, + reply_broadcast=reply_broadcast, + text=text, + thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + ) + body["user"] = user + raw = await call_slack_api("chat.postEphemeral", body, token=token, api_url=api_url, fetch=fetch) + assert_slack_ok("chat.postEphemeral", raw) + return SlackPostedMessage( + channel=_optional_string(raw.get("channel")), + id=_string_value(raw.get("message_ts")), + raw=raw, + ) + + +async def update_slack_message( + *, + channel: str, + ts: str, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, + blocks: list[Any] | None = None, + markdown_text: str | None = None, + metadata: Any = None, + reply_broadcast: bool | None = None, + text: str | None = None, + thread_ts: str | None = None, + unfurl_links: bool | None = None, + unfurl_media: bool | None = None, +) -> SlackPostedMessage: + """Update a message via ``chat.update``.""" + body = _slack_message_body( + blocks=blocks, + channel=channel, + markdown_text=markdown_text, + metadata=metadata, + reply_broadcast=reply_broadcast, + text=text, + thread_ts=thread_ts, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + ) + body["ts"] = ts + raw = await call_slack_api("chat.update", body, token=token, api_url=api_url, fetch=fetch) + assert_slack_ok("chat.update", raw) + return SlackPostedMessage( + channel=_optional_string(raw.get("channel")), + id=_string_value(raw.get("ts")), + raw=raw, + ) + + +async def delete_slack_message( + *, + channel: str, + ts: str, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, +) -> SlackApiResponse: + """Delete a message via ``chat.delete``.""" + raw = await call_slack_api( + "chat.delete", + {"channel": channel, "ts": ts}, + token=token, + api_url=api_url, + fetch=fetch, + ) + assert_slack_ok("chat.delete", raw) + return raw + + +async def send_slack_response_url( + url: str, + *, + fetch: SlackFetch | None = None, + blocks: list[Any] | None = None, + delete_original: bool | None = None, + replace_original: bool | None = None, + response_type: Literal["ephemeral", "in_channel"] | None = None, + text: str | None = None, + thread_ts: str | None = None, +) -> None: + """POST a JSON payload to a Slack interaction ``response_url``. + + Python-specific hardening: the URL must be ``https://*.slack.com`` + (response URLs always are) — refuses to POST elsewhere (SSRF guard, + mirrors the high-level adapter). + """ + _assert_slack_response_url(url) + payload: dict[str, Any] = {} + if blocks is not None: + payload["blocks"] = blocks + if delete_original is not None: + payload["delete_original"] = delete_original + if replace_original is not None: + payload["replace_original"] = replace_original + if response_type is not None: + payload["response_type"] = response_type + if text is not None: + payload["text"] = text + if thread_ts is not None: + payload["thread_ts"] = thread_ts + request = fetch if fetch is not None else _default_fetch + response = await request( + url, + method="POST", + headers={"content-type": "application/json"}, + body=_json.dumps(payload, separators=(",", ":")), + ) + status = _response_status(response) + if not 200 <= status < 300: + raise SlackApiError( + f"Slack response_url returned HTTP {status}", + method="response_url", + status=status, + ) + + +async def upload_slack_files( + files: list[SlackFileUpload], + *, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, + channel_id: str | None = None, + initial_comment: str | None = None, + thread_ts: str | None = None, +) -> SlackUploadResult: + """Upload files through Slack's external upload flow. + + ``files.getUploadURLExternal`` → raw POST per file → + ``files.completeUploadExternal``. + """ + if len(files) == 0: + return SlackUploadResult(file_ids=[], raw={"ok": True}) + resolved_token = await resolve_slack_bot_token(token) + request = fetch if fetch is not None else _default_fetch + file_ids: list[str] = [] + for file in files: + data = bytes(file.data) + upload = await call_slack_api( + "files.getUploadURLExternal", + { + "alt_txt": file.alt_text, + "filename": file.filename, + "length": len(data), + "snippet_type": file.snippet_type, + }, + token=token, + api_url=api_url, + fetch=fetch, + ) + assert_slack_ok("files.getUploadURLExternal", upload) + upload_url = _string_value(upload.get("upload_url")) + file_id = _string_value(upload.get("file_id")) + if not (upload_url and file_id): + raise SlackApiError( + "Slack files.getUploadURLExternal returned no upload URL", + method="files.getUploadURLExternal", + response=upload, + ) + response = await request( + upload_url, + method="POST", + headers={ + "authorization": f"Bearer {resolved_token}", + "content-type": "application/octet-stream", + }, + body=data, + ) + status = _response_status(response) + if not 200 <= status < 300: + raise SlackApiError( + f"Slack file upload returned HTTP {status}", + method="files.upload", + status=status, + ) + file_ids.append(file_id) + raw = await call_slack_api( + "files.completeUploadExternal", + { + "channel_id": channel_id, + "files": [ + {"id": file_id, "title": file.title if file.title is not None else file.filename} + for file, file_id in zip(files, file_ids, strict=True) + ], + "initial_comment": initial_comment, + "thread_ts": thread_ts, + }, + token=token, + api_url=api_url, + fetch=fetch, + ) + assert_slack_ok("files.completeUploadExternal", raw) + return SlackUploadResult(file_ids=file_ids, raw=raw) + + +async def fetch_slack_file( + *, + url: str, + token: SlackBotToken, + fetch: SlackFetch | None = None, +) -> Any: + """GET a private Slack file URL with bearer auth; returns the response. + + Python-specific hardening: the URL must pass + :func:`is_trusted_slack_file_url` — refuses to forward the bot token to + untrusted hosts (token-leak guard, mirrors the high-level adapter). + """ + if not is_trusted_slack_file_url(url): + raise ValueError(f"Refusing to fetch Slack file from untrusted URL: {url}") + resolved_token = await resolve_slack_bot_token(token) + request = fetch if fetch is not None else _default_fetch + response = await request( + url, + method="GET", + headers={"authorization": f"Bearer {resolved_token}"}, + ) + status = _response_status(response) + if not 200 <= status < 300: + raise SlackApiError( + f"Slack file fetch returned HTTP {status}", + method="files.fetch", + status=status, + ) + return response + + +async def fetch_slack_thread_replies( + *, + channel: str, + ts: str, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, + cursor: str | None = None, + include_all_metadata: bool | None = None, + inclusive: bool | None = None, + latest: str | None = None, + limit: int | None = None, + oldest: str | None = None, +) -> SlackThreadRepliesResult: + """Fetch a thread's replies via ``conversations.replies``. + + Returns the message list plus the ``next_cursor`` from + ``response_metadata`` (``None`` when absent or empty) for pagination. + """ + raw = await call_slack_api( + "conversations.replies", + { + "channel": channel, + "cursor": cursor, + "include_all_metadata": include_all_metadata, + "inclusive": inclusive, + "latest": latest, + "limit": limit, + "oldest": oldest, + "ts": ts, + }, + token=token, + api_url=api_url, + fetch=fetch, + ) + assert_slack_ok("conversations.replies", raw) + messages = raw.get("messages") + return SlackThreadRepliesResult( + messages=messages if isinstance(messages, list) else [], + next_cursor=_next_cursor(raw), + raw=raw, + ) + + +async def open_slack_view( + *, + view: Any, + token: SlackBotToken, + api_url: str | None = None, + fetch: SlackFetch | None = None, + interactivity_pointer: str | None = None, + trigger_id: str | None = None, +) -> SlackOpenViewResult: + """Open a modal/view via ``views.open``. + + Requires a ``trigger_id`` or ``interactivity_pointer``. + """ + if not (trigger_id or interactivity_pointer): + raise TypeError("trigger_id or interactivity_pointer is required") + raw = await call_slack_api( + "views.open", + { + "interactivity_pointer": interactivity_pointer, + "trigger_id": trigger_id, + "view": view, + }, + token=token, + api_url=api_url, + fetch=fetch, + ) + assert_slack_ok("views.open", raw) + return SlackOpenViewResult(raw=raw, view=raw.get("view")) + + +def encode_slack_api_body( + body: Mapping[str, Any], + content_type: Literal["form", "json"] = "form", +) -> SlackEncodedBody: + """Encode an API body as form-urlencoded (default) or JSON. + + ``None`` values are omitted (TS ``undefined``/``null``). Non-scalar + form values are JSON-encoded the way Slack expects (e.g. ``blocks``). + """ + if content_type == "json": + return SlackEncodedBody( + body=_json.dumps({key: value for key, value in body.items() if value is not None}, separators=(",", ":")), + content_type="application/json", + ) + pairs: list[tuple[str, str]] = [] + for key, value in body.items(): + if value is None: + continue + pairs.append((key, _encode_slack_api_value(value))) + return SlackEncodedBody( + body=urlencode(pairs), + content_type="application/x-www-form-urlencoded", + ) + + +def assert_slack_ok(method: str, response: SlackApiResponse) -> None: + """Raise :class:`SlackApiError` unless ``response["ok"] is True``.""" + if response.get("ok") is not True: + error = response.get("error") + if error is None: + error = "unknown_error" + raise SlackApiError( + f"Slack {method} failed: {error}", + method=method, + response=response, + ) + + +def is_trusted_slack_file_url(url: str) -> bool: + """Gate Slack file downloads to known Slack-owned hosts. + + Bearer tokens must never be forwarded to an arbitrary URL — a crafted + value could exfiltrate the workspace bot token. This is a Python-first + divergence: the upstream primitives do not validate the URL. See + ``docs/UPSTREAM_SYNC.md`` Known Non-Parity. + """ + try: + parsed = urlparse(url) + except (ValueError, TypeError): + return False + if parsed.scheme != "https": + return False + host = (parsed.hostname or "").lower() + if not host: + return False + # Exact-match hosts + if host in {"files.slack.com", "slack.com"}: + return True + # Suffix match for Slack-owned subdomains + return host.endswith(".slack.com") or host.endswith(".slack-edge.com") + + +def _slack_message_body( + *, + blocks: list[Any] | None, + channel: str, + markdown_text: str | None, + metadata: Any, + reply_broadcast: bool | None, + text: str | None, + thread_ts: str | None, + unfurl_links: bool | None, + unfurl_media: bool | None, +) -> dict[str, Any]: + if markdown_text is not None and (text is not None or blocks is not None): + raise TypeError("markdown_text cannot be used with text or blocks") + return { + "blocks": blocks, + "channel": channel, + "markdown_text": markdown_text, + "metadata": metadata, + "reply_broadcast": reply_broadcast, + "text": text, + "thread_ts": thread_ts, + "unfurl_links": unfurl_links, + "unfurl_media": unfurl_media, + } + + +def _assert_slack_response_url(url: str) -> None: + parsed = urlparse(url) + if not (parsed.scheme == "https" and parsed.hostname and parsed.hostname.endswith(".slack.com")): + raise ValueError(f"Invalid response_url: must be https://*.slack.com, got {url}") + + +def _encode_slack_api_value(value: Any) -> str: + if isinstance(value, bool): + # JS String(true) -> "true"; Slack's form API expects lowercase. + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + return _json.dumps(value, separators=(",", ":")) + + +def _response_status(response: Any) -> int: + status = getattr(response, "status", None) + if status is None: + status = getattr(response, "status_code", None) + if not isinstance(status, int): + raise TypeError("Slack fetch response must expose an int 'status' (or 'status_code')") + return status + + +async def _response_json(response: Any) -> Any: + value = response.json() + return await value if inspect.isawaitable(value) else value + + +async def _default_fetch( + url: str, + *, + method: str = "GET", + headers: Mapping[str, str] | None = None, + body: bytes | str | None = None, +) -> SlackHttpResponse: + """Default HTTP transport. Lazily imports ``httpx`` (hazard #10).""" + import httpx + + async with httpx.AsyncClient() as client: + response = await client.request( + method, + url, + headers=dict(headers) if headers is not None else None, + content=body, + ) + return SlackHttpResponse( + status=response.status_code, + body=response.content, + headers=dict(response.headers), + ) + + +def _string_value(value: Any) -> str: + return value if isinstance(value, str) else "" + + +def _optional_string(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _next_cursor(response: SlackApiResponse) -> str | None: + metadata = response.get("response_metadata") + cursor = metadata.get("next_cursor") if isinstance(metadata, dict) else None + return cursor if isinstance(cursor, str) and len(cursor) > 0 else None diff --git a/tests/test_slack_api_primitives.py b/tests/test_slack_api_primitives.py new file mode 100644 index 00000000..0cb9d8cb --- /dev/null +++ b/tests/test_slack_api_primitives.py @@ -0,0 +1,416 @@ +"""Tests for the runtime-free Slack Web API primitives subpath. + +Port of ``packages/adapter-slack/src/api/index.test.ts`` and +``api/boundary.test.ts`` (vercel/chat#548, #559), exposed upstream as +``@chat-adapter/slack/api``. These primitives never touch the network in +tests: a fake ``fetch`` (an :class:`~unittest.mock.AsyncMock`) is injected +and its recorded calls are asserted, mirroring upstream's ``vi.fn()`` +request mocks. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from typing import Any +from unittest.mock import AsyncMock +from urllib.parse import parse_qs + +import pytest + +from chat_sdk.adapters.slack.api import ( + SlackApiError, + call_slack_api, + delete_slack_message, + encode_slack_api_body, + fetch_slack_file, + fetch_slack_thread_replies, + open_slack_view, + post_slack_ephemeral, + post_slack_message, + send_slack_response_url, + update_slack_message, + upload_slack_files, +) +from chat_sdk.adapters.slack.api import ( + SlackFileUpload as FileUpload, +) + + +class _JsonResponse: + """Minimal stand-in for the injected fetch's response object. + + Exposes the ``status`` int and a sync ``json()`` returning the parsed + body — the shape :func:`call_slack_api` reads. + """ + + def __init__(self, value: Any, status: int = 200) -> None: + self._value = value + self.status = status + + def json(self) -> Any: + return self._value + + +def _json_response(value: Any, status: int = 200) -> _JsonResponse: + return _JsonResponse(value, status) + + +def _form(call_args: Any) -> dict[str, list[str]]: + """Parse the form-urlencoded body of a recorded fetch call.""" + body = call_args.kwargs["body"] + return parse_qs(str(body), keep_blank_values=True) + + +def _url(call_args: Any) -> str: + return call_args.args[0] + + +class TestSlackApiPrimitives: + def test_form_encodes_slack_api_bodies_with_json_object_values(self) -> None: + encoded = encode_slack_api_body( + { + "blocks": [{"type": "section"}], + "channel": "C123", + "reply_broadcast": False, + "text": "hello", + "thread_ts": None, + } + ) + + assert encoded.content_type == "application/x-www-form-urlencoded" + params = parse_qs(encoded.body) + assert params["blocks"] == ['[{"type":"section"}]'] + assert params["reply_broadcast"] == ["false"] + # None (TS undefined) is omitted entirely. + assert "thread_ts" not in params + + async def test_calls_slack_web_api_with_bearer_token_auth(self) -> None: + async def token() -> str: + return "xoxb-token" + + request = AsyncMock(return_value=_json_response({"ok": True})) + + await call_slack_api( + "chat.postMessage", + {"channel": "C123", "text": "hello"}, + token=token, + fetch=request, + ) + + call = request.await_args + assert _url(call) == "https://slack.com/api/chat.postMessage" + assert call.kwargs["headers"]["authorization"] == "Bearer xoxb-token" + assert _form(call)["text"] == ["hello"] + + async def test_supports_custom_api_origins_for_tests_and_proxies(self) -> None: + request = AsyncMock(return_value=_json_response({"ok": True})) + + await call_slack_api( + "chat.postMessage", + {}, + api_url="https://proxy.example/slack/", + token="xoxb-token", + fetch=request, + ) + + assert _url(request.await_args) == "https://proxy.example/slack/chat.postMessage" + + async def test_throws_for_non_2xx_slack_api_http_responses(self) -> None: + request = AsyncMock(return_value=_json_response({"error": "ratelimited", "ok": False}, status=429)) + + with pytest.raises(SlackApiError) as exc_info: + await call_slack_api("chat.postMessage", {}, token="xoxb", fetch=request) + + assert exc_info.value.method == "chat.postMessage" + assert exc_info.value.status == 429 + + async def test_posts_messages_and_returns_the_slack_timestamp(self) -> None: + request = AsyncMock(return_value=_json_response({"channel": "C123", "ok": True, "ts": "1.23"})) + + result = await post_slack_message( + channel="C123", + markdown_text="**hello**", + token="xoxb", + unfurl_links=False, + unfurl_media=False, + fetch=request, + ) + + params = _form(request.await_args) + assert params["markdown_text"] == ["**hello**"] + assert "text" not in params + assert "blocks" not in params + assert params["unfurl_links"] == ["false"] + assert result.channel == "C123" + assert result.id == "1.23" + assert result.raw == {"channel": "C123", "ok": True, "ts": "1.23"} + + async def test_rejects_markdown_text_conflicts_locally(self) -> None: + request = AsyncMock() + + with pytest.raises(TypeError): + await post_slack_message( + channel="C123", + markdown_text="**hello**", + text="hello", + token="xoxb", + fetch=request, + ) + + # The conflict is caught before any request is made. + request.assert_not_awaited() + + async def test_posts_ephemeral_messages(self) -> None: + request = AsyncMock(return_value=_json_response({"channel": "C123", "message_ts": "1.24", "ok": True})) + + result = await post_slack_ephemeral( + channel="C123", + text="hello", + token="xoxb", + user="U123", + fetch=request, + ) + + call = request.await_args + assert _url(call) == "https://slack.com/api/chat.postEphemeral" + assert _form(call)["user"] == ["U123"] + assert result.id == "1.24" + + async def test_updates_messages(self) -> None: + request = AsyncMock(return_value=_json_response({"channel": "C123", "ok": True, "ts": "1.25"})) + + result = await update_slack_message( + blocks=[{"type": "section"}], + channel="C123", + text="fallback", + token="xoxb", + ts="1.23", + fetch=request, + ) + + call = request.await_args + assert _url(call) == "https://slack.com/api/chat.update" + params = _form(call) + assert params["ts"] == ["1.23"] + assert params["blocks"] == ['[{"type":"section"}]'] + assert result.id == "1.25" + + async def test_deletes_messages(self) -> None: + request = AsyncMock(return_value=_json_response({"ok": True, "ts": "1.23"})) + + await delete_slack_message( + channel="C123", + token="xoxb", + ts="1.23", + fetch=request, + ) + + call = request.await_args + assert _url(call) == "https://slack.com/api/chat.delete" + params = _form(call) + assert params["channel"] == ["C123"] + assert params["ts"] == ["1.23"] + + async def test_throws_slack_api_error_for_ok_false_helper_responses(self) -> None: + request = AsyncMock(return_value=_json_response({"error": "channel_not_found", "ok": False})) + + with pytest.raises(SlackApiError): + await post_slack_message( + channel="C123", + text="hello", + token="xoxb", + fetch=request, + ) + + async def test_sends_response_url_json_payloads(self) -> None: + request = AsyncMock(return_value=_json_response(None, status=200)) + + await send_slack_response_url( + "https://hooks.slack.com/actions/T/1/abc", + replace_original=True, + text="updated", + fetch=request, + ) + + call = request.await_args + assert _url(call) == "https://hooks.slack.com/actions/T/1/abc" + assert json.loads(call.kwargs["body"]) == { + "replace_original": True, + "text": "updated", + } + + async def test_rejects_non_slack_response_urls(self) -> None: + """Python-specific SSRF guard: response_url must be https://*.slack.com. + + Diverges from upstream, which POSTs to any response_url. See + ``docs/UPSTREAM_SYNC.md`` Known Non-Parity. + """ + request = AsyncMock() + + with pytest.raises(ValueError, match="https://\\*.slack.com"): + await send_slack_response_url( + "https://evil.example/steal", + text="x", + fetch=request, + ) + + request.assert_not_awaited() + + async def test_uploads_files_with_slack_external_upload_flow(self) -> None: + request = AsyncMock( + side_effect=[ + _json_response( + { + "file_id": "F123", + "ok": True, + "upload_url": "https://files.slack.com/upload/v1/abc", + } + ), + _json_response(None, status=200), + _json_response({"files": [{"id": "F123"}], "ok": True}), + ] + ) + + result = await upload_slack_files( + [FileUpload(data=bytes([1, 2, 3]), filename="report.txt")], + channel_id="C123", + initial_comment="here", + thread_ts="1.23", + token="xoxb", + fetch=request, + ) + + calls = request.await_args_list + assert _url(calls[0]) == "https://slack.com/api/files.getUploadURLExternal" + assert _form(calls[0])["length"] == ["3"] + assert _url(calls[1]) == "https://files.slack.com/upload/v1/abc" + assert calls[1].kwargs["headers"]["authorization"] == "Bearer xoxb" + assert _url(calls[2]) == "https://slack.com/api/files.completeUploadExternal" + assert result.file_ids == ["F123"] + + async def test_fetches_private_slack_file_urls_with_bearer_auth(self) -> None: + response = _json_response("file", status=200) + request = AsyncMock(return_value=response) + + result = await fetch_slack_file( + token="xoxb", + url="https://files.slack.com/files-pri/T/F/report.txt", + fetch=request, + ) + + assert result is response + assert request.await_args.kwargs["headers"]["authorization"] == "Bearer xoxb" + + async def test_refuses_to_fetch_files_from_untrusted_hosts(self) -> None: + """Python-specific token-leak guard: file host must be Slack-owned. + + Diverges from upstream, which forwards the bearer token to any URL. + See ``docs/UPSTREAM_SYNC.md`` Known Non-Parity. + """ + request = AsyncMock() + + with pytest.raises(ValueError, match="untrusted URL"): + await fetch_slack_file( + token="xoxb", + url="https://evil.example/files-pri/x", + fetch=request, + ) + + request.assert_not_awaited() + + async def test_fetches_thread_replies_with_cursor_metadata(self) -> None: + request = AsyncMock( + return_value=_json_response( + { + "messages": [{"text": "root", "ts": "1.23"}], + "ok": True, + "response_metadata": {"next_cursor": "next"}, + } + ) + ) + + result = await fetch_slack_thread_replies( + channel="C123", + limit=50, + token="xoxb", + ts="1.23", + fetch=request, + ) + + call = request.await_args + assert _url(call) == "https://slack.com/api/conversations.replies" + assert _form(call)["ts"] == ["1.23"] + assert result.messages == [{"text": "root", "ts": "1.23"}] + assert result.next_cursor == "next" + assert result.raw == { + "messages": [{"text": "root", "ts": "1.23"}], + "ok": True, + "response_metadata": {"next_cursor": "next"}, + } + + async def test_omits_next_cursor_when_metadata_absent(self) -> None: + """An empty or missing ``next_cursor`` resolves to ``None``.""" + request = AsyncMock( + return_value=_json_response({"messages": [], "ok": True, "response_metadata": {"next_cursor": ""}}) + ) + + result = await fetch_slack_thread_replies( + channel="C123", + token="xoxb", + ts="1.23", + fetch=request, + ) + + assert result.next_cursor is None + assert result.messages == [] + + async def test_opens_slack_views_with_trigger_ids(self) -> None: + request = AsyncMock(return_value=_json_response({"ok": True, "view": {"id": "V123", "type": "modal"}})) + + result = await open_slack_view( + token="xoxb", + trigger_id="trigger", + view={"type": "modal"}, + fetch=request, + ) + + call = request.await_args + assert _url(call) == "https://slack.com/api/views.open" + assert json.loads(_form(call)["view"][0]) == {"type": "modal"} + assert result.view == {"id": "V123", "type": "modal"} + + async def test_open_view_requires_trigger_or_interactivity_pointer(self) -> None: + request = AsyncMock() + + with pytest.raises(TypeError, match="trigger_id or interactivity_pointer"): + await open_slack_view(token="xoxb", view={"type": "modal"}, fetch=request) + + request.assert_not_awaited() + + +class TestApiImportBoundary: + def test_does_not_import_the_full_adapter_or_runtime_packages(self) -> None: + """Importing the api subpath must not pull in slack_sdk, an HTTP + client, or the high-level adapter module (port of upstream's + ``api/boundary.test.ts``).""" + code = ( + "import sys\n" + "import chat_sdk.adapters.slack.api\n" + "forbidden = [\n" + " 'slack_sdk',\n" + " 'httpx',\n" + " 'aiohttp',\n" + " 'chat_sdk.adapters.slack.adapter',\n" + "]\n" + "loaded = [name for name in forbidden if name in sys.modules]\n" + "assert not loaded, f'api subpath imported runtime modules: {loaded}'\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr From 1e7b843b21dd7cce2a7a35ea1149b67a84cfdc55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 08:36:17 +0000 Subject: [PATCH 4/5] feat(slack): block kit primitives subpath (vercel/chat#555, #559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../adapters/slack/blocks/__init__.py | 531 ++++++++++++++++ src/chat_sdk/adapters/slack/blocks/errors.py | 10 + src/chat_sdk/adapters/slack/blocks/input.py | 327 ++++++++++ src/chat_sdk/adapters/slack/blocks/limits.py | 64 ++ src/chat_sdk/adapters/slack/blocks/types.py | 178 ++++++ tests/test_slack_blocks_primitives.py | 569 ++++++++++++++++++ 6 files changed, 1679 insertions(+) create mode 100644 src/chat_sdk/adapters/slack/blocks/__init__.py create mode 100644 src/chat_sdk/adapters/slack/blocks/errors.py create mode 100644 src/chat_sdk/adapters/slack/blocks/input.py create mode 100644 src/chat_sdk/adapters/slack/blocks/limits.py create mode 100644 src/chat_sdk/adapters/slack/blocks/types.py create mode 100644 tests/test_slack_blocks_primitives.py diff --git a/src/chat_sdk/adapters/slack/blocks/__init__.py b/src/chat_sdk/adapters/slack/blocks/__init__.py new file mode 100644 index 00000000..ebddffa1 --- /dev/null +++ b/src/chat_sdk/adapters/slack/blocks/__init__.py @@ -0,0 +1,531 @@ +"""Runtime-free Slack Block Kit helpers — the ``blocks`` subpath. + +Port of ``packages/adapter-slack/src/blocks/index.ts`` (vercel/chat#555; +input helpers from #559), exposed upstream as ``@chat-adapter/slack/blocks``. +Converts Chat SDK-style card objects into Slack Block Kit blocks (and a +Markdown fallback), with docs-backed Slack size limits and emoji-placeholder +conversion — without importing 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. + +Compatibility aliases ``card_to_block_kit`` / ``card_to_fallback_text`` mirror +upstream's ``cardToBlockKit`` / ``cardToFallbackText`` re-exports. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from chat_sdk.adapters.slack.blocks.errors import SlackBlockError +from chat_sdk.adapters.slack.blocks.input import ( + SLACK_FREEFORM_ACTION_ID, + SLACK_FREEFORM_ACTION_PREFIX, + SLACK_FREEFORM_BLOCK_ID, + SLACK_FREEFORM_CALLBACK_ID, + SLACK_INPUT_ACTION_PREFIX, + SlackAnsweredInputOptions, + SlackFreeformValueEntry, + SlackFreeformViewOptions, + SlackInputAction, + SlackInputOption, + SlackInputRequest, + SlackInputResponse, + answered_slack_input_blocks, + build_slack_freeform_view, + input_request_to_slack_blocks, + parse_slack_freeform_value, + parse_slack_input_response, +) +from chat_sdk.adapters.slack.blocks.limits import LIMITS +from chat_sdk.adapters.slack.blocks.types import ( + SlackActionsElement, + SlackBlock, + SlackBlocksOptions, + SlackButtonElement, + SlackButtonStyle, + SlackCardChild, + SlackCardElement, + SlackDividerElement, + SlackFieldElement, + SlackFieldsElement, + SlackImageElement, + SlackLinkButtonElement, + SlackLinkElement, + SlackRadioSelectElement, + SlackSectionElement, + SlackSelectElement, + SlackSelectOptionElement, + SlackTableAlignment, + SlackTableElement, + SlackTextElement, + SlackTextObject, + SlackTextStyle, +) +from chat_sdk.adapters.slack.format import markdown_bold_to_slack_mrkdwn + +__all__ = [ + "LIMITS", + "SLACK_FREEFORM_ACTION_ID", + "SLACK_FREEFORM_ACTION_PREFIX", + "SLACK_FREEFORM_BLOCK_ID", + "SLACK_FREEFORM_CALLBACK_ID", + "SLACK_INPUT_ACTION_PREFIX", + "SlackActionsElement", + "SlackAnsweredInputOptions", + "SlackBlock", + "SlackBlockError", + "SlackBlocksOptions", + "SlackButtonElement", + "SlackButtonStyle", + "SlackCardChild", + "SlackCardElement", + "SlackDividerElement", + "SlackFieldElement", + "SlackFieldsElement", + "SlackFreeformValueEntry", + "SlackFreeformViewOptions", + "SlackImageElement", + "SlackInputAction", + "SlackInputOption", + "SlackInputRequest", + "SlackInputResponse", + "SlackLinkButtonElement", + "SlackLinkElement", + "SlackRadioSelectElement", + "SlackSectionElement", + "SlackSelectElement", + "SlackSelectOptionElement", + "SlackTableAlignment", + "SlackTableElement", + "SlackTextElement", + "SlackTextObject", + "SlackTextStyle", + "answered_slack_input_blocks", + "build_slack_freeform_view", + "card_to_block_kit", + "card_to_fallback_text", + "card_to_slack_blocks", + "card_to_slack_fallback_text", + "convert_slack_emoji_placeholders", + "input_request_to_slack_blocks", + "parse_slack_freeform_value", + "parse_slack_input_response", +] + +_EMPTY_TEXT = " " +_EMOJI_PATTERN = re.compile(r"\{\{emoji:([a-zA-Z0-9_+-]+)\}\}") + +# An emoji-placeholder converter: ``text -> text``. +_EmojiConverter = Callable[[str], str] + + +@dataclass +class _State: + convert_emoji: _EmojiConverter + max_blocks: int + used_table: bool = False + + +def card_to_slack_blocks( + card: SlackCardElement, + options: SlackBlocksOptions | None = None, +) -> list[SlackBlock]: + """Convert a card object into Slack Block Kit blocks.""" + options = options if options is not None else {} + convert = options.get("convert_emoji") + max_blocks = options.get("max_blocks") + blocks: list[SlackBlock] = [] + state = _State( + convert_emoji=convert if convert is not None else convert_slack_emoji_placeholders, + max_blocks=max_blocks if max_blocks is not None else LIMITS.blocks, + ) + + title = card.get("title") + if title: + blocks.append( + { + "text": _plain_text(title, state.convert_emoji, LIMITS.header_text), + "type": "header", + } + ) + subtitle = card.get("subtitle") + if subtitle: + blocks.append( + { + "elements": [_mrkdwn(subtitle, state.convert_emoji, LIMITS.text_object)], + "type": "context", + } + ) + image_url = card.get("image_url") + if image_url: + blocks.append( + { + "alt_text": _truncate_text( + state.convert_emoji(title or "Card image"), + LIMITS.image_alt, + ), + "image_url": _truncate_text(image_url, LIMITS.image_url), + "type": "image", + } + ) + for child in card["children"]: + blocks.extend(_card_child_to_slack_blocks(child, state)) + return blocks[: state.max_blocks] + + +def card_to_slack_fallback_text( + card: SlackCardElement, + options: SlackBlocksOptions | None = None, +) -> str: + """Render a card as plain Markdown fallback text.""" + options = options if options is not None else {} + convert = options.get("convert_emoji") + convert_emoji = convert if convert is not None else convert_slack_emoji_placeholders + lines: list[str] = [] + title = card.get("title") + if title: + lines.append(f"*{convert_emoji(title)}*") + subtitle = card.get("subtitle") + if subtitle: + lines.append(convert_emoji(subtitle)) + for child in card["children"]: + text = _card_child_to_fallback_text(child, convert_emoji) + if text: + lines.append(text) + return "\n".join(lines) + + +def convert_slack_emoji_placeholders(text: str) -> str: + """Replace ``{{emoji:name}}`` placeholders with Slack ``:name:`` codes.""" + return _EMOJI_PATTERN.sub(r":\1:", text) + + +# Compatibility aliases (upstream ``cardToBlockKit`` / ``cardToFallbackText``). +card_to_block_kit = card_to_slack_blocks +card_to_fallback_text = card_to_slack_fallback_text + + +def _card_child_to_slack_blocks(child: SlackCardChild, state: _State) -> list[SlackBlock]: + # Discriminated on the literal ``type`` key. The per-branch ignores mirror + # the high-level adapter's card-child dispatch (cards.py): the union member + # is narrowed by the runtime check, not by the static checker. + kind = child.get("type") + if kind == "actions": + return [_actions_to_block(child, state.convert_emoji)] # type: ignore[arg-type] + if kind == "divider": + return [{"type": "divider"}] + if kind == "fields": + return [_fields_to_block(child, state.convert_emoji)] # type: ignore[arg-type] + if kind == "image": + return [_image_to_block(child, state.convert_emoji)] # type: ignore[arg-type] + if kind == "link": + return [_link_to_block(child, state.convert_emoji)] # type: ignore[arg-type] + if kind == "section": + section = cast(SlackSectionElement, child) + result: list[SlackBlock] = [] + for nested in section["children"]: + result.extend(_card_child_to_slack_blocks(nested, state)) + return result + if kind == "table": + return _table_to_blocks(child, state) # type: ignore[arg-type] + if kind == "text": + return [_text_to_block(child, state.convert_emoji)] # type: ignore[arg-type] + return cast("list[SlackBlock]", _assert_never(child)) + + +def _text_to_block(element: SlackTextElement, convert_emoji: _EmojiConverter) -> SlackBlock: + text = markdown_bold_to_slack_mrkdwn(convert_emoji(element["content"])) + if element.get("style") == "muted": + return { + "elements": [_mrkdwn(text, _identity, LIMITS.text_object)], + "type": "context", + } + return { + "text": _mrkdwn( + f"*{text}*" if element.get("style") == "bold" else text, + _identity, + LIMITS.section_text, + ), + "type": "section", + } + + +def _image_to_block(element: SlackImageElement, convert_emoji: _EmojiConverter) -> SlackBlock: + return { + "alt_text": _truncate_text(convert_emoji(element.get("alt") or "Image"), LIMITS.image_alt), + "image_url": _truncate_text(element["url"], LIMITS.image_url), + "type": "image", + } + + +def _link_to_block(element: SlackLinkElement, convert_emoji: _EmojiConverter) -> SlackBlock: + return { + "text": _mrkdwn( + f"<{element['url']}|{convert_emoji(element['label'])}>", + _identity, + LIMITS.section_text, + ), + "type": "section", + } + + +def _actions_to_block(element: SlackActionsElement, convert_emoji: _EmojiConverter) -> SlackBlock: + return { + "elements": [ + _action_to_element(child, convert_emoji) for child in element["children"][: LIMITS.actions_elements] + ], + "type": "actions", + } + + +def _action_to_element( + child: SlackButtonElement | SlackLinkButtonElement | SlackRadioSelectElement | SlackSelectElement, + convert_emoji: _EmojiConverter, +) -> dict[str, object]: + kind = child.get("type") + if kind == "button": + return _button_to_element(child, convert_emoji) # type: ignore[arg-type] + if kind == "link-button": + return _link_button_to_element(child, convert_emoji) # type: ignore[arg-type] + if kind == "radio_select": + return _radio_select_to_element(child, convert_emoji) # type: ignore[arg-type] + if kind == "select": + return _select_to_element(child, convert_emoji) # type: ignore[arg-type] + return cast("dict[str, object]", _assert_never(child)) + + +def _button_to_element(button: SlackButtonElement, convert_emoji: _EmojiConverter) -> dict[str, object]: + value = button.get("value") + return _compact( + { + "action_id": _truncate_text(button["id"], LIMITS.action_id), + "style": _map_button_style(button.get("style")), + "text": _plain_text(button["label"], convert_emoji, LIMITS.button_text), + "type": "button", + "value": None if value is None else _truncate_text(value, LIMITS.button_value), + } + ) + + +def _link_button_to_element(button: SlackLinkButtonElement, convert_emoji: _EmojiConverter) -> dict[str, object]: + return _compact( + { + "action_id": _truncate_text(f"link-{button['url']}", LIMITS.action_id), + "style": _map_button_style(button.get("style")), + "text": _plain_text(button["label"], convert_emoji, LIMITS.button_text), + "type": "button", + "url": _truncate_text(button["url"], LIMITS.button_url), + } + ) + + +def _select_to_element(select: SlackSelectElement, convert_emoji: _EmojiConverter) -> dict[str, object]: + options = [_option_object(option, convert_emoji, "plain_text") for option in select["options"][: LIMITS.options]] + placeholder = select.get("placeholder") + return _compact( + { + "action_id": _truncate_text(select["id"], LIMITS.action_id), + "initial_option": _find_initial_option(options, select.get("initial_option")), + "options": options, + "placeholder": (_plain_text(placeholder, convert_emoji, LIMITS.placeholder) if placeholder else None), + "type": "static_select", + } + ) + + +def _radio_select_to_element(select: SlackRadioSelectElement, convert_emoji: _EmojiConverter) -> dict[str, object]: + options = [_option_object(option, convert_emoji, "mrkdwn") for option in select["options"][: LIMITS.radio_options]] + return _compact( + { + "action_id": _truncate_text(select["id"], LIMITS.action_id), + "initial_option": _find_initial_option(options, select.get("initial_option")), + "options": options, + "type": "radio_buttons", + } + ) + + +def _find_initial_option( + options: list[dict[str, object]], + initial_option: str | None, +) -> dict[str, object] | None: + if initial_option is None: + return None + value = _truncate_text(initial_option, LIMITS.option_value) + for option in options: + if option.get("value") == value: + return option + return None + + +def _option_object( + option: SlackSelectOptionElement, + convert_emoji: _EmojiConverter, + text_type: str, +) -> dict[str, object]: + description = option.get("description") + return _compact( + { + "description": ( + { + "text": _truncate_text(convert_emoji(description), LIMITS.option_description), + "type": text_type, + } + if description + else None + ), + "text": { + "text": _truncate_text(convert_emoji(option["label"]), LIMITS.option_text), + "type": text_type, + }, + "value": _truncate_text(option["value"], LIMITS.option_value), + } + ) + + +def _fields_to_block(element: SlackFieldsElement, convert_emoji: _EmojiConverter) -> SlackBlock: + return { + "fields": [ + _mrkdwn( + f"*{markdown_bold_to_slack_mrkdwn(convert_emoji(field['label']))}*" + f"\n{markdown_bold_to_slack_mrkdwn(convert_emoji(field['value']))}", + _identity, + LIMITS.field_text, + ) + for field in element["children"][: LIMITS.fields] + ], + "type": "section", + } + + +def _table_to_blocks(element: SlackTableElement, state: _State) -> list[SlackBlock]: + if ( + state.used_table + or len(element["rows"]) + 1 > LIMITS.table_rows + or len(element["headers"]) > LIMITS.table_columns + ): + return [ + { + "text": _mrkdwn( + f"```\n{_table_to_ascii(element)}\n```", + _identity, + LIMITS.section_text, + ), + "type": "section", + } + ] + state.used_table = True + align = element.get("align") + column_settings = ( + [({"align": value} if value else None) for value in align[: LIMITS.table_columns]] + if align is not None + else None + ) + return [ + _compact( + { + "column_settings": column_settings, + "rows": [ + [_raw_text(header, state.convert_emoji) for header in element["headers"]], + *[[_raw_text(cell, state.convert_emoji) for cell in row] for row in element["rows"]], + ], + "type": "table", + } + ) + ] + + +def _card_child_to_fallback_text(child: SlackCardChild, convert_emoji: _EmojiConverter) -> str | None: + # Per-branch casts narrow the union to the member the literal ``type`` + # guarantees, so the inline field reads stay statically checked. + kind = child.get("type") + if kind == "actions": + return None + if kind == "divider": + return "---" + if kind == "fields": + fields = cast(SlackFieldsElement, child) + return "\n".join( + f"{convert_emoji(field['label'])}: {convert_emoji(field['value'])}" for field in fields["children"] + ) + if kind == "image": + alt = cast(SlackImageElement, child).get("alt") + return convert_emoji(alt) if alt else None + if kind == "link": + link = cast(SlackLinkElement, child) + return f"{convert_emoji(link['label'])} ({link['url']})" + if kind == "section": + section = cast(SlackSectionElement, child) + nested_lines = [ + text for nested in section["children"] if (text := _card_child_to_fallback_text(nested, convert_emoji)) + ] + return "\n".join(nested_lines) + if kind == "table": + return _table_to_ascii(cast(SlackTableElement, child)) + if kind == "text": + return convert_emoji(cast(SlackTextElement, child)["content"]) + return cast("str | None", _assert_never(child)) + + +def _mrkdwn(text: str, convert_emoji: _EmojiConverter, max_length: int) -> SlackTextObject: + return { + "text": _nonempty_text(_truncate_text(convert_emoji(text), max_length)), + "type": "mrkdwn", + } + + +def _plain_text(text: str, convert_emoji: _EmojiConverter, max_length: int) -> SlackTextObject: + return { + "emoji": True, + "text": _nonempty_text(_truncate_text(convert_emoji(text), max_length)), + "type": "plain_text", + } + + +def _raw_text(text: str, convert_emoji: _EmojiConverter) -> dict[str, str]: + return { + "text": _nonempty_text(convert_emoji(text)), + "type": "raw_text", + } + + +def _map_button_style(style: SlackButtonStyle | None) -> str | None: + return style if style in ("danger", "primary") else None + + +def _truncate_text(text: str, max_length: int) -> str: + return text[:max_length] if len(text) > max_length else text + + +def _nonempty_text(text: str) -> str: + return text if len(text) > 0 else _EMPTY_TEXT + + +def _identity(value: str) -> str: + return value + + +def _assert_never(value: object) -> object: + raise SlackBlockError(f"Unsupported Slack card element: {value}") + + +def _compact(value: dict[str, object]) -> dict[str, object]: + return {key: item for key, item in value.items() if item is not None} + + +def _table_to_ascii(table: SlackTableElement) -> str: + rows = [table["headers"], *table["rows"]] + widths = [ + max((len(row[column]) if column < len(row) else 0) for row in rows) for column in range(len(table["headers"])) + ] + lines: list[str] = [] + for row in rows: + cells = [ + (row[column] if column < len(row) else "").ljust(widths[column] if column < len(widths) else 0) + for column in range(len(row)) + ] + lines.append(" | ".join(cells).rstrip()) + return "\n".join(lines) diff --git a/src/chat_sdk/adapters/slack/blocks/errors.py b/src/chat_sdk/adapters/slack/blocks/errors.py new file mode 100644 index 00000000..b6e5a8db --- /dev/null +++ b/src/chat_sdk/adapters/slack/blocks/errors.py @@ -0,0 +1,10 @@ +"""Error type for the Slack Block Kit primitives subpath. + +Port of ``packages/adapter-slack/src/blocks/errors.ts`` (vercel/chat#555). +""" + +from __future__ import annotations + + +class SlackBlockError(Exception): + """Raised when a Slack card element cannot be converted to Block Kit.""" diff --git a/src/chat_sdk/adapters/slack/blocks/input.py b/src/chat_sdk/adapters/slack/blocks/input.py new file mode 100644 index 00000000..4e6d0e97 --- /dev/null +++ b/src/chat_sdk/adapters/slack/blocks/input.py @@ -0,0 +1,327 @@ +"""Generic input-request Block Kit helpers for the Slack blocks subpath. + +Port of ``packages/adapter-slack/src/blocks/input.ts`` (vercel/chat#559). + +Builds Block Kit for prompt/option input requests (buttons, select, radio, +and freeform-text modals) and parses the corresponding interaction +payloads back into structured responses. Input shapes are TypedDicts with +snake_case keys (``request_id``, ``allow_freeform``, ``option_id``, +``selected_option_value``, ``action_id``, ``block_id``) — upstream uses +camelCase. Emitted Block Kit dicts keep Slack's API field names. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Literal, NotRequired, TypedDict + +from chat_sdk.adapters.slack.blocks.limits import LIMITS +from chat_sdk.adapters.slack.blocks.types import SlackBlock, SlackButtonStyle + +__all__ = [ + "SLACK_FREEFORM_ACTION_ID", + "SLACK_FREEFORM_ACTION_PREFIX", + "SLACK_FREEFORM_BLOCK_ID", + "SLACK_FREEFORM_CALLBACK_ID", + "SLACK_INPUT_ACTION_PREFIX", + "SlackAnsweredInputOptions", + "SlackFreeformValueEntry", + "SlackFreeformViewOptions", + "SlackInputAction", + "SlackInputOption", + "SlackInputRequest", + "SlackInputResponse", + "answered_slack_input_blocks", + "build_slack_freeform_view", + "input_request_to_slack_blocks", + "parse_slack_freeform_value", + "parse_slack_input_response", +] + +SLACK_INPUT_ACTION_PREFIX = "input:" +SLACK_FREEFORM_ACTION_PREFIX = "input-freeform:" +SLACK_FREEFORM_CALLBACK_ID = "input-freeform-submit" +SLACK_FREEFORM_BLOCK_ID = "input-freeform-block" +SLACK_FREEFORM_ACTION_ID = "input-freeform-text" + +# Matches an option button action id of the form ``:button:``. +_BUTTON_ACTION_PATTERN = re.compile(r"^(?P.+):button:\d+$") + + +class SlackInputOption(TypedDict): + """One selectable option in an input request.""" + + id: str + label: str + description: NotRequired[str] + style: NotRequired[SlackButtonStyle] + + +class SlackInputRequest(TypedDict): + """A prompt with optional options rendered as Block Kit.""" + + prompt: str + request_id: str + allow_freeform: NotRequired[bool] + display: NotRequired[Literal["buttons", "radio", "select"]] + options: NotRequired[list[SlackInputOption]] + + +class SlackInputAction(TypedDict): + """An inbound interaction action to parse into a response.""" + + action_id: str + selected_option_value: NotRequired[str] + value: NotRequired[str] + + +class SlackInputResponse(TypedDict): + """The parsed result of an input interaction.""" + + request_id: str + option_id: NotRequired[str] + + +class SlackFreeformViewOptions(TypedDict): + """Options for :func:`build_slack_freeform_view`.""" + + metadata: Any + prompt: NotRequired[str] + title: NotRequired[str] + + +class SlackFreeformValueEntry(TypedDict): + """One submitted view-state value entry.""" + + action_id: str + block_id: str + value: NotRequired[str] + + +class SlackAnsweredInputOptions(TypedDict): + """Options for :func:`answered_slack_input_blocks`.""" + + answer: str + prompt_block: NotRequired[Any] + user_id: NotRequired[str] + + +def input_request_to_slack_blocks(request: SlackInputRequest) -> list[SlackBlock]: + """Render an input request as Slack Block Kit blocks.""" + prompt: SlackBlock = { + "text": { + "text": _truncate(request["prompt"], LIMITS.section_text), + "type": "mrkdwn", + }, + "type": "section", + } + options = request.get("options") or [] + if len(options) == 0: + return [ + prompt, + { + "elements": [_freeform_button(request["request_id"])], + "type": "actions", + }, + ] + extras = [_freeform_button(request["request_id"])] if request.get("allow_freeform") else [] + if request.get("display") == "radio": + return [ + prompt, + { + "elements": [_radio_element(request), *extras], + "type": "actions", + }, + ] + if request.get("display") == "select": + return [ + prompt, + { + "elements": [_select_element(request), *extras], + "type": "actions", + }, + ] + limit = LIMITS.actions_elements - 1 if len(extras) > 0 else LIMITS.actions_elements + elements = [_button_element(request["request_id"], option, index) for index, option in enumerate(options[:limit])] + elements.extend(extras) + return [ + prompt, + {"elements": elements, "type": "actions"}, + ] + + +def parse_slack_input_response(action: SlackInputAction) -> SlackInputResponse | None: + """Parse an inbound action into a structured input response, or ``None``.""" + action_id = action["action_id"] + if not action_id.startswith(SLACK_INPUT_ACTION_PREFIX): + return None + request_id = action_id[len(SLACK_INPUT_ACTION_PREFIX) :] + selected = action.get("selected_option_value") + if selected is not None: + if request_id: + return {"option_id": selected, "request_id": request_id} + return None + match = _BUTTON_ACTION_PATTERN.match(request_id) + value = action.get("value") + if match is not None and value is not None: + return {"option_id": value, "request_id": match.group("request_id")} + return None + + +def build_slack_freeform_view(options: SlackFreeformViewOptions) -> dict[str, Any]: + """Build a freeform-text input modal view payload.""" + title_source = options.get("title") + if title_source is None: + title_source = options.get("prompt") + if title_source is None: + title_source = "Your answer" + title = _truncate(title_source, 24) + blocks: list[Any] = [] + prompt = options.get("prompt") + if prompt: + blocks.append( + { + "text": { + "text": _truncate(prompt, LIMITS.section_text), + "type": "mrkdwn", + }, + "type": "section", + } + ) + blocks.append( + { + "block_id": SLACK_FREEFORM_BLOCK_ID, + "element": { + "action_id": SLACK_FREEFORM_ACTION_ID, + "multiline": True, + "type": "plain_text_input", + }, + "label": {"text": "Answer", "type": "plain_text"}, + "type": "input", + } + ) + metadata = options["metadata"] + private_metadata = metadata if isinstance(metadata, str) else json.dumps(metadata, separators=(",", ":")) + return { + "blocks": blocks, + "callback_id": SLACK_FREEFORM_CALLBACK_ID, + "close": {"text": "Cancel", "type": "plain_text"}, + "private_metadata": private_metadata, + "submit": {"text": "Submit", "type": "plain_text"}, + "title": {"text": title, "type": "plain_text"}, + "type": "modal", + } + + +def parse_slack_freeform_value( + values: list[SlackFreeformValueEntry], +) -> str | None: + """Extract the freeform answer from submitted view-state values.""" + for value in values: + if value["block_id"] == SLACK_FREEFORM_BLOCK_ID and value["action_id"] == SLACK_FREEFORM_ACTION_ID: + return value.get("value") + return None + + +def answered_slack_input_blocks( + input: SlackAnsweredInputOptions, +) -> list[SlackBlock]: + """Render the confirmation blocks shown after an input is answered.""" + blocks: list[SlackBlock] = [] + prompt_block = input.get("prompt_block") + if prompt_block and isinstance(prompt_block, dict): + blocks.append(prompt_block) + answer = input["answer"] + blocks.append( + { + "text": {"text": f":white_check_mark: *{answer}*", "type": "mrkdwn"}, + "type": "section", + } + ) + user_id = input.get("user_id") + if user_id: + blocks.append( + { + "elements": [{"text": f"Answered by <@{user_id}>", "type": "mrkdwn"}], + "type": "context", + } + ) + return blocks + + +def _freeform_button(request_id: str) -> dict[str, Any]: + return { + "action_id": f"{SLACK_FREEFORM_ACTION_PREFIX}{request_id}", + "style": "primary", + "text": {"text": "Type your answer", "type": "plain_text"}, + "type": "button", + "value": request_id, + } + + +def _button_element( + request_id: str, + option: SlackInputOption, + index: int, +) -> dict[str, Any]: + style = option.get("style") + return _compact( + { + "action_id": f"{SLACK_INPUT_ACTION_PREFIX}{request_id}:button:{index}", + "style": style if style in ("primary", "danger") else None, + "text": { + "text": _truncate(option["label"], LIMITS.button_text), + "type": "plain_text", + }, + "type": "button", + "value": _truncate(option["id"], LIMITS.button_value), + } + ) + + +def _select_element(request: SlackInputRequest) -> dict[str, Any]: + options = [ + { + "text": { + "text": _truncate(option["label"], LIMITS.option_text), + "type": "plain_text", + }, + "value": _truncate(option["id"], LIMITS.option_value), + } + for option in (request.get("options") or []) + ] + return { + "action_id": f"{SLACK_INPUT_ACTION_PREFIX}{request['request_id']}", + "options": options[: LIMITS.options], + "placeholder": {"text": "Choose an option", "type": "plain_text"}, + "type": "static_select", + } + + +def _radio_element(request: SlackInputRequest) -> dict[str, Any]: + options = [ + { + "text": { + "text": _truncate(option["label"], LIMITS.option_text), + "type": "plain_text", + }, + "value": _truncate(option["id"], LIMITS.option_value), + } + for option in (request.get("options") or []) + ] + if len(options) > LIMITS.radio_options: + return _select_element(request) + return { + "action_id": f"{SLACK_INPUT_ACTION_PREFIX}{request['request_id']}", + "options": options, + "type": "radio_buttons", + } + + +def _truncate(value: str, limit: int) -> str: + return value[:limit] if len(value) > limit else value + + +def _compact(value: dict[str, Any]) -> dict[str, Any]: + return {key: item for key, item in value.items() if item is not None} diff --git a/src/chat_sdk/adapters/slack/blocks/limits.py b/src/chat_sdk/adapters/slack/blocks/limits.py new file mode 100644 index 00000000..07846545 --- /dev/null +++ b/src/chat_sdk/adapters/slack/blocks/limits.py @@ -0,0 +1,64 @@ +"""Slack Block Kit size limits. + +Port of ``packages/adapter-slack/src/blocks/limits.ts`` (vercel/chat#555). +Docs-backed character and element caps Slack enforces on Block Kit +payloads. Field names are snake_case (camelCase upstream) since these are +internal constants, not a serialization boundary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True) +class _Limits: + action_id: int + actions_elements: int + block_id: int + blocks: int + button_text: int + button_url: int + button_value: int + fields: int + field_text: int + header_text: int + image_alt: int + image_url: int + option_description: int + option_text: int + option_value: int + options: int + placeholder: int + radio_options: int + section_text: int + table_columns: int + table_rows: int + text_object: int + + +LIMITS: Final = _Limits( + action_id=255, + actions_elements=25, + block_id=255, + blocks=50, + button_text=75, + button_url=3000, + button_value=2000, + fields=10, + field_text=2000, + header_text=150, + image_alt=2000, + image_url=3000, + option_description=75, + option_text=75, + option_value=150, + options=100, + placeholder=150, + radio_options=10, + section_text=3000, + table_columns=20, + table_rows=100, + text_object=3000, +) diff --git a/src/chat_sdk/adapters/slack/blocks/types.py b/src/chat_sdk/adapters/slack/blocks/types.py new file mode 100644 index 00000000..2649261e --- /dev/null +++ b/src/chat_sdk/adapters/slack/blocks/types.py @@ -0,0 +1,178 @@ +"""Typed card-input and Block Kit shapes for the Slack blocks subpath. + +Port of ``packages/adapter-slack/src/blocks/types.ts`` (vercel/chat#555). + +The card-input shapes (``SlackCardElement`` and its children) are a +self-contained copy of the Chat SDK card model, declared here (rather than +imported from ``chat_sdk.cards``) so the blocks subpath stays runtime-free. +Input field names are snake_case (``image_url``, ``initial_option``, +``callback_url``), matching ``chat_sdk.cards`` and the Python port's +internal convention — upstream uses camelCase. The emitted Block Kit dicts +keep Slack's API field names verbatim (``alt_text``, ``action_id``, +``block_id``, ``static_select``, ``raw_text``, ...), which is the Slack +serialization boundary. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal, NotRequired, TypedDict + +SlackButtonStyle = Literal["danger", "default", "primary"] +SlackTextStyle = Literal["bold", "muted", "plain"] +SlackTableAlignment = Literal["center", "left", "right"] + + +class SlackTextElement(TypedDict): + """A run of card text, optionally styled.""" + + type: Literal["text"] + content: str + style: NotRequired[SlackTextStyle] + + +class SlackImageElement(TypedDict): + """A standalone image block.""" + + type: Literal["image"] + url: str + alt: NotRequired[str] + + +class SlackDividerElement(TypedDict): + """A horizontal rule.""" + + type: Literal["divider"] + + +class SlackButtonElement(TypedDict): + """An interactive button that emits an ``action_id``/``value``.""" + + type: Literal["button"] + id: str + label: str + callback_url: NotRequired[str] + disabled: NotRequired[bool] + style: NotRequired[SlackButtonStyle] + value: NotRequired[str] + + +class SlackLinkButtonElement(TypedDict): + """A button that opens a URL.""" + + type: Literal["link-button"] + label: str + url: str + style: NotRequired[SlackButtonStyle] + + +class SlackSelectOptionElement(TypedDict): + """One option in a select or radio group.""" + + label: str + value: str + description: NotRequired[str] + + +class SlackSelectElement(TypedDict): + """A static single-select menu.""" + + type: Literal["select"] + id: str + label: str + options: list[SlackSelectOptionElement] + initial_option: NotRequired[str] + placeholder: NotRequired[str] + + +class SlackRadioSelectElement(TypedDict): + """A radio-button group.""" + + type: Literal["radio_select"] + id: str + label: str + options: list[SlackSelectOptionElement] + initial_option: NotRequired[str] + + +class SlackLinkElement(TypedDict): + """A standalone hyperlink rendered as a section.""" + + type: Literal["link"] + label: str + url: str + + +class SlackFieldElement(TypedDict): + """A label/value pair inside a fields block.""" + + type: Literal["field"] + label: str + value: str + + +class SlackFieldsElement(TypedDict): + """A two-column fields block.""" + + type: Literal["fields"] + children: list[SlackFieldElement] + + +class SlackTableElement(TypedDict): + """A table rendered natively or as an ASCII fallback.""" + + type: Literal["table"] + headers: list[str] + rows: list[list[str]] + align: NotRequired[list[SlackTableAlignment]] + + +class SlackActionsElement(TypedDict): + """A row of interactive action elements.""" + + type: Literal["actions"] + children: list[SlackButtonElement | SlackLinkButtonElement | SlackRadioSelectElement | SlackSelectElement] + + +class SlackSectionElement(TypedDict): + """A grouping element whose children are flattened inline.""" + + type: Literal["section"] + children: list[SlackCardChild] + + +# A child of a card or section. Discriminated on the ``type`` key. +SlackCardChild = ( + SlackActionsElement + | SlackDividerElement + | SlackFieldsElement + | SlackImageElement + | SlackLinkElement + | SlackSectionElement + | SlackTableElement + | SlackTextElement +) + + +class SlackCardElement(TypedDict): + """The root card object converted by :func:`cardToSlackBlocks`.""" + + type: Literal["card"] + children: list[SlackCardChild] + image_url: NotRequired[str] + subtitle: NotRequired[str] + title: NotRequired[str] + + +# An emitted Slack Block Kit block (``{"type": ..., ...}``). +SlackBlock = dict[str, Any] + +# A Slack text object (``{"type": "mrkdwn" | "plain_text", "text": ...}``). +SlackTextObject = dict[str, Any] + + +class SlackBlocksOptions(TypedDict, total=False): + """Options for :func:`cardToSlackBlocks`.""" + + convert_emoji: Callable[[str], str] + max_blocks: int diff --git a/tests/test_slack_blocks_primitives.py b/tests/test_slack_blocks_primitives.py new file mode 100644 index 00000000..f37b3343 --- /dev/null +++ b/tests/test_slack_blocks_primitives.py @@ -0,0 +1,569 @@ +"""Tests for the runtime-free Slack Block Kit primitives subpath. + +Port of ``packages/adapter-slack/src/blocks/index.test.ts`` and +``blocks/boundary.test.ts`` (vercel/chat#555, #559), exposed upstream as +``@chat-adapter/slack/blocks``. Card-input keys are snake_case +(``image_url``, ``initial_option``, ``request_id``, ...) per the Python +port convention; emitted Block Kit dicts keep Slack's API field names. +""" + +from __future__ import annotations + +import subprocess +import sys +from typing import Any + +from chat_sdk.adapters.slack.blocks import ( + answered_slack_input_blocks, + build_slack_freeform_view, + card_to_block_kit, + card_to_fallback_text, + card_to_slack_blocks, + card_to_slack_fallback_text, + convert_slack_emoji_placeholders, + input_request_to_slack_blocks, + parse_slack_freeform_value, + parse_slack_input_response, +) + + +def _card(children: list[Any] | None = None) -> dict[str, Any]: + return {"children": children if children is not None else [], "type": "card"} + + +class TestSlackBlockKitPrimitives: + def test_converts_card_headers_and_context(self) -> None: + assert card_to_slack_blocks( + { + "children": [], + "image_url": "https://example.com/image.png", + "subtitle": "Status changed", + "title": "Order", + "type": "card", + } + ) == [ + { + "text": {"emoji": True, "text": "Order", "type": "plain_text"}, + "type": "header", + }, + { + "elements": [{"text": "Status changed", "type": "mrkdwn"}], + "type": "context", + }, + { + "alt_text": "Order", + "image_url": "https://example.com/image.png", + "type": "image", + }, + ] + + def test_truncates_header_text_to_header_block_limit(self) -> None: + title = "a" * 200 + + assert card_to_slack_blocks({"children": [], "title": title, "type": "card"})[0] == { + "text": {"emoji": True, "text": "a" * 150, "type": "plain_text"}, + "type": "header", + } + + def test_truncates_image_urls_to_image_block_limit(self) -> None: + long_url = "https://example.com/" + "a" * 4000 + top_blocks = card_to_slack_blocks( + { + "children": [], + "image_url": long_url, + "title": "{{emoji:frame}}", + "type": "card", + } + ) + + assert top_blocks[0] == { + "text": {"emoji": True, "text": ":frame:", "type": "plain_text"}, + "type": "header", + } + assert top_blocks[1] == { + "alt_text": ":frame:", + "image_url": "https://example.com/" + "a" * 2980, + "type": "image", + } + assert card_to_slack_blocks({"children": [{"type": "image", "url": long_url}], "type": "card"})[0] == { + "alt_text": "Image", + "image_url": "https://example.com/" + "a" * 2980, + "type": "image", + } + + def test_converts_text_and_links(self) -> None: + assert card_to_slack_blocks( + _card( + [ + {"content": "plain", "type": "text"}, + {"content": "bold", "style": "bold", "type": "text"}, + {"content": "muted", "style": "muted", "type": "text"}, + {"label": "Docs", "type": "link", "url": "https://example.com"}, + ] + ) + ) == [ + {"text": {"text": "plain", "type": "mrkdwn"}, "type": "section"}, + {"text": {"text": "*bold*", "type": "mrkdwn"}, "type": "section"}, + {"elements": [{"text": "muted", "type": "mrkdwn"}], "type": "context"}, + { + "text": {"text": "", "type": "mrkdwn"}, + "type": "section", + }, + ] + + def test_converts_actions(self) -> None: + blocks = card_to_slack_blocks( + _card( + [ + { + "children": [ + { + "id": "approve", + "label": "Approve", + "style": "primary", + "type": "button", + }, + { + "label": "Docs", + "style": "default", + "type": "link-button", + "url": "https://example.com/docs", + }, + { + "id": "status", + "label": "Status", + "options": [ + {"label": "Open", "value": "open"}, + {"label": "Closed", "value": "closed"}, + ], + "placeholder": "Choose", + "type": "select", + }, + { + "id": "plan", + "label": "Plan", + "options": [ + {"description": "For teams", "label": "Pro", "value": "pro"}, + ], + "type": "radio_select", + }, + ], + "type": "actions", + }, + ] + ) + ) + + assert blocks[0] == { + "elements": [ + { + "action_id": "approve", + "style": "primary", + "text": {"emoji": True, "text": "Approve", "type": "plain_text"}, + "type": "button", + }, + { + "action_id": "link-https://example.com/docs", + "text": {"emoji": True, "text": "Docs", "type": "plain_text"}, + "type": "button", + "url": "https://example.com/docs", + }, + { + "action_id": "status", + "options": [ + {"text": {"text": "Open", "type": "plain_text"}, "value": "open"}, + {"text": {"text": "Closed", "type": "plain_text"}, "value": "closed"}, + ], + "placeholder": {"emoji": True, "text": "Choose", "type": "plain_text"}, + "type": "static_select", + }, + { + "action_id": "plan", + "options": [ + { + "description": {"text": "For teams", "type": "mrkdwn"}, + "text": {"text": "Pro", "type": "mrkdwn"}, + "value": "pro", + }, + ], + "type": "radio_buttons", + }, + ], + "type": "actions", + } + + def test_limits_action_elements_and_select_options(self) -> None: + blocks = card_to_slack_blocks( + _card( + [ + { + "children": [ + {"id": f"b{index}", "label": f"Button {index}", "type": "button"} for index in range(30) + ], + "type": "actions", + }, + { + "children": [ + { + "id": "select", + "label": "Select", + "options": [ + {"label": f"Option {index}", "value": f"value-{index}"} for index in range(120) + ], + "type": "select", + }, + ], + "type": "actions", + }, + ] + ) + ) + + assert len(blocks[0]["elements"]) == 25 + assert len(blocks[1]["elements"][0]["options"]) == 100 + + def test_truncates_option_values_to_option_object_limit(self) -> None: + block = card_to_slack_blocks( + _card( + [ + { + "children": [ + { + "id": "select", + "label": "Select", + "options": [{"label": "Option", "value": "v" * 200}], + "type": "select", + }, + ], + "type": "actions", + }, + ] + ) + )[0] + + assert block["elements"][0]["options"][0]["value"] == "v" * 150 + + def test_matches_truncated_initial_options_for_select_elements(self) -> None: + long_value = "v" * 200 + block = card_to_slack_blocks( + _card( + [ + { + "children": [ + { + "id": "select", + "initial_option": long_value, + "label": "Select", + "options": [{"label": "Option", "value": long_value}], + "type": "select", + }, + { + "id": "radio", + "initial_option": long_value, + "label": "Radio", + "options": [{"label": "Option", "value": long_value}], + "type": "radio_select", + }, + ], + "type": "actions", + }, + ] + ) + )[0] + + assert block["elements"][0]["initial_option"]["value"] == "v" * 150 + assert block["elements"][1]["initial_option"]["value"] == "v" * 150 + + def test_omits_initial_options_when_no_initial_value(self) -> None: + block = card_to_slack_blocks( + _card( + [ + { + "children": [ + { + "id": "select", + "label": "Select", + "options": [{"label": "Option", "value": ""}], + "type": "select", + }, + ], + "type": "actions", + }, + ] + ) + )[0] + + assert "initial_option" not in block["elements"][0] + + def test_converts_fields_and_tables(self) -> None: + blocks = card_to_slack_blocks( + _card( + [ + { + "children": [ + {"label": "Name", "type": "field", "value": "Ada"}, + {"label": "Role", "type": "field", "value": "Engineer"}, + ], + "type": "fields", + }, + { + "align": ["left", "right"], + "headers": ["Name", "Score"], + "rows": [["Ada", "10"]], + "type": "table", + }, + ] + ) + ) + + assert blocks[0] == { + "fields": [ + {"text": "*Name*\nAda", "type": "mrkdwn"}, + {"text": "*Role*\nEngineer", "type": "mrkdwn"}, + ], + "type": "section", + } + assert blocks[1] == { + "column_settings": [{"align": "left"}, {"align": "right"}], + "rows": [ + [ + {"text": "Name", "type": "raw_text"}, + {"text": "Score", "type": "raw_text"}, + ], + [ + {"text": "Ada", "type": "raw_text"}, + {"text": "10", "type": "raw_text"}, + ], + ], + "type": "table", + } + + def test_falls_back_to_ascii_tables_after_one_native_table(self) -> None: + table = {"headers": ["A", "B"], "rows": [["1", "2"]], "type": "table"} + + assert card_to_slack_blocks(_card([table, table]))[1] == { + "text": {"text": "```\nA | B\n1 | 2\n```", "type": "mrkdwn"}, + "type": "section", + } + + def test_generates_slack_fallback_text(self) -> None: + assert ( + card_to_slack_fallback_text( + { + "children": [ + {"content": "Hello", "type": "text"}, + { + "children": [{"label": "Status", "type": "field", "value": "Ready"}], + "type": "fields", + }, + { + "children": [{"id": "ok", "label": "OK", "type": "button"}], + "type": "actions", + }, + ], + "subtitle": "Sub", + "title": "Title", + "type": "card", + } + ) + == "*Title*\nSub\nHello\nStatus: Ready" + ) + + def test_keeps_compatibility_aliases(self) -> None: + card = _card([{"content": "hello", "type": "text"}]) + + assert card_to_block_kit(card) == card_to_slack_blocks(card) + assert card_to_fallback_text(card) == card_to_slack_fallback_text(card) + + def test_supports_custom_emoji_conversion(self) -> None: + card = _card([{"content": "{{emoji:thumbs_up}}", "type": "text"}]) + + assert card_to_slack_blocks(card)[0] == { + "text": {"text": ":thumbs_up:", "type": "mrkdwn"}, + "type": "section", + } + assert card_to_slack_blocks(card, {"convert_emoji": lambda _text: ":+1:"})[0] == { + "text": {"text": ":+1:", "type": "mrkdwn"}, + "type": "section", + } + assert convert_slack_emoji_placeholders("hi {{emoji:wave}}") == "hi :wave:" + + def test_renders_input_requests_as_slack_buttons(self) -> None: + assert input_request_to_slack_blocks( + { + "options": [ + {"id": "approve", "label": "Approve", "style": "primary"}, + {"id": "deny", "label": "Deny", "style": "danger"}, + ], + "prompt": "Approve deploy?", + "request_id": "req-1", + } + ) == [ + {"text": {"text": "Approve deploy?", "type": "mrkdwn"}, "type": "section"}, + { + "elements": [ + { + "action_id": "input:req-1:button:0", + "style": "primary", + "text": {"text": "Approve", "type": "plain_text"}, + "type": "button", + "value": "approve", + }, + { + "action_id": "input:req-1:button:1", + "style": "danger", + "text": {"text": "Deny", "type": "plain_text"}, + "type": "button", + "value": "deny", + }, + ], + "type": "actions", + }, + ] + + def test_renders_input_requests_as_selects(self) -> None: + assert input_request_to_slack_blocks( + { + "display": "select", + "options": [{"id": "one", "label": "One"}], + "prompt": "Pick one", + "request_id": "req-1", + } + )[1] == { + "elements": [ + { + "action_id": "input:req-1", + "options": [{"text": {"text": "One", "type": "plain_text"}, "value": "one"}], + "placeholder": {"text": "Choose an option", "type": "plain_text"}, + "type": "static_select", + }, + ], + "type": "actions", + } + + def test_renders_input_requests_as_radios(self) -> None: + assert input_request_to_slack_blocks( + { + "display": "radio", + "options": [{"id": "one", "label": "One"}], + "prompt": "Pick one", + "request_id": "req-1", + } + )[1] == { + "elements": [ + { + "action_id": "input:req-1", + "options": [{"text": {"text": "One", "type": "plain_text"}, "value": "one"}], + "type": "radio_buttons", + }, + ], + "type": "actions", + } + + def test_renders_freeform_alongside_options_when_allowed(self) -> None: + assert input_request_to_slack_blocks( + { + "allow_freeform": True, + "options": [{"id": "approve", "label": "Approve"}], + "prompt": "Approve deploy?", + "request_id": "req-1", + } + )[1] == { + "elements": [ + { + "action_id": "input:req-1:button:0", + "text": {"text": "Approve", "type": "plain_text"}, + "type": "button", + "value": "approve", + }, + { + "action_id": "input-freeform:req-1", + "style": "primary", + "text": {"text": "Type your answer", "type": "plain_text"}, + "type": "button", + "value": "req-1", + }, + ], + "type": "actions", + } + + def test_renders_and_reads_freeform_input_modals(self) -> None: + view = build_slack_freeform_view({"metadata": {"request_id": "req-1"}, "prompt": "Tell me why"}) + + assert view["callback_id"] == "input-freeform-submit" + assert view["private_metadata"] == '{"request_id":"req-1"}' + assert view["title"] == {"text": "Tell me why", "type": "plain_text"} + assert view["type"] == "modal" + assert ( + parse_slack_freeform_value( + [ + { + "action_id": "input-freeform-text", + "block_id": "input-freeform-block", + "value": "because", + } + ] + ) + == "because" + ) + + def test_parses_input_actions_and_answered_blocks(self) -> None: + assert parse_slack_input_response({"action_id": "input:req-1:button:0", "value": "approve"}) == { + "option_id": "approve", + "request_id": "req-1", + } + assert parse_slack_input_response({"action_id": "input:req-2", "selected_option_value": "later"}) == { + "option_id": "later", + "request_id": "req-2", + } + assert answered_slack_input_blocks({"answer": "Approve", "user_id": "U123"}) == [ + { + "text": {"text": ":white_check_mark: *Approve*", "type": "mrkdwn"}, + "type": "section", + }, + { + "elements": [{"text": "Answered by <@U123>", "type": "mrkdwn"}], + "type": "context", + }, + ] + + def test_parse_input_response_returns_none_for_unprefixed_actions(self) -> None: + # Actions without the ``input:`` prefix are not ours to parse. + assert parse_slack_input_response({"action_id": "other:req-1", "value": "x"}) is None + # A button action id with no matching ``:button:`` and no + # selected option value yields no response. + assert parse_slack_input_response({"action_id": "input:req-1", "value": "x"}) is None + + def test_build_freeform_view_passes_string_metadata_through(self) -> None: + # A string metadata is used verbatim (not JSON re-encoded). + view = build_slack_freeform_view({"metadata": "raw-token"}) + + assert view["private_metadata"] == "raw-token" + # With no prompt/title, the title falls back to the default. + assert view["title"] == {"text": "Your answer", "type": "plain_text"} + + +class TestBlocksImportBoundary: + def test_does_not_import_the_full_adapter_or_runtime_packages(self) -> None: + """Importing the blocks subpath must not pull in slack_sdk, an HTTP + client, or the high-level adapter module (port of upstream's + ``blocks/boundary.test.ts``).""" + code = ( + "import sys\n" + "import chat_sdk.adapters.slack.blocks\n" + "forbidden = [\n" + " 'slack_sdk',\n" + " 'httpx',\n" + " 'aiohttp',\n" + " 'chat_sdk.adapters.slack.adapter',\n" + "]\n" + "loaded = [name for name in forbidden if name in sys.modules]\n" + "assert not loaded, f'blocks subpath imported runtime modules: {loaded}'\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr From 03bbea29eae36a22a94aa40508de08a71368451e Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 18 Jun 2026 12:43:19 -0700 Subject: [PATCH 5/5] 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. --- docs/UPSTREAM_SYNC.md | 2 + .../adapters/slack/webhook/__init__.py | 6 + src/chat_sdk/adapters/slack/webhook/parse.py | 136 +++++++++++- src/chat_sdk/adapters/slack/webhook/types.py | 51 +++++ tests/test_slack_webhook_primitives.py | 202 +++++++++++++++++- 5 files changed, 384 insertions(+), 13 deletions(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 78ee0978..2a4a157d 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -645,6 +645,8 @@ stay explicit instead of being rediscovered in code review. | 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. | | 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`. | +| 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 ` 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. | ### Platform-specific gaps diff --git a/src/chat_sdk/adapters/slack/webhook/__init__.py b/src/chat_sdk/adapters/slack/webhook/__init__.py index b0d82ad7..63a175f7 100644 --- a/src/chat_sdk/adapters/slack/webhook/__init__.py +++ b/src/chat_sdk/adapters/slack/webhook/__init__.py @@ -19,12 +19,15 @@ SlackBlockSuggestionPayload, SlackContinuation, SlackDirectMessagePayload, + SlackFile, SlackHeaders, SlackRetry, SlackSlashCommandPayload, SlackUnsupportedPayload, SlackUrlVerificationPayload, + SlackUser, SlackViewClosedPayload, + SlackViewStateValue, SlackViewSubmissionPayload, SlackWebhookError, SlackWebhookParseError, @@ -46,12 +49,15 @@ "SlackBlockSuggestionPayload", "SlackContinuation", "SlackDirectMessagePayload", + "SlackFile", "SlackHeaders", "SlackRetry", "SlackSlashCommandPayload", "SlackUnsupportedPayload", "SlackUrlVerificationPayload", + "SlackUser", "SlackViewClosedPayload", + "SlackViewStateValue", "SlackViewSubmissionPayload", "SlackWebhookError", "SlackWebhookParseError", diff --git a/src/chat_sdk/adapters/slack/webhook/parse.py b/src/chat_sdk/adapters/slack/webhook/parse.py index cec6154f..b305a200 100644 --- a/src/chat_sdk/adapters/slack/webhook/parse.py +++ b/src/chat_sdk/adapters/slack/webhook/parse.py @@ -19,12 +19,15 @@ SlackBlockSuggestionPayload, SlackContinuation, SlackDirectMessagePayload, + SlackFile, SlackHeaders, SlackRetry, SlackSlashCommandPayload, SlackUnsupportedPayload, SlackUrlVerificationPayload, + SlackUser, SlackViewClosedPayload, + SlackViewStateValue, SlackViewSubmissionPayload, SlackWebhookPayload, ) @@ -151,6 +154,7 @@ def _parse_message_event( "enterprise_id": enterprise_id, "event_id": optional_string(envelope.get("event_id")), "event_time": event_time if isinstance(event_time, (int, float)) and not isinstance(event_time, bool) else None, + "files": _parse_files(event.get("files")), "is_ext_shared_channel": ( envelope.get("is_ext_shared_channel") if isinstance(envelope.get("is_ext_shared_channel"), bool) else None ), @@ -195,7 +199,7 @@ def _parse_block_actions(raw: dict[str, Any], retry: SlackRetry | None) -> Slack channel = record_value(raw.get("channel")) container = record_value(raw.get("container")) message = record_value(raw.get("message")) - user = record_value(raw.get("user")) + user = _parse_user(raw.get("user")) team = record_value(raw.get("team")) enterprise = record_value(raw.get("enterprise")) channel_id = optional_string(_get(channel, "id")) or optional_string(_get(container, "channel_id")) @@ -203,7 +207,7 @@ def _parse_block_actions(raw: dict[str, Any], retry: SlackRetry | None) -> Slack thread_ts = ( optional_string(_get(message, "thread_ts")) or optional_string(_get(container, "thread_ts")) or message_ts ) - team_id = optional_string(_get(team, "id")) or optional_string(_get(user, "team_id")) + team_id = optional_string(_get(team, "id")) or user.team_id enterprise_id = optional_string(_get(enterprise, "id")) or optional_string(_get(team, "enterprise_id")) continuation = ( SlackContinuation( @@ -215,16 +219,22 @@ def _parse_block_actions(raw: dict[str, Any], retry: SlackRetry | None) -> Slack if channel_id and thread_ts else None ) + message_blocks_value = _get(message, "blocks") + message_blocks = message_blocks_value if isinstance(message_blocks_value, list) else None + message_prompt_block = _find_prompt_block(message_blocks) actions = raw.get("actions") return SlackBlockActionsPayload( - actions=[_parse_action(action) for action in actions] if isinstance(actions, list) else [], + actions=[_parse_action(action, user) for action in actions] if isinstance(actions, list) else [], channel_id=channel_id, continuation=continuation, enterprise_id=enterprise_id, is_enterprise_install=( raw.get("is_enterprise_install") if isinstance(raw.get("is_enterprise_install"), bool) else None ), + message_blocks=message_blocks, + message_prompt_block=message_prompt_block, + message_prompt_text=_read_prompt_text(message_prompt_block), message_ts=message_ts, raw=raw, response_url=optional_string(raw.get("response_url")), @@ -232,22 +242,26 @@ def _parse_block_actions(raw: dict[str, Any], retry: SlackRetry | None) -> Slack team_id=team_id, thread_ts=thread_ts, trigger_id=optional_string(raw.get("trigger_id")), - user_id=string_value(_get(user, "id")), - user_name=optional_string(_get(user, "username")) or optional_string(_get(user, "name")), + user=user, + user_id=user.id, + user_name=user.username or user.name, ) -def _parse_action(action: Any) -> SlackAction: +def _parse_action(action: Any, user: SlackUser | None = None) -> SlackAction: raw = action if is_record(action) else {} selected_option = record_value(raw.get("selected_option")) text = record_value(raw.get("text")) + selected_text = record_value(_get(selected_option, "text")) return SlackAction( action_id=string_value(raw.get("action_id")), block_id=optional_string(raw.get("block_id")), - label=optional_string(_get(text, "text")), + label=optional_string(_get(selected_text, "text")) or optional_string(_get(text, "text")), raw=raw, + selected_option_label=optional_string(_get(selected_text, "text")), selected_option_value=optional_string(_get(selected_option, "value")), type=string_value(raw.get("type")), + user=user, value=optional_string(raw.get("value")), ) @@ -273,18 +287,22 @@ def _parse_block_suggestion(raw: dict[str, Any], retry: SlackRetry | None) -> Sl def _parse_view_submission(raw: dict[str, Any], retry: SlackRetry | None) -> SlackViewSubmissionPayload: team = record_value(raw.get("team")) enterprise = record_value(raw.get("enterprise")) - user = record_value(raw.get("user")) + user = _parse_user(raw.get("user")) view = record_value(raw.get("view")) if view is None: view = {} response_urls = view.get("response_urls") return SlackViewSubmissionPayload( + callback_id=optional_string(view.get("callback_id")), enterprise_id=optional_string(_get(enterprise, "id")) or optional_string(_get(team, "enterprise_id")), + private_metadata=optional_string(view.get("private_metadata")), raw=raw, response_urls=response_urls if isinstance(response_urls, list) else None, retry=retry, team_id=optional_string(_get(team, "id")), - user_id=string_value(_get(user, "id")), + user=user, + user_id=user.id, + values=_parse_view_values(view), view=view, ) @@ -292,18 +310,114 @@ def _parse_view_submission(raw: dict[str, Any], retry: SlackRetry | None) -> Sla def _parse_view_closed(raw: dict[str, Any], retry: SlackRetry | None) -> SlackViewClosedPayload: team = record_value(raw.get("team")) enterprise = record_value(raw.get("enterprise")) - user = record_value(raw.get("user")) + user = _parse_user(raw.get("user")) view = record_value(raw.get("view")) return SlackViewClosedPayload( enterprise_id=optional_string(_get(enterprise, "id")) or optional_string(_get(team, "enterprise_id")), raw=raw, retry=retry, team_id=optional_string(_get(team, "id")), - user_id=string_value(_get(user, "id")), + user=user, + user_id=user.id, view=view if view is not None else {}, ) +def _parse_files(value: Any) -> list[SlackFile]: + if not isinstance(value, list): + return [] + files: list[SlackFile] = [] + for entry in value: + file = record_value(entry) + if file is None: + continue + mime_type = optional_string(file.get("mimetype")) + size = file.get("size") + files.append( + SlackFile( + download_url=optional_string(file.get("url_private_download")), + filetype=optional_string(file.get("filetype")), + id=string_value(file.get("id")), + mime_type=mime_type, + name=optional_string(file.get("name")), + raw=file, + size=size if isinstance(size, (int, float)) and not isinstance(size, bool) else None, + title=optional_string(file.get("title")), + type=_infer_file_type(mime_type), + url=optional_string(file.get("url_private")), + ) + ) + return files + + +def _infer_file_type(mime_type: str | None) -> Literal["audio", "file", "image", "video"]: + if mime_type is not None and mime_type.startswith("image/"): + return "image" + if mime_type is not None and mime_type.startswith("video/"): + return "video" + if mime_type is not None and mime_type.startswith("audio/"): + return "audio" + return "file" + + +def _parse_user(value: Any) -> SlackUser: + user = record_value(value) + if user is None: + user = {} + return SlackUser( + id=string_value(user.get("id")), + name=optional_string(user.get("name")), + team_id=optional_string(user.get("team_id")), + username=optional_string(user.get("username")), + ) + + +def _find_prompt_block(blocks: list[Any] | None) -> Any: + if blocks is None: + return None + for block in blocks: + item = record_value(block) + if item is not None and item.get("type") == "section" and record_value(item.get("text")) is not None: + return block + return None + + +def _read_prompt_text(block: Any) -> str | None: + item = record_value(block) + text = record_value(_get(item, "text")) + return optional_string(_get(text, "text")) + + +def _parse_view_values(view: dict[str, Any]) -> list[SlackViewStateValue]: + state = record_value(view.get("state")) + values = record_value(_get(state, "values")) + if values is None: + return [] + output: list[SlackViewStateValue] = [] + for block_id, block in values.items(): + actions = record_value(block) + if actions is None: + continue + for action_id, action in actions.items(): + raw = record_value(action) + if raw is None: + continue + selected_option = record_value(raw.get("selected_option")) + selected_text = record_value(_get(selected_option, "text")) + output.append( + SlackViewStateValue( + action_id=action_id, + block_id=block_id, + raw=raw, + selected_option_label=optional_string(_get(selected_text, "text")), + selected_option_value=optional_string(_get(selected_option, "value")), + type=optional_string(raw.get("type")), + value=optional_string(raw.get("value")), + ) + ) + return output + + def _get(record: dict[str, Any] | None, key: str) -> Any: """Optional-chained lookup: ``record?.[key]``.""" return record.get(key) if record is not None else None diff --git a/src/chat_sdk/adapters/slack/webhook/types.py b/src/chat_sdk/adapters/slack/webhook/types.py index ddf4da91..232d5e02 100644 --- a/src/chat_sdk/adapters/slack/webhook/types.py +++ b/src/chat_sdk/adapters/slack/webhook/types.py @@ -62,6 +62,32 @@ class SlackContinuation: team_id: str | None = None +@dataclass +class SlackUser: + """The acting Slack user attached to interactive payloads.""" + + id: str + name: str | None = None + team_id: str | None = None + username: str | None = None + + +@dataclass +class SlackFile: + """A file shared on a message-like Events API callback.""" + + id: str + raw: dict[str, Any] + type: Literal["audio", "file", "image", "video"] + download_url: str | None = None + filetype: str | None = None + mime_type: str | None = None + name: str | None = None + size: float | None = None + title: str | None = None + url: str | None = None + + @dataclass class SlackUrlVerificationPayload: """Slack ``url_verification`` handshake payload.""" @@ -86,6 +112,7 @@ class _SlackEventBasePayload: enterprise_id: str | None = None event_id: str | None = None event_time: float | None = None + files: list[SlackFile] | None = None is_ext_shared_channel: bool | None = None retry: SlackRetry | None = None team_id: str | None = None @@ -139,7 +166,9 @@ class SlackAction: type: str block_id: str | None = None label: str | None = None + selected_option_label: str | None = None selected_option_value: str | None = None + user: SlackUser | None = None value: str | None = None @@ -154,12 +183,16 @@ class SlackBlockActionsPayload: continuation: SlackContinuation | None = None enterprise_id: str | None = None is_enterprise_install: bool | None = None + message_blocks: list[Any] | None = None + message_prompt_block: Any | None = None + message_prompt_text: str | None = None message_ts: str | None = None response_url: str | None = None retry: SlackRetry | None = None team_id: str | None = None thread_ts: str | None = None trigger_id: str | None = None + user: SlackUser | None = None user_name: str | None = None kind: Literal["block_actions"] = "block_actions" @@ -180,6 +213,19 @@ class SlackBlockSuggestionPayload: kind: Literal["block_suggestion"] = "block_suggestion" +@dataclass +class SlackViewStateValue: + """A single resolved value inside a view's ``state.values`` map.""" + + action_id: str + block_id: str + raw: dict[str, Any] + selected_option_label: str | None = None + selected_option_value: str | None = None + type: str | None = None + value: str | None = None + + @dataclass class SlackViewSubmissionPayload: """A ``view_submission`` interactive payload.""" @@ -187,10 +233,14 @@ class SlackViewSubmissionPayload: raw: dict[str, Any] user_id: str view: dict[str, Any] + callback_id: str | None = None enterprise_id: str | None = None + private_metadata: str | None = None response_urls: list[Any] | None = None retry: SlackRetry | None = None team_id: str | None = None + user: SlackUser | None = None + values: list[SlackViewStateValue] | None = None kind: Literal["view_submission"] = "view_submission" @@ -204,6 +254,7 @@ class SlackViewClosedPayload: enterprise_id: str | None = None retry: SlackRetry | None = None team_id: str | None = None + user: SlackUser | None = None kind: Literal["view_closed"] = "view_closed" diff --git a/tests/test_slack_webhook_primitives.py b/tests/test_slack_webhook_primitives.py index 1f9c2ac8..5353fa0a 100644 --- a/tests/test_slack_webhook_primitives.py +++ b/tests/test_slack_webhook_primitives.py @@ -19,7 +19,10 @@ from chat_sdk.adapters.slack.webhook import ( SlackContinuation, + SlackFile, SlackRetry, + SlackUser, + SlackViewStateValue, SlackWebhookParseError, SlackWebhookVerificationError, parse_slack_webhook_body, @@ -228,6 +231,17 @@ def test_parses_app_mentions_with_provider_native_continuation(self): "event": { "channel": "C123", "text": "<@U999> hello", + "files": [ + { + "id": "F123", + "mimetype": "image/png", + "name": "chart.png", + "size": 123, + "title": "Chart", + "url_private": "https://files.slack.com/files-pri/chart.png", + "url_private_download": ("https://files.slack.com/files-pri/chart-download.png"), + } + ], "thread_ts": "1710000000.000001", "ts": "1710000000.000002", "type": "app_mention", @@ -257,6 +271,21 @@ def test_parses_app_mentions_with_provider_native_continuation(self): ) assert payload.event_id == "Ev123" assert payload.event_time == 1_710_000_000 + assert payload.files == [ + SlackFile( + download_url="https://files.slack.com/files-pri/chart-download.png", + id="F123", + mime_type="image/png", + name="chart.png", + raw=payload.files[0].raw, + size=123, + title="Chart", + type="image", + url="https://files.slack.com/files-pri/chart.png", + ) + ] + # ``raw`` is the verbatim Slack file object, not dropped. + assert payload.files[0].raw["url_private"] == "https://files.slack.com/files-pri/chart.png" assert payload.is_ext_shared_channel is True assert payload.retry == SlackRetry(num=2, reason="http_timeout") assert payload.text == "<@U999> hello" @@ -351,7 +380,10 @@ def test_parses_block_action_payloads(self): { "action_id": "approve", "block_id": "actions", - "selected_option": {"value": "yes"}, + "selected_option": { + "text": {"text": "Yes", "type": "plain_text"}, + "value": "yes", + }, "text": {"text": "Approve", "type": "plain_text"}, "type": "button", "value": "approve-value", @@ -365,6 +397,12 @@ def test_parses_block_action_payloads(self): "type": "message", }, "message": { + "blocks": [ + { + "text": {"text": "Approve deployment?", "type": "mrkdwn"}, + "type": "section", + } + ], "thread_ts": "1710000000.000001", "ts": "1710000000.000002", }, @@ -383,10 +421,15 @@ def test_parses_block_action_payloads(self): action = payload.actions[0] assert action.action_id == "approve" assert action.block_id == "actions" - assert action.label == "Approve" + # Upstream parse.ts:276 — the selected option's text wins over the + # element text when both are present. + assert action.label == "Yes" + assert action.selected_option_label == "Yes" assert action.selected_option_value == "yes" assert action.type == "button" assert action.value == "approve-value" + # The acting user is attached to every parsed action (parse.ts:296). + assert action.user == SlackUser(id="U123", username="josh") assert payload.channel_id == "C123" assert payload.continuation == SlackContinuation( channel_id="C123", @@ -394,14 +437,54 @@ def test_parses_block_action_payloads(self): team_id="T123", thread_ts="1710000000.000001", ) + assert payload.message_blocks == [ + { + "text": {"text": "Approve deployment?", "type": "mrkdwn"}, + "type": "section", + } + ] + assert payload.message_prompt_block == { + "text": {"text": "Approve deployment?", "type": "mrkdwn"}, + "type": "section", + } + assert payload.message_prompt_text == "Approve deployment?" assert payload.message_ts == "1710000000.000002" assert payload.response_url == "https://hooks.slack.com/actions/T123/1/abc" assert payload.team_id == "T123" assert payload.thread_ts == "1710000000.000001" assert payload.trigger_id == "123.456.abc" + assert payload.user == SlackUser(id="U123", username="josh") assert payload.user_id == "U123" assert payload.user_name == "josh" + def test_select_action_label_falls_back_to_element_text(self): + """When no option is selected, the label falls back to the element's + own text (parse.ts:276 — ``selectedText?.text || text?.text``).""" + raw = { + "actions": [ + { + "action_id": "approve", + "text": {"text": "Approve", "type": "plain_text"}, + "type": "button", + "value": "approve-value", + } + ], + "channel": {"id": "C123"}, + "container": {"channel_id": "C123", "thread_ts": "1710000000.000001"}, + "type": "block_actions", + "user": {"id": "U123"}, + } + + payload = parse_slack_webhook_body( + urlencode({"payload": json.dumps(raw)}), + content_type="application/x-www-form-urlencoded", + ) + + action = payload.actions[0] + assert action.label == "Approve" + assert action.selected_option_label is None + assert action.selected_option_value is None + def test_parses_block_suggestion_payloads(self): raw = { "action_id": "external", @@ -436,6 +519,7 @@ def test_parses_view_submissions(self): "view": { "callback_id": "feedback", "id": "V123", + "private_metadata": '{"id":"123"}', "response_urls": [ { "action_id": "target", @@ -443,6 +527,16 @@ def test_parses_view_submissions(self): "response_url": "https://hooks.slack.com/app/1/2/3", } ], + "state": { + "values": { + "feedback": { + "message": { + "type": "plain_text_input", + "value": "looks good", + } + } + } + }, }, } @@ -452,6 +546,8 @@ def test_parses_view_submissions(self): ) assert payload.kind == "view_submission" + assert payload.callback_id == "feedback" + assert payload.private_metadata == '{"id":"123"}' assert payload.response_urls == [ { "action_id": "target", @@ -460,7 +556,18 @@ def test_parses_view_submissions(self): } ] assert payload.team_id == "T123" + assert payload.user == SlackUser(id="U123") assert payload.user_id == "U123" + # parseViewValues (parse.ts:404) flattens state.values into a list. + assert payload.values == [ + SlackViewStateValue( + action_id="message", + block_id="feedback", + raw={"type": "plain_text_input", "value": "looks good"}, + type="plain_text_input", + value="looks good", + ) + ] assert payload.view["callback_id"] == "feedback" assert payload.view["id"] == "V123" @@ -481,9 +588,100 @@ def test_parses_view_closed_payloads(self): assert payload.kind == "view_closed" assert payload.enterprise_id == "E123" assert payload.team_id is None + assert payload.user == SlackUser(id="U123") assert payload.user_id == "U123" assert payload.view == {"id": "V123"} + def test_infers_file_types_from_mimetype(self): + """``inferFileType`` (parse.ts:368) maps the mimetype prefix to a + coarse media kind, defaulting to ``file`` when absent/unknown.""" + event_files = [ + {"id": "F1", "mimetype": "image/png"}, + {"id": "F2", "mimetype": "video/mp4"}, + {"id": "F3", "mimetype": "audio/mpeg"}, + {"id": "F4", "mimetype": "application/pdf"}, + {"id": "F5"}, + ] + payload = parse_slack_webhook_body( + json.dumps( + { + "event": { + "channel": "C123", + "files": event_files, + "text": "see attached", + "ts": "1710000000.000002", + "type": "app_mention", + "user": "U123", + }, + "type": "event_callback", + } + ), + content_type="application/json", + ) + + assert [f.type for f in payload.files] == ["image", "video", "audio", "file", "file"] + + def test_files_default_to_empty_list_when_absent(self): + payload = parse_slack_webhook_body( + json.dumps( + { + "event": { + "channel": "C123", + "text": "hello", + "ts": "1710000000.000002", + "type": "app_mention", + "user": "U123", + }, + "type": "event_callback", + } + ), + content_type="application/json", + ) + + assert payload.files == [] + + def test_view_values_surface_selected_option_for_select_inputs(self): + """parseViewValues (parse.ts:411) extracts the selected option's + label/value for static-select inputs.""" + raw = { + "team": {"id": "T123"}, + "type": "view_submission", + "user": {"id": "U123"}, + "view": { + "id": "V123", + "state": { + "values": { + "priority_block": { + "priority": { + "type": "static_select", + "selected_option": { + "text": {"text": "High", "type": "plain_text"}, + "value": "high", + }, + } + } + } + }, + }, + } + + payload = parse_slack_webhook_body( + urlencode({"payload": json.dumps(raw)}), + content_type="application/x-www-form-urlencoded", + ) + + assert payload.values == [ + SlackViewStateValue( + action_id="priority", + block_id="priority_block", + raw=payload.values[0].raw, + selected_option_label="High", + selected_option_value="high", + type="static_select", + value=None, + ) + ] + def test_returns_unsupported_for_valid_but_unsupported_payloads(self): payload = parse_slack_webhook_body( json.dumps(