mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 00:42:11 +00:00
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:
parent
5e9dcdb21d
commit
c1e46b938e
7 changed files with 317 additions and 11 deletions
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue