Turn a human's Slack interaction at the plan gate into a structured decision the
graph can route, with the kind-aware mapping that closes a silent-FAIL hazard.
- KIND-AWARE NORMALIZATION (load-bearing): map_plan_decision() in slack_adapter
maps a reply to {"decision","notes"} — approve ∈ {approve,approved,yes,ok,lgtm,
ship}; abandon ∈ {abandon,reject,cancel,stop,kill}; EVERYTHING ELSE →
request_changes with the full reply as notes (never accidental abandon). Wired
in the listener's _resolve_payload for plan_decision rows ONLY (clarify passes
through). Without this, arbitrary change-notes hit the graph's
unrecognized-verb→FAILED path and silently fail the task. Anti-FAIL tests
assert prose → request_changes (!= abandon) at both the mapper and the
end-to-end listener seam; a regression test guards clarify pass-through.
New find_open_question_kind_by_channel_ref (anti-replay, status='open') powers
the thread-reply fallback's kind lookup.
- BUTTONS + MODAL: build_plan_decision_blocks() renders Approve (primary) /
Request changes / Abandon (danger+confirm); question_id double-anchored in
message metadata AND each button value ("<verb>:<question_id>"). Approve/abandon
submit via the existing @app.action(.*); request_changes has a dedicated
handler that AUTHORIZES before views_open (proven by test) and opens a notes
modal (private_metadata carries the id) → view_submission → request_changes +
notes. Free-text reply stays the always-available equal path. AUTHZ-01 ordering
preserved.
- No manifest change (views.open needs no extra scope).
1454 passed (1412 + 42).
687 lines
29 KiB
Python
687 lines
29 KiB
Python
"""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 ``<!-- shq:... -->``
|
|
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",
|
|
"PLAN_DECISION_ABANDON_ACTION",
|
|
"PLAN_DECISION_ABANDON_VERBS",
|
|
"PLAN_DECISION_APPROVE_ACTION",
|
|
"PLAN_DECISION_APPROVE_VERBS",
|
|
"PLAN_DECISION_KIND",
|
|
"PLAN_DECISION_REQUEST_CHANGES_ACTION",
|
|
"VIA_SLACK",
|
|
"SlackPostError",
|
|
"SlackPoster",
|
|
"SlackTransport",
|
|
"build_callback_id",
|
|
"build_plan_decision_blocks",
|
|
"build_question_blocks",
|
|
"build_request_changes_modal",
|
|
"map_plan_decision",
|
|
"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 ``<!-- shq:... -->`` marker convention (``shq`` = Sea Haven
|
|
# question) from the base module.
|
|
CALLBACK_ID_PREFIX = "shq"
|
|
|
|
# The ``pending_questions.kind`` discriminator for the plan-review decision gate.
|
|
# Mirrors ``agent_team.graph.PLAN_DECISION_KIND`` and the DB CHECK constraint;
|
|
# duplicated here (as a plain string constant) so the transport/listener layer
|
|
# can route kind-aware decisions WITHOUT importing the graph module.
|
|
PLAN_DECISION_KIND = "plan_decision"
|
|
|
|
# Plan-decision verb allowlists (B3). A reply / button-value to a
|
|
# ``kind == 'plan_decision'`` question is normalized (lowercase + strip) and
|
|
# matched against these allowlists. Anything ELSE — arbitrary change-request
|
|
# prose — maps to ``request_changes`` carrying the FULL original reply as
|
|
# ``notes`` (the safe default; NEVER an accidental approve or abandon). See
|
|
# :func:`map_plan_decision`.
|
|
PLAN_DECISION_APPROVE_VERBS: frozenset[str] = frozenset(
|
|
{"approve", "approved", "yes", "ok", "lgtm", "ship"}
|
|
)
|
|
PLAN_DECISION_ABANDON_VERBS: frozenset[str] = frozenset(
|
|
{"abandon", "reject", "cancel", "stop", "kill"}
|
|
)
|
|
|
|
# Block Kit action_id namespace for the three plan-gate buttons. Each carries
|
|
# the decision verb; ``request_changes`` opens a modal for free-form notes.
|
|
PLAN_DECISION_ACTION_PREFIX = "plan_decision"
|
|
PLAN_DECISION_APPROVE_ACTION = f"{PLAN_DECISION_ACTION_PREFIX}:approve"
|
|
PLAN_DECISION_REQUEST_CHANGES_ACTION = f"{PLAN_DECISION_ACTION_PREFIX}:request_changes"
|
|
PLAN_DECISION_ABANDON_ACTION = f"{PLAN_DECISION_ACTION_PREFIX}:abandon"
|
|
|
|
|
|
def map_plan_decision(raw_answer: Any) -> dict[str, str]:
|
|
"""Map a raw plan-gate reply to a structured ``{"decision", "notes"}`` dict.
|
|
|
|
THE LOAD-BEARING B3 SAFETY MAPPING. The graph's ``_parse_decision`` maps any
|
|
unrecognized verb to ``abandon`` → terminal FAILED, so free-text change
|
|
notes (e.g. "use pytest fixtures instead") would silently FAIL the task if
|
|
they reached the graph unmapped. This normalizes BEFORE the graph sees it:
|
|
|
|
* normalize the text (``str`` → lowercase → strip);
|
|
* **approve** iff in :data:`PLAN_DECISION_APPROVE_VERBS`
|
|
(``approve/approved/yes/ok/lgtm/ship``), with ``notes=""``;
|
|
* **abandon** iff in :data:`PLAN_DECISION_ABANDON_VERBS`
|
|
(``abandon/reject/cancel/stop/kill``), with ``notes=""``;
|
|
* **everything else → ``request_changes`` with the FULL original reply as
|
|
``notes``** — the safe default. Arbitrary prose is a change request, never
|
|
an accidental approve or abandon. Empty / whitespace-only input also maps
|
|
to ``request_changes`` (with empty notes): a blank reply is treated as a
|
|
benign no-op change request, never a destructive abandon.
|
|
|
|
The answer is opaque DATA throughout — never executed or interpreted beyond
|
|
this verb match. Returns the dict the graph's ``_parse_decision`` consumes.
|
|
"""
|
|
original = "" if raw_answer is None else str(raw_answer)
|
|
verb = original.strip().lower()
|
|
if verb in PLAN_DECISION_APPROVE_VERBS:
|
|
return {"decision": "approve", "notes": ""}
|
|
if verb in PLAN_DECISION_ABANDON_VERBS:
|
|
return {"decision": "abandon", "notes": ""}
|
|
# Everything else (including empty/whitespace) → request_changes, carrying
|
|
# the full ORIGINAL reply (not the lowercased form) so the notes preserve
|
|
# the human's exact wording. Never an accidental abandon.
|
|
return {"decision": "request_changes", "notes": original.strip()}
|
|
|
|
|
|
# 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:<question_id>``) 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 build_plan_decision_blocks(
|
|
question_id: str, body_text: str
|
|
) -> list[dict[str, Any]]:
|
|
"""Render the plan-gate decision surface as Block Kit blocks (B3).
|
|
|
|
A section carrying the human-readable ``body_text`` (the plan summary +
|
|
review findings + decision instructions assembled by the coordinator) plus
|
|
an ``actions`` block with the three decision buttons:
|
|
|
|
* **Approve** (``action_id="plan_decision:approve"``, ``style="primary"``);
|
|
* **Request changes** (``action_id="plan_decision:request_changes"``) — its
|
|
dedicated handler opens a modal for free-form notes;
|
|
* **Abandon** (``action_id="plan_decision:abandon"``, ``style="danger"``,
|
|
guarded by a ``confirm`` dialog so it is never a single-click mistake).
|
|
|
|
Each button's ``value`` encodes ``"<verb>:<question_id>"`` so an inbound
|
|
``block_actions`` payload can recover BOTH the verb and the ledger
|
|
``question_id`` even if the message metadata is absent. The ``question_id``
|
|
is ALSO carried in the message ``metadata.event_payload`` by the caller
|
|
(mirroring the clarifier), so recovery is double-anchored. The section text
|
|
is the always-visible fallback for non-interactive clients (a free-text
|
|
thread reply remains an equal path to all three decisions).
|
|
|
|
Returns a plain JSON-serializable list (no Slack SDK types).
|
|
"""
|
|
section_text = body_text if body_text else "Plan needs your decision."
|
|
# Slack section text caps at ~3000 chars; keep a margin.
|
|
if len(section_text) > 2900:
|
|
section_text = section_text[:2900].rstrip() + "…"
|
|
return [
|
|
{"type": "section", "text": {"type": "mrkdwn", "text": section_text}},
|
|
{
|
|
"type": "actions",
|
|
"elements": [
|
|
{
|
|
"type": "button",
|
|
"action_id": PLAN_DECISION_APPROVE_ACTION,
|
|
"text": {"type": "plain_text", "text": "Approve"},
|
|
"style": "primary",
|
|
"value": f"approve:{question_id}",
|
|
},
|
|
{
|
|
"type": "button",
|
|
"action_id": PLAN_DECISION_REQUEST_CHANGES_ACTION,
|
|
"text": {"type": "plain_text", "text": "Request changes"},
|
|
"value": f"request_changes:{question_id}",
|
|
},
|
|
{
|
|
"type": "button",
|
|
"action_id": PLAN_DECISION_ABANDON_ACTION,
|
|
"text": {"type": "plain_text", "text": "Abandon"},
|
|
"style": "danger",
|
|
"value": f"abandon:{question_id}",
|
|
"confirm": {
|
|
"title": {"type": "plain_text", "text": "Abandon this task?"},
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": (
|
|
"This terminally FAILS the task. The plan is "
|
|
"discarded and the pipeline stops."
|
|
),
|
|
},
|
|
"confirm": {"type": "plain_text", "text": "Abandon"},
|
|
"deny": {"type": "plain_text", "text": "Keep"},
|
|
"style": "danger",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
# Block / action ids for the request-changes modal input, so the inbound
|
|
# ``view_submission`` extraction can find the notes value deterministically.
|
|
REQUEST_CHANGES_MODAL_CALLBACK_ID = (
|
|
f"{PLAN_DECISION_ACTION_PREFIX}:request_changes_modal"
|
|
)
|
|
REQUEST_CHANGES_NOTES_BLOCK_ID = "plan_decision_notes_block"
|
|
REQUEST_CHANGES_NOTES_ACTION_ID = "plan_decision_notes_input"
|
|
|
|
|
|
def build_request_changes_modal(question_id: str) -> dict[str, Any]:
|
|
"""Build the "Request changes" notes modal (views.open view, B3).
|
|
|
|
One required multiline ``plain_text_input`` ("What should change?"). The
|
|
ledger ``question_id`` round-trips through ``private_metadata`` as
|
|
``"request_changes:<question_id>"`` so the eventual ``view_submission``
|
|
recovers it (mirroring the button-value encoding). On submit, the input text
|
|
becomes the ``request_changes`` notes via the kind-aware normalizer.
|
|
|
|
Returns a plain JSON-serializable view dict (no Slack SDK types) so the
|
|
caller passes it straight to ``client.views_open(trigger_id=..., view=...)``.
|
|
"""
|
|
return {
|
|
"type": "modal",
|
|
"callback_id": REQUEST_CHANGES_MODAL_CALLBACK_ID,
|
|
"private_metadata": f"request_changes:{question_id}",
|
|
"title": {"type": "plain_text", "text": "Request changes"},
|
|
"submit": {"type": "plain_text", "text": "Send"},
|
|
"close": {"type": "plain_text", "text": "Cancel"},
|
|
"blocks": [
|
|
{
|
|
"type": "input",
|
|
"block_id": REQUEST_CHANGES_NOTES_BLOCK_ID,
|
|
"label": {"type": "plain_text", "text": "What should change?"},
|
|
"element": {
|
|
"type": "plain_text_input",
|
|
"action_id": REQUEST_CHANGES_NOTES_ACTION_ID,
|
|
"multiline": True,
|
|
},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
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
|
|
|
|
@property
|
|
def poster(self) -> SlackPoster:
|
|
"""The injected network seam (the ``chat.postMessage`` callable).
|
|
|
|
Exposed read-only so collaborators sharing this transport (e.g. the
|
|
inbound :class:`~agent_team.transport.slack_listener.SlackListener`) can
|
|
post auxiliary messages — the root "📥 Task received" ack and the 👍
|
|
reaction-bearing posts — through the SAME poster the question delivery
|
|
uses, instead of constructing a second client. The foundation default
|
|
still refuses the network (no poster configured).
|
|
"""
|
|
return self._poster
|
|
|
|
def post_question(
|
|
self,
|
|
*,
|
|
thread_id: str,
|
|
question_id: str,
|
|
turn: int,
|
|
question_set: QuestionSet,
|
|
deadline: str,
|
|
thread_ts: str | None = None,
|
|
) -> 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.
|
|
|
|
``thread_ts`` (one-thread-per-task) — when set, the message is posted as
|
|
a THREADED REPLY under that root ``ts`` (the task's "📥 Task received"
|
|
ack post), so every clarifier question for a task lands in one Slack
|
|
thread. When ``None`` (the default, e.g. a task that did not originate
|
|
from ``/new-task``) the message is posted top-level exactly as before.
|
|
The returned value is still the POSTED message's own ``ts``; the caller
|
|
(:func:`agent_team.responder.notify_question`) is what records the
|
|
durable ``channel_ref`` (it uses the root ``thread_ts`` when threading so
|
|
an inbound reply's ``thread_ts`` maps back to this question).
|
|
"""
|
|
# Plan-review gate (B3): when the question_set is a plan decision, render
|
|
# the three decision buttons (Approve / Request changes / Abandon) over
|
|
# the presentation text instead of the generic question blocks. The
|
|
# presentation body is passed through the question_set ``context`` (under
|
|
# ``presentation``) by the coordinator; the buttons carry the recoverable
|
|
# question_id (and the message metadata double-anchors it). A free-text
|
|
# thread reply remains an equal path to all three decisions.
|
|
if question_set.context.get("kind") == PLAN_DECISION_KIND:
|
|
body_text = str(question_set.context.get("presentation") or "")
|
|
blocks = build_plan_decision_blocks(question_id, body_text)
|
|
fallback_text = body_text or (
|
|
f"Plan decision needed on task {thread_id} (turn {turn})."
|
|
)
|
|
else:
|
|
blocks = build_question_blocks(question_set, deadline)
|
|
fallback_text = (
|
|
f"Agent-team needs input on task {thread_id} "
|
|
f"(turn {turn}); reply by {deadline}."
|
|
)
|
|
message: dict[str, Any] = {
|
|
"channel": self.channel,
|
|
"callback_id": build_callback_id(question_id),
|
|
"text": fallback_text,
|
|
"blocks": blocks,
|
|
"metadata": {
|
|
"event_type": "agent_team_question",
|
|
"event_payload": {
|
|
"thread_id": thread_id,
|
|
"question_id": question_id,
|
|
"turn": turn,
|
|
},
|
|
},
|
|
}
|
|
# Thread under the task's root message when one exists (one thread per
|
|
# task). Only set the key when non-empty so the top-level-post behavior
|
|
# is byte-identical for non-/new-task origins.
|
|
if thread_ts:
|
|
message["thread_ts"] = thread_ts
|
|
|
|
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):
|
|
# Modal (view_submission) round-trips the question_id through
|
|
# ``private_metadata`` (B3 request-changes modal). It carries
|
|
# ``"<verb>:<question_id>"`` (or a bare id); recover the id suffix. This
|
|
# is checked BEFORE the view ``callback_id`` because the B3 modal's
|
|
# callback_id is a modal identifier (``plan_decision:...``), NOT a
|
|
# ``shq:<question_id>`` carrier.
|
|
private_metadata = view.get("private_metadata")
|
|
if private_metadata:
|
|
return _question_id_from_value(str(private_metadata))
|
|
# A view whose callback_id IS an ``shq:`` carrier (a non-B3 modal that
|
|
# embedded the question id there directly) still resolves.
|
|
view_callback_id = view.get("callback_id")
|
|
if view_callback_id and str(view_callback_id).startswith(
|
|
f"{CALLBACK_ID_PREFIX}:"
|
|
):
|
|
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"])
|
|
|
|
# Block-action button value: the B3 plan-gate buttons encode
|
|
# ``"<verb>:<question_id>"`` so the id is recoverable even with no
|
|
# callback_id / message metadata on the payload.
|
|
actions = raw.get("actions")
|
|
if (
|
|
isinstance(actions, Sequence)
|
|
and not isinstance(actions, (str, bytes))
|
|
and actions
|
|
):
|
|
for action in actions:
|
|
if not isinstance(action, Mapping):
|
|
continue
|
|
value = action.get("value")
|
|
if not value:
|
|
continue
|
|
qid = _question_id_from_plan_decision_value(str(value))
|
|
if qid:
|
|
return qid
|
|
|
|
question_id = raw.get("question_id")
|
|
if question_id:
|
|
return str(question_id)
|
|
|
|
raise ValueError("Slack payload carries no recoverable question_id")
|
|
|
|
|
|
_PLAN_DECISION_VALUE_VERBS = frozenset({"approve", "request_changes", "abandon"})
|
|
|
|
|
|
def _question_id_from_value(value: str) -> str:
|
|
"""Recover the question_id from a ``private_metadata`` string.
|
|
|
|
The B3 request-changes modal stores ``"<verb>:<question_id>"`` (or a bare
|
|
``question_id``) in ``private_metadata``. A leading known decision verb is a
|
|
prefix to strip; otherwise the whole value IS the id. Raises
|
|
:class:`ValueError` on an empty value.
|
|
"""
|
|
qid = _question_id_from_plan_decision_value(value)
|
|
if qid:
|
|
return qid
|
|
if not value:
|
|
raise ValueError("empty private_metadata; no recoverable question_id")
|
|
return value
|
|
|
|
|
|
def _question_id_from_plan_decision_value(value: str) -> str | None:
|
|
"""Return the ``question_id`` suffix of a ``"<verb>:<question_id>"`` value.
|
|
|
|
Only matches when the prefix is a known plan-decision verb so an ordinary
|
|
button value (e.g. a clarifier's free-text answer) is never mis-parsed.
|
|
Returns ``None`` if the value is not a ``"<verb>:<question_id>"`` encoding.
|
|
"""
|
|
verb, sep, rest = value.partition(":")
|
|
if sep and verb in _PLAN_DECISION_VALUE_VERBS and rest:
|
|
return rest
|
|
return None
|
|
|
|
|
|
def _modal_input_text(view: Mapping[str, Any]) -> Any:
|
|
"""Recover the submitted text from a ``view_submission`` view (B3 modal).
|
|
|
|
Walks ``view.state.values`` (``{block_id: {action_id: {value: ...}}}``) and
|
|
returns the first non-empty ``plain_text_input`` value. The B3 request-
|
|
changes modal has a single input, so the first value is the notes text.
|
|
Returns ``None`` if no input value is present.
|
|
"""
|
|
state = view.get("state")
|
|
if not isinstance(state, Mapping):
|
|
return None
|
|
values = state.get("values")
|
|
if not isinstance(values, Mapping):
|
|
return None
|
|
for block in values.values():
|
|
if not isinstance(block, Mapping):
|
|
continue
|
|
for action in block.values():
|
|
if isinstance(action, Mapping) and action.get("value") is not None:
|
|
return action["value"]
|
|
return None
|
|
|
|
|
|
def _extract_answer(raw: Mapping[str, Any]) -> Any:
|
|
"""Recover the answer value from any supported inbound payload."""
|
|
# view_submission (modal): the answer is the submitted input text (B3).
|
|
view = raw.get("view")
|
|
if isinstance(view, Mapping):
|
|
text = _modal_input_text(view)
|
|
if text is not None:
|
|
return text
|
|
|
|
actions = raw.get("actions")
|
|
if (
|
|
isinstance(actions, Sequence)
|
|
and not isinstance(actions, (str, bytes))
|
|
and actions
|
|
):
|
|
mappings = [action for action in actions if isinstance(action, Mapping)]
|
|
# A single plan-decision button encodes ``"<verb>:<question_id>"``; the
|
|
# question_id is recovered separately (callback_id / metadata / value),
|
|
# so the ANSWER is the bare verb. Stripping the suffix here lets the
|
|
# kind-aware normalizer match it against the approve/abandon allowlists.
|
|
if len(mappings) == 1:
|
|
value = mappings[0].get("value")
|
|
if value is not None:
|
|
verb, sep, rest = str(value).partition(":")
|
|
if sep and verb in _PLAN_DECISION_VALUE_VERBS and rest:
|
|
return verb
|
|
return _join_answer_actions(mappings)
|
|
|
|
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")
|