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**
2. Copy the manifest below, replacing the two placeholder URLs:
- 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>
<summary>Slack App Manifest</summary>
@ -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://<your-ngrok-url>/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://<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:**
- `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) |

View file

@ -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)

View file

@ -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:

View file

@ -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."""

View file

@ -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:

View file

@ -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"

View file

@ -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<ReactNode> {
@ -71,11 +93,88 @@ function renderSlackBody(text: string): Array<ReactNode> {
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) {
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 (
<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)]">
{isLinear ? (
<Markdown content={body} />
) : blocks ? (
renderSlackBlocks(blocks)
) : (
<div className="[overflow-wrap:anywhere] break-words whitespace-pre-wrap">
{renderSlackBody(body)}