"""Slack Block Kit transport adapter (design §3.3.1, §7.1 P1). The first concrete :class:`~agent_team.transport.base.Transport` implementation. P1 ships the human gate over **one** transport (Slack first), so this adapter must satisfy the §3.3.1 contracts the durable responder depends on: * :meth:`SlackTransport.post_question` renders a :class:`QuestionSet` as a Slack Block Kit message, embeds the ``question_id`` in the message ``callback_id`` (Slack's native callback metadata, the analogue of the GitHub ```` marker), posts it via an **injected** poster callable, and returns the Slack message ``ts`` as the ``channel_ref`` stored on the ledger row. * :meth:`SlackTransport.parse_answer` normalizes an inbound Slack payload (an interactive ``block_actions`` callback or a plain text/slash reply) to ``(question_id, answer, via)`` for the first-answer-wins compare-and-set. This module is **transport I/O only** and carries no live wiring: the network call is a dependency-injected ``poster`` callable, so the adapter is unit testable and ships nothing provisioned. A production deployment supplies a poster backed by the Composio Slack connector or ``slack_sdk`` (design §3 — "the R720 routes Slack/Jira/Notion through" the Composio connector); the foundation must not import or require either, so the default poster raises a clear "not configured" error rather than reaching the network. """ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence from typing import Any from agent_team.transport.base import ( NormalizedAnswer, QuestionSet, Transport, ) __all__ = [ "CALLBACK_ID_PREFIX", "VIA_SLACK", "SlackPostError", "SlackPoster", "SlackTransport", "build_callback_id", "build_question_blocks", "parse_callback_id", ] # ``via`` channel-identity tag recorded in the ledger's ``answered_via`` column # for the audit trail (§3.3.1). VIA_SLACK = "slack" # Namespacing prefix for the embedded ``callback_id`` so an inbound Slack # interaction can be unambiguously mapped back to its ledger ``question_id``. # Mirrors the GitHub ```` marker convention (``shq`` = Sea Haven # question) from the base module. CALLBACK_ID_PREFIX = "shq" # Type of the network seam: given the rendered Slack message kwargs, perform the # ``chat.postMessage`` and return the response payload. The only field this # adapter requires from the response is the message ``ts`` (the ``channel_ref``). SlackPoster = Callable[[dict[str, Any]], Mapping[str, Any]] class SlackPostError(RuntimeError): """Raised when posting a Slack message fails or no poster is configured. The §3.3.1 delivery rule writes the ledger row ``open`` *before* posting, so a raised :class:`SlackPostError` leaves the row ``open`` with no ``channel_ref`` and the reconcile loop retries delivery idempotently. The caller (responder/delivery loop) is expected to catch this and leave the row for reconcile rather than treating the question as delivered. """ def _default_poster(_message: dict[str, Any]) -> Mapping[str, Any]: """Default poster: refuse to reach the network (pre-deployment scaffolding). The foundation must not provision or wire live infrastructure, so a :class:`SlackTransport` constructed without an explicit ``poster`` cannot post. Production injects a poster backed by the Composio Slack connector or ``slack_sdk``. """ raise SlackPostError( "SlackTransport has no poster configured; inject a Slack poster " "callable (Composio connector / slack_sdk) before posting." ) def build_callback_id(question_id: str) -> str: """Embed ``question_id`` in a namespaced Slack ``callback_id``. Slack echoes the message-level ``callback_id`` back on every interaction payload, so it is the natural carrier for the ledger ``question_id`` (§3.3.1: "Slack ``callback_id``"). """ return f"{CALLBACK_ID_PREFIX}:{question_id}" def parse_callback_id(callback_id: str) -> str: """Recover the ``question_id`` from a :func:`build_callback_id` value. Accepts both the namespaced form (``shq:``) and a bare ``question_id`` (defensive, in case a payload surfaces the id directly). Raises :class:`ValueError` on an empty / malformed callback id so a mis-mapped answer fails loudly instead of being silently mis-attributed. """ if not callback_id: raise ValueError("empty callback_id") prefix = f"{CALLBACK_ID_PREFIX}:" if callback_id.startswith(prefix): question_id = callback_id[len(prefix) :] if not question_id: raise ValueError(f"callback_id missing question_id: {callback_id!r}") return question_id return callback_id def build_question_blocks( question_set: QuestionSet, deadline: str ) -> list[dict[str, Any]]: """Render a :class:`QuestionSet` as Slack Block Kit blocks. Surfaces optional ``context`` (``repo`` / ``summary``) as a context block, lists the ordered questions, and footnotes the ``deadline`` so Adam sees the answer window. Returns a plain JSON-serializable list (no Slack SDK types), keeping the adapter dependency-free. """ blocks: list[dict[str, Any]] = [ { "type": "header", "text": { "type": "plain_text", "text": f"Agent-team needs input (turn {question_set.turn})", }, } ] context_elements: list[dict[str, Any]] = [] repo = question_set.context.get("repo") if repo: context_elements.append({"type": "mrkdwn", "text": f"*repo:* {repo}"}) summary = question_set.context.get("summary") if summary: context_elements.append({"type": "mrkdwn", "text": str(summary)}) if context_elements: blocks.append({"type": "context", "elements": context_elements}) for index, question in enumerate(question_set.questions, start=1): blocks.append( { "type": "section", "text": {"type": "mrkdwn", "text": f"*{index}.* {question}"}, } ) blocks.append( { "type": "context", "elements": [ {"type": "mrkdwn", "text": f"_Reply in this thread by {deadline}._"} ], } ) return blocks def _join_answer_actions(actions: Sequence[Mapping[str, Any]]) -> Any: """Reduce one-or-more interactive actions to a single answer value. A button click yields one action; a multi-select yields several. A single action collapses to its scalar value; multiple actions return the ordered list of values so the responder records every selection. """ values = [_action_value(action) for action in actions] if len(values) == 1: return values[0] return values def _action_value(action: Mapping[str, Any]) -> Any: """Extract the answer value from one Slack ``block_actions`` action.""" if "value" in action and action["value"] is not None: return action["value"] selected = action.get("selected_option") if isinstance(selected, Mapping): return selected.get("value") selected_options = action.get("selected_options") if isinstance(selected_options, Sequence) and not isinstance( selected_options, (str, bytes) ): return [ opt.get("value") for opt in selected_options if isinstance(opt, Mapping) ] selected_user = action.get("selected_user") if selected_user is not None: return selected_user # Fall back to the action_id so a payload without an explicit value still # maps to *some* deterministic answer rather than ``None``. return action.get("action_id") class SlackTransport(Transport): """Concrete Slack Block Kit :class:`Transport` (§3.3.1, P1 — Slack first). ``channel`` is the target Slack channel id. ``poster`` is the injected network seam (defaults to a non-networking poster that raises, so the foundation ships nothing live). ``thread_ts`` mode is implicit: when a ``QuestionSet`` is delivered the adapter posts a top-level message and uses its ``ts`` as the ``channel_ref``; answers arrive as thread replies or interactive callbacks carrying the embedded ``question_id``. """ def __init__( self, channel: str, poster: SlackPoster | None = None, ) -> None: self.channel = channel self._poster: SlackPoster = poster if poster is not None else _default_poster def post_question( self, *, thread_id: str, question_id: str, turn: int, question_set: QuestionSet, deadline: str, ) -> str: """Render + post the question-set; return the Slack ``ts`` channel_ref. Embeds ``question_id`` in the message ``callback_id`` so an inbound answer maps back (§3.3.1). On any poster failure raises :class:`SlackPostError` so the ledger row stays ``open`` for reconcile. """ blocks = build_question_blocks(question_set, deadline) message: dict[str, Any] = { "channel": self.channel, "callback_id": build_callback_id(question_id), "text": ( f"Agent-team needs input on task {thread_id} " f"(turn {turn}); reply by {deadline}." ), "blocks": blocks, "metadata": { "event_type": "agent_team_question", "event_payload": { "thread_id": thread_id, "question_id": question_id, "turn": turn, }, }, } try: response = self._poster(message) except SlackPostError: raise except Exception as exc: # noqa: BLE001 — normalize any poster failure raise SlackPostError(f"Slack post failed: {exc}") from exc channel_ref = _extract_ts(response) if not channel_ref: raise SlackPostError( "Slack post response missing message 'ts'; cannot record " f"channel_ref (response keys: {sorted(response.keys())})" ) return channel_ref def parse_answer(self, raw: Any) -> tuple[str, Any, str]: """Normalize an inbound Slack payload to ``(question_id, answer, via)``. Handles the two inbound shapes for P1: * an interactive ``block_actions`` payload — ``callback_id`` carries the ``question_id`` and ``actions[*]`` carry the answer value(s); * a plain text / slash reply — ``callback_id`` (or ``question_id``) carries the id and ``text`` / ``answer`` carries the value. Raises :class:`ValueError` on a payload with no recoverable ``question_id`` so a malformed answer is rejected loudly rather than mis-attributed. """ if not isinstance(raw, Mapping): raise ValueError(f"Slack payload must be a mapping, got {type(raw)!r}") question_id = _extract_question_id(raw) answer = _extract_answer(raw) normalized = NormalizedAnswer( question_id=question_id, answer=answer, via=VIA_SLACK ) return normalized.question_id, normalized.answer, normalized.via def _extract_ts(response: Mapping[str, Any]) -> str | None: """Pull the message ``ts`` from a ``chat.postMessage`` response. Slack returns the timestamp at the top level (``{"ts": ...}``) and also nested under ``message`` (``{"message": {"ts": ...}}``); accept either. """ ts = response.get("ts") if ts: return str(ts) message = response.get("message") if isinstance(message, Mapping) and message.get("ts"): return str(message["ts"]) return None def _extract_question_id(raw: Mapping[str, Any]) -> str: """Recover the ledger ``question_id`` from any supported inbound payload.""" callback_id = raw.get("callback_id") if callback_id: return parse_callback_id(str(callback_id)) # Interactive payloads nest the callback metadata under ``view`` / ``message``. view = raw.get("view") if isinstance(view, Mapping) and view.get("callback_id"): return parse_callback_id(str(view["callback_id"])) message = raw.get("message") if isinstance(message, Mapping): metadata = message.get("metadata") if isinstance(metadata, Mapping): payload = metadata.get("event_payload") if isinstance(payload, Mapping) and payload.get("question_id"): return str(payload["question_id"]) question_id = raw.get("question_id") if question_id: return str(question_id) raise ValueError("Slack payload carries no recoverable question_id") def _extract_answer(raw: Mapping[str, Any]) -> Any: """Recover the answer value from any supported inbound payload.""" actions = raw.get("actions") if ( isinstance(actions, Sequence) and not isinstance(actions, (str, bytes)) and actions ): return _join_answer_actions( [action for action in actions if isinstance(action, Mapping)] ) if "answer" in raw: return raw["answer"] if "text" in raw: return raw["text"] if "value" in raw: return raw["value"] raise ValueError("Slack payload carries no recoverable answer")