import json import os from collections import OrderedDict from typing import Any from langgraph.config import get_config from langgraph_sdk import get_client from ..utils.slack import ( convert_mentions_to_slack_format, post_slack_thread_reply_with_ts, post_slack_top_level_message_with_ts, store_slack_message_run_mapping, ) LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get( "LANGGRAPH_URL_PROD", "http://localhost:2024" ) # Runs that have already posted their single top-level (channel) message. Scheduled # runs seed `slack_thread` with a channel but no `thread_ts`, so every reply would # otherwise spray a new top-level message into the report channel; cap it at one. _MAX_TRACKED_RUNS = 2048 _top_level_posts: "OrderedDict[str, None]" = OrderedDict() async def slack_thread_reply( message: str, options: list[str] | None = None, blocks: list[dict[str, Any]] | None = None, plan_approval: bool = False, ) -> dict[str, Any]: """Post a message to the current Slack thread. Use this for clarifying questions, essential progress updates, and the final outcome. Make `message` as terse as possible: default to one sentence with only the outcome/status and link, or one blocking question. Omit greetings, preambles, headings, recaps, implementation details, and redundant context; use bullets only when multiple items are essential. This terseness rule is specific to Slack tool messages, not normal web UI assistant messages. Always end the run with a terse final outcome. Format messages using Slack's mrkdwn format, NOT standard Markdown. Key differences: *bold*, _italic_, ~strikethrough~, , bullet lists with "• ", ```code blocks```, > blockquotes. Do NOT use **bold**, [link](url), or other standard Markdown syntax. To ask a user to choose from predefined options, pass `options`. Slack will render interactive buttons and the web UI will render the same choices. The user can still reply manually in the Slack thread. To present a plan for approval with action buttons, pass plan_approval=True. This renders "Approve & Implement", "Revise Plan", and "Cancel" buttons. The plan itself should be posted to the dashboard thread; use this to post a summary with a link to the dashboard thread view where the full plan can be reviewed. To mention/tag a user, use Slack's mention format: <@USER_ID>. You can find user IDs in the conversation context (e.g. @Name(U06KD8BFY95)). Example: <@U06KD8BFY95> will tag that user in the message.""" config = get_config() configurable = config.get("configurable", {}) slack_thread = configurable.get("slack_thread", {}) channel_id = slack_thread.get("channel_id") thread_ts = slack_thread.get("thread_ts") if not channel_id: return { "success": False, "error": "Missing slack_thread.channel_id in config", } if not message.strip(): return {"success": False, "error": "Message cannot be empty"} top_level = not thread_ts run_key = _run_key(config) if top_level else None if top_level and run_key is not None and run_key in _top_level_posts: return { "success": False, "error": "A message was already posted to this channel for this run", "hint": ( "Only one top-level message per run is allowed for the configured " "report channel; post a single final report and do not call this again." ), } message = convert_mentions_to_slack_format(message) if top_level: # Interactive blocks (options / plan_approval) render dead buttons in a # report channel where no run is driving the approval/option flow. slack_blocks = blocks elif plan_approval: slack_blocks = _build_plan_approval_blocks(message) else: slack_blocks = blocks or _build_option_blocks(message, options) message_ts, slack_error = await _post_and_store_mapping( channel_id, thread_ts, message, blocks=slack_blocks ) if message_ts is None: return { "success": False, "error": slack_error or "post failed", "slack_error": slack_error, "message_chars": len(message), "hint": _slack_reply_failure_hint(slack_error), } if top_level and run_key is not None: _top_level_posts[run_key] = None if len(_top_level_posts) > _MAX_TRACKED_RUNS: _top_level_posts.popitem(last=False) return {"success": True} def _run_key(config: dict[str, Any]) -> str | None: candidates = [config.get("run_id")] configurable = config.get("configurable") if isinstance(configurable, dict): candidates.append(configurable.get("run_id")) candidates.append(configurable.get("thread_id")) for candidate in candidates: if isinstance(candidate, str) and candidate: return candidate return None def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[str, Any]] | None: if not options: return None clean_options = [option.strip() for option in options if option.strip()] if not clean_options: return None return [ {"type": "section", "text": {"type": "mrkdwn", "text": message}}, { "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": option[:75], "emoji": True}, "value": json.dumps({"type": "open_swe_option", "response": option}), "action_id": "open_swe_option_select", } for option in clean_options[:5] ], }, ] def _build_plan_approval_blocks(message: str) -> list[dict[str, Any]]: return [ {"type": "section", "text": {"type": "mrkdwn", "text": message}}, { "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "Approve & Implement", "emoji": True}, "style": "primary", "value": json.dumps({"type": "plan_approval", "action": "approve"}), "action_id": "open_swe_option_select", }, { "type": "button", "text": {"type": "plain_text", "text": "Revise Plan", "emoji": True}, "value": json.dumps({"type": "plan_approval", "action": "revise"}), "action_id": "open_swe_option_select", }, { "type": "button", "text": {"type": "plain_text", "text": "Cancel", "emoji": True}, "style": "danger", "value": json.dumps({"type": "plan_approval", "action": "cancel"}), "action_id": "open_swe_option_select", }, ], }, ] def build_workflow_approval_blocks(message: str, fingerprint: str) -> list[dict[str, Any]]: return [ {"type": "section", "text": {"type": "mrkdwn", "text": message}}, { "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "Approve workflow push", "emoji": True}, "style": "primary", "value": json.dumps( { "type": "workflow_push_approval", "action": "approve", "fingerprint": fingerprint, } ), "action_id": "open_swe_option_select", }, { "type": "button", "text": {"type": "plain_text", "text": "Reject", "emoji": True}, "style": "danger", "value": json.dumps( { "type": "workflow_push_approval", "action": "reject", "fingerprint": fingerprint, } ), "action_id": "open_swe_option_select", }, ], }, ] def _slack_reply_failure_hint(slack_error: str | None) -> str: if slack_error == "msg_too_long": return "Slack rejected the message as too long; retry with a shorter message." if slack_error in {"channel_not_found", "not_in_channel"}: return "Slack rejected the channel; do not retry. Surface the failure to the user via the trace output instead." if slack_error and slack_error.startswith("rate_limited"): retry_after = slack_error.partition(":")[2].strip() if retry_after: return f"Slack rate limited the request; wait at least {retry_after}s before retrying, or surface the failure to the user via the trace output." return "Slack rate limited the request; wait before retrying, or surface the failure to the user via the trace output." if slack_error == "missing_slack_bot_token": return "Slack bot token is missing; do not retry. Surface the failure to the user via the trace output instead." if slack_error and slack_error.startswith("http_error:"): return "Slack posting hit an HTTP error; retry once, then surface the failure to the user via the trace output." return "Slack post failed; retry once with a concise message or surface the failure to the user via the trace output." async def _post_and_store_mapping( channel_id: str, thread_ts: str | None, message: str, *, blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: if not thread_ts: # Top-level report posts are fire-and-forget: a scheduled run is one-shot, so # there is no live run to route channel replies back to (no mapping stored). return await post_slack_top_level_message_with_ts(channel_id, message, blocks=blocks) message_ts, slack_error = await post_slack_thread_reply_with_ts( channel_id, thread_ts, message, blocks=blocks ) if message_ts: langgraph_client = get_client(url=LANGGRAPH_URL) await store_slack_message_run_mapping(langgraph_client, channel_id, thread_ts, message_ts) return message_ts, slack_error