feat: Add slack trigger webhook (#1006)

* feat: Add slack trigger webhook

* cr

* better username tagging, react with eyes

* cr

* cr

* cr
This commit is contained in:
Brace Sproul 2026-03-04 16:43:28 -08:00 • committed by GitHub
parent b5951413d7
commit 51b4b27c2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 824 additions and 17 deletions

View file

@ -37,7 +37,9 @@ TASK_EXECUTION_SECTION = """---
### Task Execution
If you make changes, call `linear_comment` to notify the user of the changes. For questions or status updates, call `linear_comment` with your answer.
If you make changes, communicate updates in the source channel:
- Use `linear_comment` for Linear-triggered tasks.
- Use `slack_thread_reply` for Slack-triggered tasks.
For tasks that require code changes, follow this order:
@ -45,12 +47,12 @@ For tasks that require code changes, follow this order:
2. **Implement** — Make focused, minimal changes. Do not modify code outside the scope of the task.
3. **Verify** — Run tests and linters to confirm correctness before submitting.
4. **Submit** — Call `commit_and_open_pr`.
5. **Comment** — Call `linear_comment` with a summary and the PR link.
5. **Comment** — Call `linear_comment` or `slack_thread_reply` with a summary and the PR link.
For questions or status checks (no code changes needed):
1. **Answer** — Gather the information needed to respond.
2. **Comment** — Call `linear_comment` with your answer. Never leave a question unanswered."""
2. **Comment** — Call `linear_comment` or `slack_thread_reply` with your answer. Never leave a question unanswered."""
TOOL_USAGE_SECTION = """---
@ -70,7 +72,11 @@ Make HTTP requests (GET, POST, PUT, DELETE, etc.) to APIs. Use this for API call
Commits all changes, pushes to a branch, and opens a **draft** GitHub PR. If a PR already exists for the branch, it is updated instead of recreated.
#### `linear_comment`
Posts a comment to a Linear ticket given a `ticket_id`. Call this **after** `commit_and_open_pr` to notify stakeholders that the work is done and include the PR link. You can tag Linear users with `@username` (their Linear display name). Example: "I've completed the implementation and opened a PR: <pr_url>. Hey @username, let me know if you have any feedback!"."""
Posts a comment to a Linear ticket given a `ticket_id`. Call this **after** `commit_and_open_pr` to notify stakeholders that the work is done and include the PR link. You can tag Linear users with `@username` (their Linear display name). Example: "I've completed the implementation and opened a PR: <pr_url>. Hey @username, let me know if you have any feedback!".
#### `slack_thread_reply`
Posts a message to the active Slack thread. Use this for clarifying questions, status updates, and final summaries when the task was triggered from Slack.
Remember that Slack does not use standard markdown formatting. Ensure you always conform to the Slack specific markdown format when sending messages"""
TOOL_BEST_PRACTICES_SECTION = """---

View file

@ -32,7 +32,13 @@ from .middleware import (
open_pr_if_needed,
)
from .prompt import construct_system_prompt
from .tools import commit_and_open_pr, fetch_url, http_request, linear_comment
from .tools import (
commit_and_open_pr,
fetch_url,
http_request,
linear_comment,
slack_thread_reply,
)
from .utils.auth import save_encrypted_token_from_email
from .utils.model import make_model
@ -377,7 +383,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
linear_issue_number=linear_issue_number,
agents_md=agents_md,
),
tools=[http_request, fetch_url, commit_and_open_pr, linear_comment],
tools=[http_request, fetch_url, commit_and_open_pr, linear_comment, slack_thread_reply],
backend=sandbox_backend,
middleware=[
ToolErrorMiddleware(),

View file

@ -2,5 +2,12 @@ from .commit_and_open_pr import commit_and_open_pr
from .fetch_url import fetch_url
from .http_request import http_request
from .linear_comment import linear_comment
from .slack_thread_reply import slack_thread_reply
__all__ = ["commit_and_open_pr", "fetch_url", "http_request", "linear_comment"]
__all__ = [
"commit_and_open_pr",
"fetch_url",
"http_request",
"linear_comment",
"slack_thread_reply",
]

View file

@ -0,0 +1,27 @@
import asyncio
from typing import Any
from langgraph.config import get_config
from ..utils.slack import post_slack_thread_reply
def slack_thread_reply(message: str) -> dict[str, Any]:
"""Post a message to the current Slack thread."""
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 or not thread_ts:
return {
"success": False,
"error": "Missing slack_thread.channel_id or slack_thread.thread_ts in config",
}
if not message.strip():
return {"success": False, "error": "Message cannot be empty"}
success = asyncio.run(post_slack_thread_reply(channel_id, thread_ts, message))
return {"success": success}

View file

@ -14,6 +14,7 @@ from langgraph_sdk import get_client
from ..encryption import encrypt_token
from .linear import comment_on_linear_issue
from .slack import post_slack_thread_reply
logger = logging.getLogger(__name__)
@ -36,6 +37,30 @@ logger.debug(
)
def _retry_instruction(source: str) -> str:
if source == "slack":
return "Once authenticated, mention me again in this Slack thread to retry."
return "Once authenticated, reply to this issue mentioning @openswe to retry."
def _source_account_label(source: str) -> str:
if source == "slack":
return "Slack"
return "Linear"
def _auth_link_text(source: str, auth_url: str) -> str:
if source == "slack":
return auth_url
return f"[Authenticate with GitHub]({auth_url})"
def _work_item_label(source: str) -> str:
if source == "slack":
return "thread"
return "issue"
def get_service_jwt_token_for_user(
user_id: str, tenant_id: str, expiration_seconds: int = 300
) -> str:
@ -45,10 +70,10 @@ def get_service_jwt_token_for_user(
raise ValueError(msg)
payload = {
"sub": user_id,
"tenant_id": tenant_id,
"iat": datetime.now(UTC),
"sub": "unspecified",
"exp": datetime.now(UTC) + timedelta(seconds=expiration_seconds),
"user_id": user_id,
"tenant_id": tenant_id,
}
return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256")
@ -94,6 +119,7 @@ async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str
headers = {
"X-Service-Key": service_token,
"X-Tenant-Id": tenant_id,
"X-User-Id": ls_user_id,
}
payload = {
@ -162,9 +188,10 @@ async def leave_failure_comment(
message: str,
) -> None:
"""Leave an auth failure comment for the appropriate source."""
config = get_config()
configurable = config.get("configurable", {})
if source == "linear":
config = get_config()
configurable = config.get("configurable", {})
linear_issue = configurable.get("linear_issue", {})
issue_id = linear_issue.get("id") if isinstance(linear_issue, dict) else None
if issue_id:
@ -175,6 +202,18 @@ async def leave_failure_comment(
)
await comment_on_linear_issue(issue_id, message)
return
if source == "slack":
slack_thread = configurable.get("slack_thread", {})
channel_id = slack_thread.get("channel_id") if isinstance(slack_thread, dict) else None
thread_ts = slack_thread.get("thread_ts") if isinstance(slack_thread, dict) else None
if channel_id and thread_ts:
logger.info(
"Posting auth failure reply to Slack channel %s thread %s",
channel_id,
thread_ts,
)
await post_slack_thread_reply(channel_id, thread_ts, message)
return
raise ValueError(f"Unknown source: {source}")
@ -211,14 +250,15 @@ async def save_encrypted_token_from_email(
ls_user_id = user_info.get("ls_user_id")
tenant_id = user_info.get("tenant_id")
if not ls_user_id or not tenant_id:
account_label = _source_account_label(source)
message = (
"🔐 **GitHub Authentication Required**\n\n"
f"Could not find a LangSmith account for **{email}**.\n\n"
"Please ensure this email is invited to the main LangSmith organization. "
"If your Linear account uses a different email than your LangSmith account, "
f"If your {account_label} account uses a different email than your LangSmith account, "
"you may need to update one of them to match.\n\n"
"Once your email is added to LangSmith, "
"reply to this issue mentioning @openswe to retry."
f"{_retry_instruction(source)}"
)
await leave_failure_comment(source, message)
raise ValueError(f"No ls_user_id found from email {email}")
@ -226,12 +266,14 @@ async def save_encrypted_token_from_email(
auth_result = await get_github_token_for_user(ls_user_id, tenant_id)
auth_url = auth_result.get("auth_url")
if auth_url:
work_item_label = _work_item_label(source)
auth_link_text = _auth_link_text(source, auth_url)
message = (
"🔐 **GitHub Authentication Required**\n\n"
"To allow the Open SWE agent to work on this issue, "
f"To allow the Open SWE agent to work on this {work_item_label}, "
"please authenticate with GitHub by clicking the link below:\n\n"
f"[Authenticate with GitHub]({auth_url})\n\n"
"Once authenticated, reply to this issue mentioning @openswe to retry."
f"{auth_link_text}\n\n"
f"{_retry_instruction(source)}"
)
await leave_failure_comment(source, message)
raise ValueError("User not authenticated.")

View file

@ -0,0 +1,323 @@
"""Slack API utilities."""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import logging
import os
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
SLACK_API_BASE_URL = "https://slack.com/api"
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", "")
def _slack_headers() -> dict[str, str]:
if not SLACK_BOT_TOKEN:
return {}
return {
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json; charset=utf-8",
}
def _parse_ts(ts: str | None) -> float:
try:
return float(ts or "0")
except (TypeError, ValueError):
return 0.0
def _extract_slack_user_name(user: dict[str, Any]) -> str:
profile = user.get("profile", {})
if isinstance(profile, dict):
display_name = profile.get("display_name")
if isinstance(display_name, str) and display_name.strip():
return display_name.strip()
real_name = profile.get("real_name")
if isinstance(real_name, str) and real_name.strip():
return real_name.strip()
real_name = user.get("real_name")
if isinstance(real_name, str) and real_name.strip():
return real_name.strip()
name = user.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
return "unknown"
def replace_bot_mention_with_username(text: str, bot_user_id: str, bot_username: str) -> str:
"""Replace Slack bot ID mention token with @username."""
if not text:
return ""
if bot_user_id and bot_username:
return text.replace(f"<@{bot_user_id}>", f"@{bot_username}")
return text
def verify_slack_signature(
body: bytes,
timestamp: str,
signature: str,
secret: str,
max_age_seconds: int = 300,
) -> bool:
"""Verify Slack request signature."""
if not secret:
return True
if not timestamp or not signature:
return False
try:
request_timestamp = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - request_timestamp) > max_age_seconds:
return False
base_string = f"v0:{timestamp}:{body.decode('utf-8', errors='replace')}"
expected = (
"v0="
+ hmac.new(secret.encode("utf-8"), base_string.encode("utf-8"), hashlib.sha256).hexdigest()
)
return hmac.compare_digest(expected, signature)
def strip_bot_mention(text: str, bot_user_id: str, bot_username: str = "") -> str:
"""Remove bot mention token from Slack text."""
if not text:
return ""
stripped = text
if bot_user_id:
stripped = stripped.replace(f"<@{bot_user_id}>", "")
if bot_username:
stripped = stripped.replace(f"@{bot_username}", "")
return stripped.strip()
def select_slack_context_messages(
messages: list[dict[str, Any]],
current_message_ts: str,
bot_user_id: str,
bot_username: str = "",
) -> tuple[list[dict[str, Any]], str]:
"""Select context from thread start or previous bot mention."""
if not messages:
return [], "thread_start"
current_ts = _parse_ts(current_message_ts)
ordered = sorted(messages, key=lambda item: _parse_ts(item.get("ts")))
up_to_current = [item for item in ordered if _parse_ts(item.get("ts")) <= current_ts]
if not up_to_current:
up_to_current = ordered
mention_tokens = []
if bot_user_id:
mention_tokens.append(f"<@{bot_user_id}>")
if bot_username:
mention_tokens.append(f"@{bot_username}")
if not mention_tokens:
return up_to_current, "thread_start"
last_mention_index = -1
for index, message in enumerate(up_to_current[:-1]):
text = message.get("text", "")
if isinstance(text, str) and any(token in text for token in mention_tokens):
last_mention_index = index
if last_mention_index >= 0:
return up_to_current[last_mention_index:], "last_mention"
return up_to_current, "thread_start"
def format_slack_messages_for_prompt(
messages: list[dict[str, Any]],
user_names_by_id: dict[str, str] | None = None,
bot_user_id: str = "",
bot_username: str = "",
) -> str:
"""Format Slack messages into readable prompt text."""
if not messages:
return "(no thread messages available)"
lines: list[str] = []
for message in messages:
text = (
replace_bot_mention_with_username(
str(message.get("text", "")),
bot_user_id=bot_user_id,
bot_username=bot_username,
).strip()
or "[non-text message]"
)
user_id = message.get("user")
if isinstance(user_id, str) and user_id:
author_name = (user_names_by_id or {}).get(user_id) or user_id
author = f"@{author_name}({user_id})"
else:
bot_profile = message.get("bot_profile", {})
if isinstance(bot_profile, dict):
bot_name = bot_profile.get("name") or message.get("username") or "Bot"
else:
bot_name = message.get("username") or "Bot"
author = f"@{bot_name}(bot)"
lines.append(f"{author}: {text}")
return "\n".join(lines)
async def post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> bool:
"""Post a reply in a Slack thread."""
if not SLACK_BOT_TOKEN:
return False
payload = {
"channel": channel_id,
"thread_ts": thread_ts,
"text": text,
}
async with httpx.AsyncClient() as http_client:
try:
response = await http_client.post(
f"{SLACK_API_BASE_URL}/chat.postMessage",
headers=_slack_headers(),
json=payload,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
logger.warning("Slack chat.postMessage failed: %s", data.get("error"))
return False
return True
except httpx.HTTPError:
logger.exception("Slack chat.postMessage request failed")
return False
async def add_slack_reaction(channel_id: str, message_ts: str, emoji: str = "eyes") -> bool:
"""Add a reaction to a Slack message."""
if not SLACK_BOT_TOKEN:
return False
payload = {
"channel": channel_id,
"timestamp": message_ts,
"name": emoji,
}
async with httpx.AsyncClient() as http_client:
try:
response = await http_client.post(
f"{SLACK_API_BASE_URL}/reactions.add",
headers=_slack_headers(),
json=payload,
)
response.raise_for_status()
data = response.json()
if data.get("ok"):
return True
if data.get("error") == "already_reacted":
return True
logger.warning("Slack reactions.add failed: %s", data.get("error"))
return False
except httpx.HTTPError:
logger.exception("Slack reactions.add request failed")
return False
async def get_slack_user_info(user_id: str) -> dict[str, Any] | None:
"""Get Slack user details by user ID."""
if not SLACK_BOT_TOKEN:
return None
async with httpx.AsyncClient() as http_client:
try:
response = await http_client.get(
f"{SLACK_API_BASE_URL}/users.info",
headers=_slack_headers(),
params={"user": user_id},
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
logger.warning("Slack users.info failed: %s", data.get("error"))
return None
user = data.get("user")
if isinstance(user, dict):
return user
except httpx.HTTPError:
logger.exception("Slack users.info request failed")
return None
async def get_slack_user_names(user_ids: list[str]) -> dict[str, str]:
"""Get display names for a set of Slack user IDs."""
unique_ids = sorted({user_id for user_id in user_ids if isinstance(user_id, str) and user_id})
if not unique_ids:
return {}
user_infos = await asyncio.gather(
*(get_slack_user_info(user_id) for user_id in unique_ids),
return_exceptions=True,
)
user_names: dict[str, str] = {}
for user_id, user_info in zip(unique_ids, user_infos, strict=True):
if isinstance(user_info, dict):
user_names[user_id] = _extract_slack_user_name(user_info)
else:
user_names[user_id] = user_id
return user_names
async def fetch_slack_thread_messages(channel_id: str, thread_ts: str) -> list[dict[str, Any]]:
"""Fetch all messages for a Slack thread."""
if not SLACK_BOT_TOKEN:
return []
messages: list[dict[str, Any]] = []
cursor: str | None = None
async with httpx.AsyncClient() as http_client:
while True:
params: dict[str, str | int] = {"channel": channel_id, "ts": thread_ts, "limit": 200}
if cursor:
params["cursor"] = cursor
try:
response = await http_client.get(
f"{SLACK_API_BASE_URL}/conversations.replies",
headers=_slack_headers(),
params=params,
)
response.raise_for_status()
payload = response.json()
except httpx.HTTPError:
logger.exception("Slack conversations.replies request failed")
break
if not payload.get("ok"):
logger.warning("Slack conversations.replies failed: %s", payload.get("error"))
break
batch = payload.get("messages", [])
if isinstance(batch, list):
messages.extend(item for item in batch if isinstance(item, dict))
response_metadata = payload.get("response_metadata", {})
cursor = (
response_metadata.get("next_cursor") if isinstance(response_metadata, dict) else ""
)
if not cursor:
break
messages.sort(key=lambda item: _parse_ts(item.get("ts")))
return messages

View file

@ -5,6 +5,7 @@ import hmac
import json
import logging
import os
import uuid
from typing import Any
import httpx
@ -14,12 +15,28 @@ from langgraph_sdk import get_client
from .utils.comments import get_recent_comments
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
from .utils.slack import (
add_slack_reaction,
fetch_slack_thread_messages,
format_slack_messages_for_prompt,
get_slack_user_info,
get_slack_user_names,
post_slack_thread_reply,
select_slack_context_messages,
strip_bot_mention,
verify_slack_signature,
)
logger = logging.getLogger(__name__)
app = FastAPI()
LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "")
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
SLACK_BOT_USER_ID = os.environ.get("SLACK_BOT_USER_ID", "")
SLACK_BOT_USERNAME = os.environ.get("SLACK_BOT_USERNAME", "")
SLACK_REPO_OWNER = os.environ.get("SLACK_REPO_OWNER", "langchain-ai")
SLACK_REPO_NAME = os.environ.get("SLACK_REPO_NAME", "open-swe")
LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
"LANGGRAPH_URL_PROD", "http://localhost:2024"
@ -218,6 +235,33 @@ def generate_thread_id_from_issue(issue_id: str) -> str:
)
def generate_thread_id_from_slack_thread(channel_id: str, thread_id: str) -> str:
"""Generate a deterministic thread ID from a Slack thread identifier."""
composite = f"{channel_id}:{thread_id}"
md5_hex = hashlib.md5(composite.encode("utf-8")).hexdigest()
return str(uuid.UUID(hex=md5_hex))
async def get_slack_repo_config(message: str, channel_id: str, thread_ts: str) -> dict[str, str]:
"""Resolve repository configuration for Slack-triggered runs."""
if "repo:" in message:
# extract out the repo from the message assuming its in the format of repo:owner/repo
import re
match = re.search(r"repo:([^ ]+)", message)
if match:
repo = match.group(1)
owner, name = repo.split("/")
await post_slack_thread_reply(
channel_id, thread_ts, f"Using repository: `{owner}/{name}`"
)
return {"owner": owner, "name": name}
owner = SLACK_REPO_OWNER.strip() or "langchain-ai"
name = SLACK_REPO_NAME.strip() or "langchainplus"
return {"owner": owner, "name": name}
async def is_thread_active(thread_id: str) -> bool:
"""Check if a thread is currently active (has a running run).
@ -513,6 +557,120 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
logger.info("LangGraph run created successfully for thread %s", thread_id)
async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[str, str]) -> None:
"""Process a Slack app mention by creating or interrupting a thread run."""
channel_id = event_data.get("channel_id", "")
thread_ts = event_data.get("thread_ts", "")
event_ts = event_data.get("event_ts", "")
user_id = event_data.get("user_id", "")
text = event_data.get("text", "")
bot_user_id = event_data.get("bot_user_id", "")
if not channel_id or not thread_ts or not event_ts:
logger.warning(
"Missing Slack event fields (channel_id=%s, thread_ts=%s, event_ts=%s)",
channel_id,
thread_ts,
event_ts,
)
return
reacted = await add_slack_reaction(channel_id, event_ts, "eyes")
if not reacted:
logger.debug(
"Unable to add eyes reaction for Slack message ts=%s in channel=%s",
event_ts,
channel_id,
)
thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts)
user_email = None
user_name = ""
if user_id:
slack_user = await get_slack_user_info(user_id)
if slack_user:
profile = slack_user.get("profile", {})
if isinstance(profile, dict):
user_email = profile.get("email")
user_name = (
profile.get("display_name")
or profile.get("real_name")
or slack_user.get("real_name")
or slack_user.get("name")
or ""
)
thread_messages = await fetch_slack_thread_messages(channel_id, thread_ts)
if not any(str(message.get("ts")) == str(event_ts) for message in thread_messages):
thread_messages.append({"ts": event_ts, "text": text, "user": user_id})
context_messages, context_mode = select_slack_context_messages(
thread_messages, event_ts, bot_user_id, SLACK_BOT_USERNAME
)
context_user_ids = [
value
for value in (message.get("user") for message in context_messages)
if isinstance(value, str) and value
]
user_names_by_id = await get_slack_user_names(context_user_ids)
if user_id and user_name and user_id not in user_names_by_id:
user_names_by_id[user_id] = user_name
context_text = format_slack_messages_for_prompt(
context_messages,
user_names_by_id,
bot_user_id=bot_user_id,
bot_username=SLACK_BOT_USERNAME,
)
context_source = (
"the previous message where I was tagged"
if context_mode == "last_mention"
else "the beginning of the thread"
)
clean_text = (
strip_bot_mention(text, bot_user_id, bot_username=SLACK_BOT_USERNAME)
or "(no text in mention)"
)
trigger_user = user_name or (f"<@{user_id}>" if user_id else "Unknown user")
prompt = (
"You were mentioned in Slack.\n\n"
f"## Repository\n{repo_config.get('owner')}/{repo_config.get('name')}\n\n"
f"## Triggered by\n{trigger_user}\n\n"
f"## Slack Thread\n- Channel: {channel_id}\n- Thread TS: {thread_ts}\n"
f"- Context starts at: {context_source}\n\n"
f"## Conversation Context\n{context_text}\n\n"
f"## Latest Mention Request\n{clean_text}\n\n"
"Use `slack_thread_reply` to communicate in this Slack thread for clarifications, "
"status updates, and final summaries."
)
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
configurable: dict[str, Any] = {
"repo": repo_config,
"slack_thread": {
"channel_id": channel_id,
"thread_ts": thread_ts,
"triggering_user_id": user_id,
"triggering_user_name": user_name,
"triggering_user_email": user_email,
"triggering_event_ts": event_ts,
},
"user_email": user_email,
"source": "slack",
}
langgraph_client = get_client(url=LANGGRAPH_URL)
await langgraph_client.runs.create(
thread_id,
"agent",
input={"messages": [{"role": "user", "content": content_blocks}]},
config={"configurable": configurable},
if_not_exists="create",
multitask_strategy="interrupt",
)
def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
"""Verify the Linear webhook signature.
@ -651,6 +809,100 @@ async def linear_webhook_verify() -> dict[str, str]:
return {"status": "ok", "message": "Linear webhook endpoint is active"}
@app.post("/webhooks/slack")
async def slack_webhook(request: Request, background_tasks: BackgroundTasks) -> dict[str, str]:
"""Handle Slack Event API webhooks for app mentions."""
body = await request.body()
signature = request.headers.get("X-Slack-Signature", "")
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
if SLACK_SIGNING_SECRET and not verify_slack_signature(
body=body,
timestamp=timestamp,
signature=signature,
secret=SLACK_SIGNING_SECRET,
):
logger.warning("Invalid Slack signature")
raise HTTPException(status_code=401, detail="Invalid signature")
try:
payload = json.loads(body)
except json.JSONDecodeError:
logger.exception("Failed to parse Slack webhook JSON")
return {"status": "error", "message": "Invalid JSON"}
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return {"challenge": challenge}
if payload.get("type") != "event_callback":
return {"status": "ignored", "reason": "Not an event callback"}
event = payload.get("event", {})
if event.get("type") != "app_mention":
message_text = event.get("text", "")
has_username_mention = bool(
event.get("type") == "message"
and SLACK_BOT_USERNAME
and f"@{SLACK_BOT_USERNAME}" in message_text
)
has_id_mention = bool(
event.get("type") == "message"
and SLACK_BOT_USER_ID
and f"<@{SLACK_BOT_USER_ID}>" in message_text
)
if not (has_username_mention or has_id_mention):
return {"status": "ignored", "reason": "Not an app_mention event"}
if event.get("subtype") == "bot_message" or event.get("bot_id"):
return {"status": "ignored", "reason": "Event from a bot"}
channel_id = event.get("channel", "")
event_ts = event.get("ts", "")
thread_ts = event.get("thread_ts") or event_ts
user_id = event.get("user", "")
text = event.get("text", "")
if not channel_id or not event_ts or not thread_ts:
return {"status": "ignored", "reason": "Missing channel/thread timestamp"}
bot_user_id = SLACK_BOT_USER_ID
if not bot_user_id:
authorizations = payload.get("authorizations", [])
if isinstance(authorizations, list) and authorizations:
auth_user_id = authorizations[0].get("user_id")
if isinstance(auth_user_id, str):
bot_user_id = auth_user_id
if not bot_user_id:
authed_users = payload.get("authed_users", [])
if isinstance(authed_users, list) and authed_users:
first_user = authed_users[0]
if isinstance(first_user, str):
bot_user_id = first_user
if bot_user_id and user_id == bot_user_id:
return {"status": "ignored", "reason": "Event from this bot user"}
event_data = {
"channel_id": channel_id,
"thread_ts": thread_ts,
"event_ts": event_ts,
"user_id": user_id,
"text": text,
"bot_user_id": bot_user_id,
}
repo_config = await get_slack_repo_config(text, channel_id, thread_ts)
background_tasks.add_task(process_slack_mention, event_data, repo_config)
return {"status": "accepted", "message": "Slack mention queued"}
@app.get("/webhooks/slack")
async def slack_webhook_verify() -> dict[str, str]:
"""Verify endpoint for Slack webhook setup."""
return {"status": "ok", "message": "Slack webhook endpoint is active"}
@app.get("/health")
async def health_check() -> dict[str, str]:
"""Health check endpoint."""

View file

@ -0,0 +1,30 @@
from __future__ import annotations
import asyncio
import pytest
from agent.utils import auth
def test_leave_failure_comment_posts_to_slack_thread(
monkeypatch: pytest.MonkeyPatch,
) -> None:
called: dict[str, str] = {}
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool:
called["channel_id"] = channel_id
called["thread_ts"] = thread_ts
called["message"] = message
return True
monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply)
monkeypatch.setattr(
auth,
"get_config",
lambda: {"configurable": {"slack_thread": {"channel_id": "C123", "thread_ts": "1.2"}}},
)
asyncio.run(auth.leave_failure_comment("slack", "auth failed"))
assert called == {"channel_id": "C123", "thread_ts": "1.2", "message": "auth failed"}

View file

@ -0,0 +1,114 @@
from agent.utils.slack import (
format_slack_messages_for_prompt,
replace_bot_mention_with_username,
select_slack_context_messages,
strip_bot_mention,
)
from agent.webapp import generate_thread_id_from_slack_thread
def test_generate_thread_id_from_slack_thread_is_deterministic() -> None:
channel_id = "C12345"
thread_ts = "1730900000.123456"
first = generate_thread_id_from_slack_thread(channel_id, thread_ts)
second = generate_thread_id_from_slack_thread(channel_id, thread_ts)
assert first == second
assert len(first) == 36
def test_select_slack_context_messages_uses_thread_start_when_no_prior_mention() -> None:
bot_user_id = "UBOT"
messages = [
{"ts": "1.0", "text": "hello", "user": "U1"},
{"ts": "2.0", "text": "context", "user": "U2"},
{"ts": "3.0", "text": "<@UBOT> please help", "user": "U1"},
]
selected, mode = select_slack_context_messages(messages, "3.0", bot_user_id)
assert mode == "thread_start"
assert [item["ts"] for item in selected] == ["1.0", "2.0", "3.0"]
def test_select_slack_context_messages_uses_previous_mention_boundary() -> None:
bot_user_id = "UBOT"
messages = [
{"ts": "1.0", "text": "hello", "user": "U1"},
{"ts": "2.0", "text": "<@UBOT> first request", "user": "U1"},
{"ts": "3.0", "text": "extra context", "user": "U2"},
{"ts": "4.0", "text": "<@UBOT> second request", "user": "U3"},
]
selected, mode = select_slack_context_messages(messages, "4.0", bot_user_id)
assert mode == "last_mention"
assert [item["ts"] for item in selected] == ["2.0", "3.0", "4.0"]
def test_select_slack_context_messages_ignores_messages_after_current_event() -> None:
bot_user_id = "UBOT"
messages = [
{"ts": "1.0", "text": "<@UBOT> first request", "user": "U1"},
{"ts": "2.0", "text": "follow-up", "user": "U2"},
{"ts": "3.0", "text": "<@UBOT> second request", "user": "U3"},
{"ts": "4.0", "text": "after event", "user": "U4"},
]
selected, mode = select_slack_context_messages(messages, "3.0", bot_user_id)
assert mode == "last_mention"
assert [item["ts"] for item in selected] == ["1.0", "2.0", "3.0"]
def test_strip_bot_mention_removes_bot_tag() -> None:
assert strip_bot_mention("<@UBOT> please check", "UBOT") == "please check"
def test_strip_bot_mention_removes_bot_username_tag() -> None:
assert (
strip_bot_mention("@open-swe please check", "UBOT", bot_username="open-swe")
== "please check"
)
def test_replace_bot_mention_with_username() -> None:
assert (
replace_bot_mention_with_username("<@UBOT> can you help?", "UBOT", "open-swe")
== "@open-swe can you help?"
)
def test_format_slack_messages_for_prompt_uses_name_and_id() -> None:
formatted = format_slack_messages_for_prompt(
[{"ts": "1.0", "text": "hello", "user": "U123"}],
{"U123": "alice"},
)
assert formatted == "@alice(U123): hello"
def test_format_slack_messages_for_prompt_replaces_bot_id_mention_in_text() -> None:
formatted = format_slack_messages_for_prompt(
[{"ts": "1.0", "text": "<@UBOT> status update?", "user": "U123"}],
{"U123": "alice"},
bot_user_id="UBOT",
bot_username="open-swe",
)
assert formatted == "@alice(U123): @open-swe status update?"
def test_select_slack_context_messages_detects_username_mention() -> None:
selected, mode = select_slack_context_messages(
[
{"ts": "1.0", "text": "@open-swe first request", "user": "U1"},
{"ts": "2.0", "text": "follow up", "user": "U2"},
{"ts": "3.0", "text": "@open-swe second request", "user": "U3"},
],
"3.0",
bot_user_id="UBOT",
bot_username="open-swe",
)
assert mode == "last_mention"
assert [item["ts"] for item in selected] == ["1.0", "2.0", "3.0"]