From 51b4b27c2e6b6da7e60faeada2f0706f2b0513b5 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Wed, 4 Mar 2026 16:43:28 -0800 Subject: [PATCH] feat: Add slack trigger webhook (#1006) * feat: Add slack trigger webhook * cr * better username tagging, react with eyes * cr * cr * cr --- apps/agent/agent/prompt.py | 14 +- apps/agent/agent/server.py | 10 +- apps/agent/agent/tools/__init__.py | 9 +- apps/agent/agent/tools/slack_thread_reply.py | 27 ++ apps/agent/agent/utils/auth.py | 62 +++- apps/agent/agent/utils/slack.py | 323 +++++++++++++++++++ apps/agent/agent/webapp.py | 252 +++++++++++++++ apps/agent/tests/test_auth_sources.py | 30 ++ apps/agent/tests/test_slack_context.py | 114 +++++++ 9 files changed, 824 insertions(+), 17 deletions(-) create mode 100644 apps/agent/agent/tools/slack_thread_reply.py create mode 100644 apps/agent/agent/utils/slack.py create mode 100644 apps/agent/tests/test_auth_sources.py create mode 100644 apps/agent/tests/test_slack_context.py diff --git a/apps/agent/agent/prompt.py b/apps/agent/agent/prompt.py index 3f14cb2d..79dfcf2d 100644 --- a/apps/agent/agent/prompt.py +++ b/apps/agent/agent/prompt.py @@ -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: . 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: . 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 = """--- diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index 5d2e96fb..9ed12307 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -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(), diff --git a/apps/agent/agent/tools/__init__.py b/apps/agent/agent/tools/__init__.py index 92f20643..e478edb5 100644 --- a/apps/agent/agent/tools/__init__.py +++ b/apps/agent/agent/tools/__init__.py @@ -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", +] diff --git a/apps/agent/agent/tools/slack_thread_reply.py b/apps/agent/agent/tools/slack_thread_reply.py new file mode 100644 index 00000000..efd3644e --- /dev/null +++ b/apps/agent/agent/tools/slack_thread_reply.py @@ -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} diff --git a/apps/agent/agent/utils/auth.py b/apps/agent/agent/utils/auth.py index 9077547d..0420d4af 100644 --- a/apps/agent/agent/utils/auth.py +++ b/apps/agent/agent/utils/auth.py @@ -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.") diff --git a/apps/agent/agent/utils/slack.py b/apps/agent/agent/utils/slack.py new file mode 100644 index 00000000..b5cda60b --- /dev/null +++ b/apps/agent/agent/utils/slack.py @@ -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 diff --git a/apps/agent/agent/webapp.py b/apps/agent/agent/webapp.py index 7c14877f..4aa00bf5 100644 --- a/apps/agent/agent/webapp.py +++ b/apps/agent/agent/webapp.py @@ -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.""" diff --git a/apps/agent/tests/test_auth_sources.py b/apps/agent/tests/test_auth_sources.py new file mode 100644 index 00000000..90bf6270 --- /dev/null +++ b/apps/agent/tests/test_auth_sources.py @@ -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"} diff --git a/apps/agent/tests/test_slack_context.py b/apps/agent/tests/test_slack_context.py new file mode 100644 index 00000000..c6e4342c --- /dev/null +++ b/apps/agent/tests/test_slack_context.py @@ -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"]