mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 01:03:26 +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
|
### 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:
|
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.
|
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.
|
3. **Verify** — Run tests and linters to confirm correctness before submitting.
|
||||||
4. **Submit** — Call `commit_and_open_pr`.
|
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):
|
For questions or status checks (no code changes needed):
|
||||||
|
|
||||||
1. **Answer** — Gather the information needed to respond.
|
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 = """---
|
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.
|
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`
|
#### `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 = """---
|
TOOL_BEST_PRACTICES_SECTION = """---
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,13 @@ from .middleware import (
|
||||||
open_pr_if_needed,
|
open_pr_if_needed,
|
||||||
)
|
)
|
||||||
from .prompt import construct_system_prompt
|
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.auth import save_encrypted_token_from_email
|
||||||
from .utils.model import make_model
|
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,
|
linear_issue_number=linear_issue_number,
|
||||||
agents_md=agents_md,
|
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,
|
backend=sandbox_backend,
|
||||||
middleware=[
|
middleware=[
|
||||||
ToolErrorMiddleware(),
|
ToolErrorMiddleware(),
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,12 @@ from .commit_and_open_pr import commit_and_open_pr
|
||||||
from .fetch_url import fetch_url
|
from .fetch_url import fetch_url
|
||||||
from .http_request import http_request
|
from .http_request import http_request
|
||||||
from .linear_comment import linear_comment
|
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 ..encryption import encrypt_token
|
||||||
from .linear import comment_on_linear_issue
|
from .linear import comment_on_linear_issue
|
||||||
|
from .slack import post_slack_thread_reply
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def get_service_jwt_token_for_user(
|
||||||
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|
@ -45,10 +70,10 @@ def get_service_jwt_token_for_user(
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"sub": user_id,
|
"sub": "unspecified",
|
||||||
"tenant_id": tenant_id,
|
|
||||||
"iat": datetime.now(UTC),
|
|
||||||
"exp": datetime.now(UTC) + timedelta(seconds=expiration_seconds),
|
"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")
|
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 = {
|
headers = {
|
||||||
"X-Service-Key": service_token,
|
"X-Service-Key": service_token,
|
||||||
"X-Tenant-Id": tenant_id,
|
"X-Tenant-Id": tenant_id,
|
||||||
|
"X-User-Id": ls_user_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
|
|
@ -162,9 +188,10 @@ async def leave_failure_comment(
|
||||||
message: str,
|
message: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Leave an auth failure comment for the appropriate source."""
|
"""Leave an auth failure comment for the appropriate source."""
|
||||||
|
config = get_config()
|
||||||
|
configurable = config.get("configurable", {})
|
||||||
|
|
||||||
if source == "linear":
|
if source == "linear":
|
||||||
config = get_config()
|
|
||||||
configurable = config.get("configurable", {})
|
|
||||||
linear_issue = configurable.get("linear_issue", {})
|
linear_issue = configurable.get("linear_issue", {})
|
||||||
issue_id = linear_issue.get("id") if isinstance(linear_issue, dict) else None
|
issue_id = linear_issue.get("id") if isinstance(linear_issue, dict) else None
|
||||||
if issue_id:
|
if issue_id:
|
||||||
|
|
@ -175,6 +202,18 @@ async def leave_failure_comment(
|
||||||
)
|
)
|
||||||
await comment_on_linear_issue(issue_id, message)
|
await comment_on_linear_issue(issue_id, message)
|
||||||
return
|
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}")
|
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")
|
ls_user_id = user_info.get("ls_user_id")
|
||||||
tenant_id = user_info.get("tenant_id")
|
tenant_id = user_info.get("tenant_id")
|
||||||
if not ls_user_id or not tenant_id:
|
if not ls_user_id or not tenant_id:
|
||||||
|
account_label = _source_account_label(source)
|
||||||
message = (
|
message = (
|
||||||
"🔐 **GitHub Authentication Required**\n\n"
|
"🔐 **GitHub Authentication Required**\n\n"
|
||||||
f"Could not find a LangSmith account for **{email}**.\n\n"
|
f"Could not find a LangSmith account for **{email}**.\n\n"
|
||||||
"Please ensure this email is invited to the main LangSmith organization. "
|
"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"
|
"you may need to update one of them to match.\n\n"
|
||||||
"Once your email is added to LangSmith, "
|
"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)
|
await leave_failure_comment(source, message)
|
||||||
raise ValueError(f"No ls_user_id found from email {email}")
|
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_result = await get_github_token_for_user(ls_user_id, tenant_id)
|
||||||
auth_url = auth_result.get("auth_url")
|
auth_url = auth_result.get("auth_url")
|
||||||
if auth_url:
|
if auth_url:
|
||||||
|
work_item_label = _work_item_label(source)
|
||||||
|
auth_link_text = _auth_link_text(source, auth_url)
|
||||||
message = (
|
message = (
|
||||||
"🔐 **GitHub Authentication Required**\n\n"
|
"🔐 **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"
|
"please authenticate with GitHub by clicking the link below:\n\n"
|
||||||
f"[Authenticate with GitHub]({auth_url})\n\n"
|
f"{auth_link_text}\n\n"
|
||||||
"Once authenticated, reply to this issue mentioning @openswe to retry."
|
f"{_retry_instruction(source)}"
|
||||||
)
|
)
|
||||||
await leave_failure_comment(source, message)
|
await leave_failure_comment(source, message)
|
||||||
raise ValueError("User not authenticated.")
|
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 json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -14,12 +15,28 @@ from langgraph_sdk import get_client
|
||||||
|
|
||||||
from .utils.comments import get_recent_comments
|
from .utils.comments import get_recent_comments
|
||||||
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "")
|
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 = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
||||||
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
"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:
|
async def is_thread_active(thread_id: str) -> bool:
|
||||||
"""Check if a thread is currently active (has a running run).
|
"""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)
|
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:
|
def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
|
||||||
"""Verify the Linear webhook signature.
|
"""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"}
|
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")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, str]:
|
async def health_check() -> dict[str, str]:
|
||||||
"""Health check endpoint."""
|
"""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