"""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 Callable, Mapping from typing import Any from agent_team.transport.slack_adapter import SlackPoster, SlackTransport __all__ = [ "build_live_slack_transport", "build_slack_poster", "build_slack_reactor", ] # 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. # ``thread_ts`` IS a postMessage parameter (one-thread-per-task threading) and is # forwarded when present so a question/notification posts as a threaded reply. _POST_MESSAGE_KEYS = ("channel", "text", "blocks", "metadata", "thread_ts") 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_slack_reactor( token: str | None = None, *, client: Any = None ) -> "Callable[[str, str], None]": """Build a live ``slack_sdk``-backed 👍-reaction adder (one-thread-per-task UX). The returned ``(channel, ts) -> None`` callable performs a Slack ``reactions.add`` (emoji ``thumbsup``) on the message at ``(channel, ts)`` so a human sees the machine received their inbound answer. It is wired into the inbound :class:`~agent_team.transport.slack_listener.SlackListener` (which only calls it AFTER the AUTHZ-01 owner check passes) and is invoked best-effort — the listener swallows any failure. Requires the ``reactions:write`` bot scope. Until that scope is granted (the manifest re-applied + the app reinstalled) ``reactions.add`` fails with a ``missing_scope`` error; this reactor lets that propagate to the listener, which swallows it, so the reaction silently no-ops rather than breaking answer handling. ``client`` (optional) injects a pre-built client for testability; any object exposing ``reactions_add(**kwargs)`` works. When omitted, a ``slack_sdk.WebClient`` is constructed lazily from ``token`` (falling back to ``SLACK_BOT_TOKEN``); the deferred-import / missing-token semantics match :func:`build_slack_poster`. """ if client is None: client = _build_web_client(token) def _reactor(channel: str, ts: str) -> None: client.reactions_add(channel=channel, timestamp=ts, name="thumbsup") return _reactor 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}" )