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>
This commit is contained in:
Johannes du Plessis 2026-06-04 10:26:09 -07:00 • committed by GitHub
parent 5e9dcdb21d
commit c1e46b938e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 317 additions and 11 deletions

View file

@ -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** 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: 2. Copy the manifest below, replacing the two placeholder URLs:
- Replace `<your-provider-id>` with the OAuth provider ID from step 3a - Replace `<your-provider-id>` with the OAuth provider ID from step 3a
- Replace `<your-ngrok-url>` with the ngrok URL from step 2 - Replace `<your-ngrok-url>` with the backend URL from step 2 (or your deployed LangGraph/FastAPI URL in production)
<details> <details>
<summary>Slack App Manifest</summary> <summary>Slack App Manifest</summary>
@ -343,6 +343,10 @@ Users can also override the team/project mapping per-comment by including `repo:
"message.mpim" "message.mpim"
] ]
}, },
"interactivity": {
"is_enabled": true,
"request_url": "https://<your-ngrok-url>/webhooks/slack/interactivity"
},
"org_deploy_enabled": false, "org_deploy_enabled": false,
"socket_mode_enabled": false, "socket_mode_enabled": false,
"token_rotation_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-...`) 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://<your-backend-url>/webhooks/slack`
- **Interactivity & Shortcuts → Interactivity Request URL:** `https://<your-backend-url>/webhooks/slack/interactivity`
Slack Block Kit option buttons only work when Interactivity is enabled and pointed at `/webhooks/slack/interactivity`.
**Credentials you'll need:** **Credentials you'll need:**
- `SLACK_BOT_TOKEN`: the Bot User OAuth Token (`xoxb-...`) - `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 | | `POST /webhooks/linear` | Linear comment webhooks |
| `GET /webhooks/linear` | Linear webhook verification | | `GET /webhooks/linear` | Linear webhook verification |
| `POST /webhooks/slack` | Slack event webhooks | | `POST /webhooks/slack` | Slack event webhooks |
| `POST /webhooks/slack/interactivity` | Slack Block Kit button interactions |
| `GET /webhooks/slack` | Slack webhook verification | | `GET /webhooks/slack` | Slack webhook verification |
| `GET /dashboard/api/auth/login` | Dashboard GitHub OAuth login | | `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) | | `GET /dashboard/api/auth/callback` | Dashboard GitHub OAuth callback (registered on the App in step 3b) |

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import os import os
from typing import Any 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. """Post a message to the current Slack thread.
Use this for clarifying questions, mid-run progress updates, and the final 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. bullet lists with "• ", ```code blocks```, > blockquotes.
Do NOT use **bold**, [link](url), or other standard Markdown syntax. 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>. 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)). You can find user IDs in the conversation context (e.g. @Name(U06KD8BFY95)).
Example: <@U06KD8BFY95> will tag that user in the message.""" 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"} return {"success": False, "error": "Message cannot be empty"}
message = convert_mentions_to_slack_format(message) 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: if message_ts is None:
return { return {
"success": False, "success": False,
@ -61,6 +73,29 @@ def slack_thread_reply(message: str) -> dict[str, Any]:
return {"success": True} 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: def _slack_reply_failure_hint(slack_error: str | None) -> str:
if slack_error == "msg_too_long": if slack_error == "msg_too_long":
return "Slack rejected the message as too long; retry with a shorter message." 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( 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]: ) -> 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: if message_ts:
langgraph_client = get_client(url=LANGGRAPH_URL) langgraph_client = get_client(url=LANGGRAPH_URL)
await store_slack_message_run_mapping(langgraph_client, channel_id, thread_ts, message_ts) await store_slack_message_run_mapping(langgraph_client, channel_id, thread_ts, message_ts)

View file

@ -296,6 +296,7 @@ async def post_slack_thread_reply_with_ts(
*, *,
unfurl_links: bool = True, unfurl_links: bool = True,
unfurl_media: bool = True, unfurl_media: bool = True,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""Post a reply in a Slack thread and return its Slack timestamp and error.""" """Post a reply in a Slack thread and return its Slack timestamp and error."""
if not SLACK_BOT_TOKEN: if not SLACK_BOT_TOKEN:
@ -308,6 +309,8 @@ async def post_slack_thread_reply_with_ts(
"unfurl_links": unfurl_links, "unfurl_links": unfurl_links,
"unfurl_media": unfurl_media, "unfurl_media": unfurl_media,
} }
if blocks:
payload["blocks"] = blocks
async with httpx.AsyncClient() as http_client: async with httpx.AsyncClient() as http_client:
try: try:

View file

@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import parse_qs, quote
import httpx import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request 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"} 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") @app.get("/webhooks/slack")
async def slack_webhook_verify() -> dict[str, str]: async def slack_webhook_verify() -> dict[str, str]:
"""Verify endpoint for Slack webhook setup.""" """Verify endpoint for Slack webhook setup."""

View file

@ -230,6 +230,23 @@ async def test_post_slack_thread_reply_with_ts_returns_http_error(
@pytest.mark.asyncio @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( async def test_post_slack_thread_reply_preserves_bool_return_on_error(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:

View file

@ -23,7 +23,11 @@ def test_slack_thread_reply_returns_structured_error_for_msg_too_long(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
async def fake_post_and_store_mapping( 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]: ) -> tuple[str | None, str | None]:
return None, "msg_too_long" return None, "msg_too_long"
@ -47,7 +51,11 @@ def test_slack_thread_reply_hints_not_to_retry_channel_errors(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
async def fake_post_and_store_mapping( 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]: ) -> tuple[str | None, str | None]:
return None, slack_error return None, slack_error
@ -68,7 +76,11 @@ def test_slack_thread_reply_rate_limited_hint_includes_retry_after(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
async def fake_post_and_store_mapping( 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]: ) -> tuple[str | None, str | None]:
return None, "rate_limited: 30" return None, "rate_limited: 30"
@ -88,7 +100,11 @@ def test_slack_thread_reply_rate_limited_hint_without_retry_after(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
async def fake_post_and_store_mapping( 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]: ) -> tuple[str | None, str | None]:
return None, "rate_limited" return None, "rate_limited"
@ -106,7 +122,11 @@ def test_slack_thread_reply_uses_post_failed_without_slack_error(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
async def fake_post_and_store_mapping( 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]: ) -> tuple[str | None, str | None]:
return None, 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["error"] == "post failed"
assert result["slack_error"] is None assert result["slack_error"] is None
assert result["message_chars"] == 5 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"

View file

@ -21,6 +21,28 @@ const SLACK_TOKEN = /<([^>]+)>/g
const LINK_CLASS = const LINK_CLASS =
"text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]" "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 // 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.). // rather than feeding it to the Markdown renderer (which mis-renders *bold* etc.).
function renderSlackBody(text: string): Array<ReactNode> { function renderSlackBody(text: string): Array<ReactNode> {
@ -71,11 +93,88 @@ function renderSlackBody(text: string): Array<ReactNode> {
return nodes 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 (
<div className="flex flex-col gap-2">
{blocks.map((block, index) => {
if (
(block.type === "section" || block.type === "context") &&
isSlackTextObject(block.text)
) {
return (
<div
key={index}
className="[overflow-wrap:anywhere] break-words whitespace-pre-wrap"
>
{renderSlackBody(block.text.text ?? "")}
</div>
)
}
if (block.type === "actions" && Array.isArray(block.elements)) {
return (
<div key={index} className="flex flex-wrap gap-2">
{block.elements.map((element, elementIndex) => {
const label = isSlackTextObject(element.text)
? element.text.text
: element.type || "Action"
return (
<span
key={elementIndex}
className="rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel)] px-2 py-1 text-[12px] text-[color:var(--ui-text)]"
>
{label}
</span>
)
})}
</div>
)
}
if (block.type === "divider") {
return (
<div
key={index}
className="border-t border-[var(--ui-border-subtle)]"
/>
)
}
return null
})}
</div>
)
}
export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) { export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) {
const isLinear = chunk.toolKind === "linear" const isLinear = chunk.toolKind === "linear"
const body = const body =
((isLinear ? chunk.input?.comment_body : chunk.input?.message) as string) || ((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 ( return (
<div className="my-1"> <div className="my-1">
@ -88,6 +187,8 @@ export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) {
<div className="max-h-[250px] overflow-auto px-3 py-2 text-[13px] text-[color:var(--ui-text)]"> <div className="max-h-[250px] overflow-auto px-3 py-2 text-[13px] text-[color:var(--ui-text)]">
{isLinear ? ( {isLinear ? (
<Markdown content={body} /> <Markdown content={body} />
) : blocks ? (
renderSlackBlocks(blocks)
) : ( ) : (
<div className="[overflow-wrap:anywhere] break-words whitespace-pre-wrap"> <div className="[overflow-wrap:anywhere] break-words whitespace-pre-wrap">
{renderSlackBody(body)} {renderSlackBody(body)}