"""Live ``slack_sdk``-backed Slack poster (design §3.3.1, §7.1 P1 — Slack first). The :mod:`agent_team.transport.slack_adapter` module ships the §3.3.1 transport contract with a dependency-injected ``poster`` seam: the adapter renders the question-set into a message dict and hands it to a ``SlackPoster = Callable[[dict[str, Any]], Mapping[str, Any]]`` whose job is to perform the real ``chat.postMessage`` and return a response carrying the message ``ts``. The foundation's default poster refuses the network so nothing ships provisioned; this module supplies the **production** poster, backed by ``slack_sdk.WebClient``, that the P1 (Slack first) live wiring injects. Deferred import (mirrors :func:`agent_team.graph.build_sqlite_checkpointer`): ``slack_sdk`` is an optional dependency that may be absent in pre-deploy / test environments, so this module imports cleanly without it. The import is deferred to the moment a live client is actually constructed, and a missing package raises a clear :class:`RuntimeError` so a misconfigured deploy fails loudly rather than silently. Message-dict to ``chat.postMessage`` mapping -------------------------------------------- The adapter's message dict (see ``SlackTransport.post_question``) carries ``channel``, ``callback_id``, ``text``, ``blocks`` and ``metadata``. Slack's ``chat.postMessage`` Web API method does **not** accept a top-level ``callback_id`` keyword argument (``callback_id`` is a legacy attachment / interactive-component field, not a message-post parameter), so passing it through verbatim would raise a ``TypeError`` / Slack ``invalid_arguments``. The durable inbound key is therefore carried by ``metadata`` instead: the adapter embeds ``question_id`` under ``metadata.event_payload.question_id``, and ``slack_adapter._extract_question_id`` reads exactly that path off an inbound message. ``chat.postMessage`` *does* accept ``metadata``, so forwarding it preserves the inbound mapping. The poster consequently **drops** ``callback_id`` from the postMessage kwargs and forwards only the parameters the Web API accepts (``channel``, ``text``, ``blocks``, ``metadata``), letting ``metadata`` do the question-id round-trip the adapter relies on. """ from __future__ import annotations import os from collections.abc import Mapping from typing import Any from agent_team.transport.slack_adapter import SlackPoster, SlackTransport __all__ = [ "build_live_slack_transport", "build_slack_poster", ] # Top-level ``chat.postMessage`` keyword arguments the live poster forwards. # ``callback_id`` is deliberately excluded: it is not a postMessage parameter, # and the durable inbound key lives in ``metadata.event_payload`` instead. _POST_MESSAGE_KEYS = ("channel", "text", "blocks", "metadata") def build_slack_poster(token: str | None = None, *, client: Any = None) -> SlackPoster: """Build a live ``slack_sdk``-backed :data:`SlackPoster` (§3.3.1, P1). The returned callable accepts the adapter's rendered message dict, performs a ``chat.postMessage``, and returns the response as a mapping carrying the message ``ts`` so ``SlackTransport._extract_ts`` can record the ``channel_ref``. ``client`` (optional) injects a pre-built Slack client for testability; any object exposing ``chat_postMessage(**kwargs)`` works. When omitted, a ``slack_sdk.WebClient`` is constructed lazily from ``token`` (falling back to the ``SLACK_BOT_TOKEN`` environment variable). The ``slack_sdk`` import is deferred so this module imports cleanly without the optional package; a missing package or a missing token raises a clear :class:`RuntimeError`. The poster maps the adapter's message dict to the Web API's accepted parameters: it forwards ``channel``, ``text``, ``blocks`` and ``metadata`` and **drops** ``callback_id`` (not a ``chat.postMessage`` parameter — the ``question_id`` round-trips via ``metadata.event_payload`` instead). See the module docstring for the full rationale. """ if client is None: client = _build_web_client(token) def _poster(message: dict[str, Any]) -> Mapping[str, Any]: kwargs = {key: message[key] for key in _POST_MESSAGE_KEYS if key in message} response = client.chat_postMessage(**kwargs) return _as_mapping(response) return _poster def build_live_slack_transport( channel: str, token: str | None = None, *, client: Any = None ) -> SlackTransport: """Build a :class:`SlackTransport` wired to a live ``slack_sdk`` poster. Convenience constructor for the P1 live coordinator: equivalent to ``SlackTransport(channel, poster=build_slack_poster(token, client=client))``. See :func:`build_slack_poster` for the token / client / deferred-import semantics. """ return SlackTransport(channel, poster=build_slack_poster(token, client=client)) def _build_web_client(token: str | None) -> Any: """Lazily construct a ``slack_sdk.WebClient`` (deferred optional import). Raises a clear :class:`RuntimeError` if ``slack_sdk`` is not installed or no token is resolvable (neither ``token`` nor ``SLACK_BOT_TOKEN``), so a misconfigured deploy fails loudly rather than silently. """ try: from slack_sdk import WebClient except ImportError as exc: # pragma: no cover - depends on optional dep raise RuntimeError( "slack_sdk is unavailable; install the 'slack_sdk' package to build " "a live Slack poster (P1), or inject a 'client' for testing." ) from exc resolved = token or os.environ.get("SLACK_BOT_TOKEN") if not resolved: raise RuntimeError( "No Slack bot token available; pass 'token' or set the " "SLACK_BOT_TOKEN environment variable to build a live Slack poster." ) return WebClient(token=resolved) def _as_mapping(response: Any) -> Mapping[str, Any]: """Coerce a ``chat_postMessage`` response to a plain mapping. ``slack_sdk`` returns a ``SlackResponse`` exposing the payload via ``.data``; if a test injects a client returning a bare mapping, accept it as-is. The result must carry ``ts`` so ``SlackTransport._extract_ts`` recovers the ``channel_ref``. """ if isinstance(response, Mapping): return response data = getattr(response, "data", None) if isinstance(data, Mapping): return data raise TypeError( "Slack chat_postMessage returned an unsupported response; expected a " f"mapping or an object with a mapping '.data', got {type(response)!r}" )