mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
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:
parent
b5951413d7
commit
51b4b27c2e
9 changed files with 824 additions and 17 deletions
|
|
@ -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 = """---
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
27
apps/agent/agent/tools/slack_thread_reply.py
Normal file
27
apps/agent/agent/tools/slack_thread_reply.py
Normal 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}
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
323
apps/agent/agent/utils/slack.py
Normal file
323
apps/agent/agent/utils/slack.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
30
apps/agent/tests/test_auth_sources.py
Normal file
30
apps/agent/tests/test_auth_sources.py
Normal 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"}
|
||||
114
apps/agent/tests/test_slack_context.py
Normal file
114
apps/agent/tests/test_slack_context.py
Normal 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"]
|
||||
Loading…
Add table
Reference in a new issue