From c1e46b938e10737404ca744d1e2b56665417a113 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Thu, 4 Jun 2026 10:26:09 -0700 Subject: [PATCH] feat: add Slack Block Kit reply options (#1407) * feat: add Slack Block Kit reply options Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> * docs: document Slack interactivity setup Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> --- INSTALLATION.md | 16 ++- agent/tools/slack_thread_reply.py | 49 ++++++++- agent/utils/slack.py | 3 + agent/webapp.py | 82 +++++++++++++- tests/test_slack_assistants_status.py | 17 +++ tests/test_slack_thread_reply_tool.py | 60 ++++++++++- ui/src/components/agents/ported/ReplyCard.tsx | 101 ++++++++++++++++++ 7 files changed, 317 insertions(+), 11 deletions(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index 9f902159..8c740292 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -286,7 +286,7 @@ Users can also override the team/project mapping per-comment by including `repo: 1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From a manifest** 2. Copy the manifest below, replacing the two placeholder URLs: - Replace `` with the OAuth provider ID from step 3a - - Replace `` with the ngrok URL from step 2 + - Replace `` with the backend URL from step 2 (or your deployed LangGraph/FastAPI URL in production)
Slack App Manifest @@ -343,6 +343,10 @@ Users can also override the team/project mapping per-comment by including `repo: "message.mpim" ] }, + "interactivity": { + "is_enabled": true, + "request_url": "https:///webhooks/slack/interactivity" + }, "org_deploy_enabled": false, "socket_mode_enabled": false, "token_rotation_enabled": false @@ -354,6 +358,15 @@ Users can also override the team/project mapping per-comment by including `repo: 3. Install the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`) +**Slack URL checklist:** + +Both Slack URLs must point at the Open SWE backend that serves `agent.webapp:app` (locally, your ngrok URL forwarding to `langgraph dev`; in production, your LangGraph/FastAPI deployment URL), not the dashboard frontend URL. + +- **Event Subscriptions → Request URL:** `https:///webhooks/slack` +- **Interactivity & Shortcuts → Interactivity Request URL:** `https:///webhooks/slack/interactivity` + +Slack Block Kit option buttons only work when Interactivity is enabled and pointed at `/webhooks/slack/interactivity`. + **Credentials you'll need:** - `SLACK_BOT_TOKEN`: the Bot User OAuth Token (`xoxb-...`) @@ -539,6 +552,7 @@ make dev # uv run langgraph dev | `POST /webhooks/linear` | Linear comment webhooks | | `GET /webhooks/linear` | Linear webhook verification | | `POST /webhooks/slack` | Slack event webhooks | +| `POST /webhooks/slack/interactivity` | Slack Block Kit button interactions | | `GET /webhooks/slack` | Slack webhook verification | | `GET /dashboard/api/auth/login` | Dashboard GitHub OAuth login | | `GET /dashboard/api/auth/callback` | Dashboard GitHub OAuth callback (registered on the App in step 3b) | diff --git a/agent/tools/slack_thread_reply.py b/agent/tools/slack_thread_reply.py index 54476adc..a65e2d1e 100644 --- a/agent/tools/slack_thread_reply.py +++ b/agent/tools/slack_thread_reply.py @@ -1,4 +1,5 @@ import asyncio +import json import os from typing import Any @@ -16,7 +17,11 @@ LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get( ) -def slack_thread_reply(message: str) -> dict[str, Any]: +def slack_thread_reply( + message: str, + options: list[str] | None = None, + blocks: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: """Post a message to the current Slack thread. Use this for clarifying questions, mid-run progress updates, and the final @@ -30,6 +35,10 @@ def slack_thread_reply(message: str) -> dict[str, Any]: 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 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.""" @@ -49,7 +58,10 @@ def slack_thread_reply(message: str) -> dict[str, Any]: return {"success": False, "error": "Message cannot be empty"} message = convert_mentions_to_slack_format(message) - message_ts, slack_error = asyncio.run(_post_and_store_mapping(channel_id, thread_ts, message)) + slack_blocks = blocks or _build_option_blocks(message, options) + message_ts, slack_error = asyncio.run( + _post_and_store_mapping(channel_id, thread_ts, message, blocks=slack_blocks) + ) if message_ts is None: return { "success": False, @@ -61,6 +73,29 @@ def slack_thread_reply(message: str) -> dict[str, Any]: return {"success": True} +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 _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." @@ -79,9 +114,15 @@ def _slack_reply_failure_hint(slack_error: str | None) -> str: async def _post_and_store_mapping( - channel_id: str, thread_ts: str, message: str + channel_id: str, + thread_ts: str, + message: str, + *, + blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: - message_ts, slack_error = await post_slack_thread_reply_with_ts(channel_id, thread_ts, message) + 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) diff --git a/agent/utils/slack.py b/agent/utils/slack.py index b1d2d109..63558b90 100644 --- a/agent/utils/slack.py +++ b/agent/utils/slack.py @@ -296,6 +296,7 @@ async def post_slack_thread_reply_with_ts( *, unfurl_links: bool = True, unfurl_media: bool = True, + blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: """Post a reply in a Slack thread and return its Slack timestamp and error.""" if not SLACK_BOT_TOKEN: @@ -308,6 +309,8 @@ async def post_slack_thread_reply_with_ts( "unfurl_links": unfurl_links, "unfurl_media": unfurl_media, } + if blocks: + payload["blocks"] = blocks async with httpx.AsyncClient() as http_client: try: diff --git a/agent/webapp.py b/agent/webapp.py index 4d70fe13..a0aa63e9 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -10,7 +10,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import UTC, datetime from typing import Any -from urllib.parse import quote +from urllib.parse import parse_qs, quote import httpx from fastapi import BackgroundTasks, FastAPI, HTTPException, Request @@ -1443,6 +1443,86 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) -> return {"status": "accepted", "message": "Slack mention queued"} +@app.post("/webhooks/slack/interactivity") +async def slack_interactivity( + request: Request, background_tasks: BackgroundTasks +) -> dict[str, str]: + """Handle Slack Block Kit interactions.""" + body = await request.body() + signature = request.headers.get("X-Slack-Signature", "") + timestamp = request.headers.get("X-Slack-Request-Timestamp", "") + if not verify_slack_signature( + body=body, + timestamp=timestamp, + signature=signature, + secret=SLACK_SIGNING_SECRET, + ): + logger.warning("Invalid Slack interactivity signature") + raise HTTPException(status_code=401, detail="Invalid signature") + + form = parse_qs(body.decode("utf-8")) + payload_raw = (form.get("payload") or [""])[0] + try: + payload = json.loads(payload_raw) + except json.JSONDecodeError: + logger.exception("Failed to parse Slack interactivity payload") + return {"status": "error", "message": "Invalid payload"} + + action = _first_open_swe_option_action(payload.get("actions")) + if action is None: + return {"status": "ignored", "reason": "No Open SWE action"} + + try: + action_value = json.loads(str(action.get("value") or "{}")) + except json.JSONDecodeError: + return {"status": "ignored", "reason": "Invalid action value"} + if action_value.get("type") != "open_swe_option": + return {"status": "ignored", "reason": "Unknown action type"} + + response = str(action_value.get("response") or "").strip() + if not response: + return {"status": "ignored", "reason": "Empty response"} + + channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {} + message = payload.get("message") if isinstance(payload.get("message"), dict) else {} + container = payload.get("container") if isinstance(payload.get("container"), dict) else {} + user = payload.get("user") if isinstance(payload.get("user"), dict) else {} + channel_id = str(channel.get("id") or container.get("channel_id") or "") + event_ts = str( + action.get("action_ts") or message.get("ts") or container.get("message_ts") or "" + ) + thread_ts = str( + message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or event_ts + ) + user_id = str(user.get("id") or "") + if not channel_id or not thread_ts or not event_ts or not user_id: + return {"status": "ignored", "reason": "Missing Slack action context"} + + repo_config = await get_slack_repo_config(channel_id, thread_ts, slack_user_id=user_id) + background_tasks.add_task( + process_slack_mention, + { + "channel_id": channel_id, + "thread_ts": thread_ts, + "event_ts": event_ts, + "user_id": user_id, + "text": response, + "bot_user_id": SLACK_BOT_USER_ID, + }, + repo_config, + ) + return {"status": "accepted", "message": "Slack option queued"} + + +def _first_open_swe_option_action(actions: Any) -> dict[str, Any] | None: + if not isinstance(actions, list): + return None + for action in actions: + if isinstance(action, dict) and action.get("action_id") == "open_swe_option_select": + return action + return None + + @app.get("/webhooks/slack") async def slack_webhook_verify() -> dict[str, str]: """Verify endpoint for Slack webhook setup.""" diff --git a/tests/test_slack_assistants_status.py b/tests/test_slack_assistants_status.py index ca3c7e2b..0e75f00b 100644 --- a/tests/test_slack_assistants_status.py +++ b/tests/test_slack_assistants_status.py @@ -230,6 +230,23 @@ async def test_post_slack_thread_reply_with_ts_returns_http_error( @pytest.mark.asyncio +@pytest.mark.asyncio +async def test_post_slack_thread_reply_with_ts_sends_blocks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test") + + blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "Pick"}}] + client_cm = _async_client_cm(_ok_response()) + with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm): + result = await slack_utils.post_slack_thread_reply_with_ts( + "C1", "1.0", "Pick", blocks=blocks + ) + + assert result == ("1.0", None) + assert client_cm.post.call_args.kwargs["json"]["blocks"] == blocks + + async def test_post_slack_thread_reply_preserves_bool_return_on_error( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_slack_thread_reply_tool.py b/tests/test_slack_thread_reply_tool.py index 2e78462d..e4415ab1 100644 --- a/tests/test_slack_thread_reply_tool.py +++ b/tests/test_slack_thread_reply_tool.py @@ -23,7 +23,11 @@ def test_slack_thread_reply_returns_structured_error_for_msg_too_long( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_post_and_store_mapping( - channel_id: str, thread_ts: str, message: str + channel_id: str, + thread_ts: str, + message: str, + *, + blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: return None, "msg_too_long" @@ -47,7 +51,11 @@ def test_slack_thread_reply_hints_not_to_retry_channel_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_post_and_store_mapping( - channel_id: str, thread_ts: str, message: str + channel_id: str, + thread_ts: str, + message: str, + *, + blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: return None, slack_error @@ -68,7 +76,11 @@ def test_slack_thread_reply_rate_limited_hint_includes_retry_after( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_post_and_store_mapping( - channel_id: str, thread_ts: str, message: str + channel_id: str, + thread_ts: str, + message: str, + *, + blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: return None, "rate_limited: 30" @@ -88,7 +100,11 @@ def test_slack_thread_reply_rate_limited_hint_without_retry_after( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_post_and_store_mapping( - channel_id: str, thread_ts: str, message: str + channel_id: str, + thread_ts: str, + message: str, + *, + blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: return None, "rate_limited" @@ -106,7 +122,11 @@ def test_slack_thread_reply_uses_post_failed_without_slack_error( monkeypatch: pytest.MonkeyPatch, ) -> None: async def fake_post_and_store_mapping( - channel_id: str, thread_ts: str, message: str + channel_id: str, + thread_ts: str, + message: str, + *, + blocks: list[dict[str, Any]] | None = None, ) -> tuple[str | None, str | None]: return None, None @@ -119,3 +139,33 @@ def test_slack_thread_reply_uses_post_failed_without_slack_error( assert result["error"] == "post failed" assert result["slack_error"] is None assert result["message_chars"] == 5 + + +def test_slack_thread_reply_builds_option_blocks(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + async def fake_post_and_store_mapping( + channel_id: str, + thread_ts: str, + message: str, + *, + blocks: list[dict[str, Any]] | None = None, + ) -> tuple[str | None, str | None]: + captured.update( + {"channel_id": channel_id, "thread_ts": thread_ts, "message": message, "blocks": blocks} + ) + return "2.0", None + + monkeypatch.setattr(slack_reply_tool, "get_config", _config) + monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping) + + result = slack_reply_tool.slack_thread_reply("Pick one", options=["A", "B"]) + + assert result == {"success": True} + assert captured["channel_id"] == "C1" + assert captured["thread_ts"] == "1.0" + assert captured["message"] == "Pick one" + actions = captured["blocks"][1] + assert actions["type"] == "actions" + assert [button["text"]["text"] for button in actions["elements"]] == ["A", "B"] + assert actions["elements"][0]["action_id"] == "open_swe_option_select" diff --git a/ui/src/components/agents/ported/ReplyCard.tsx b/ui/src/components/agents/ported/ReplyCard.tsx index 7611cbe2..270f2de2 100644 --- a/ui/src/components/agents/ported/ReplyCard.tsx +++ b/ui/src/components/agents/ported/ReplyCard.tsx @@ -21,6 +21,28 @@ const SLACK_TOKEN = /<([^>]+)>/g const LINK_CLASS = "text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]" +type SlackTextObject = { type?: string; text?: string } +type SlackBlock = { + type?: string + text?: SlackTextObject + elements?: Array<{ type?: string; text?: SlackTextObject }> +} + +function isSlackTextObject(value: unknown): value is SlackTextObject { + return ( + !!value && + typeof value === "object" && + typeof (value as SlackTextObject).text === "string" + ) +} + +function isSlackBlockArray(value: unknown): value is SlackBlock[] { + return ( + Array.isArray(value) && + value.every((block) => !!block && typeof block === "object") + ) +} + // Slack mrkdwn isn't standard Markdown — rewrite its link/mention syntax for display // rather than feeding it to the Markdown renderer (which mis-renders *bold* etc.). function renderSlackBody(text: string): Array { @@ -71,11 +93,88 @@ function renderSlackBody(text: string): Array { return nodes } +function blocksFromOptions( + message: string, + options: unknown +): SlackBlock[] | null { + if (!Array.isArray(options)) return null + const cleanOptions = options.filter( + (option): option is string => + typeof option === "string" && option.trim().length > 0 + ) + if (cleanOptions.length === 0) return null + return [ + { type: "section", text: { type: "mrkdwn", text: message } }, + { + type: "actions", + elements: cleanOptions.slice(0, 5).map((option) => ({ + type: "button", + text: { type: "plain_text", text: option }, + })), + }, + ] +} + +function renderSlackBlocks(blocks: SlackBlock[]): ReactNode { + return ( +
+ {blocks.map((block, index) => { + if ( + (block.type === "section" || block.type === "context") && + isSlackTextObject(block.text) + ) { + return ( +
+ {renderSlackBody(block.text.text ?? "")} +
+ ) + } + if (block.type === "actions" && Array.isArray(block.elements)) { + return ( +
+ {block.elements.map((element, elementIndex) => { + const label = isSlackTextObject(element.text) + ? element.text.text + : element.type || "Action" + return ( + + {label} + + ) + })} +
+ ) + } + if (block.type === "divider") { + return ( +
+ ) + } + return null + })} +
+ ) +} + export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) { const isLinear = chunk.toolKind === "linear" const body = ((isLinear ? chunk.input?.comment_body : chunk.input?.message) as string) || "" + const blocks = !isLinear + ? isSlackBlockArray(chunk.input?.blocks) + ? chunk.input.blocks + : blocksFromOptions(body, chunk.input?.options) + : null return (
@@ -88,6 +187,8 @@ export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) {
{isLinear ? ( + ) : blocks ? ( + renderSlackBlocks(blocks) ) : (
{renderSlackBody(body)}