From b6ea229a46120441c6211b6cb62139143aeea119 Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Wed, 29 Apr 2026 17:42:27 -0700 Subject: [PATCH] feat: give agent ability to read cross-posted Slack message links [closes OPE-37] (#1200) * feat: give agent ability to read cross-posted Slack message links [close OPE-37] * refactor: clean up Slack link resolution code * linting * refactor: address PR review feedback for Slack link resolution * linting --------- Co-authored-by: open-swe[bot] --- agent/server.py | 2 + agent/tools/__init__.py | 2 + agent/tools/slack_read_thread_messages.py | 52 +++++++ agent/utils/slack.py | 159 ++++++++++++++++++++++ agent/webapp.py | 13 +- 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 agent/tools/slack_read_thread_messages.py diff --git a/agent/server.py b/agent/server.py index f38abfea..6de3d440 100644 --- a/agent/server.py +++ b/agent/server.py @@ -52,6 +52,7 @@ from .tools import ( list_pr_review_comments, list_pr_reviews, list_repos, + slack_read_thread_messages, slack_thread_reply, submit_pr_review, update_pr_review, @@ -302,6 +303,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: linear_get_issue_comments, linear_list_teams, linear_update_issue, + slack_read_thread_messages, slack_thread_reply, github_comment, list_pr_reviews, diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index a7e6655e..d1351d54 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -20,6 +20,7 @@ from .linear_get_issue_comments import linear_get_issue_comments from .linear_list_teams import linear_list_teams from .linear_update_issue import linear_update_issue from .list_repos import list_repos +from .slack_read_thread_messages import slack_read_thread_messages from .slack_thread_reply import slack_thread_reply from .web_search import web_search @@ -42,6 +43,7 @@ __all__ = [ "linear_list_teams", "linear_update_issue", "list_repos", + "slack_read_thread_messages", "slack_thread_reply", "submit_pr_review", "update_pr_review", diff --git a/agent/tools/slack_read_thread_messages.py b/agent/tools/slack_read_thread_messages.py new file mode 100644 index 00000000..25a16979 --- /dev/null +++ b/agent/tools/slack_read_thread_messages.py @@ -0,0 +1,52 @@ +import asyncio +from typing import Any + +from ..utils.slack import ( + fetch_slack_thread_messages, + format_slack_messages_for_prompt, + get_slack_user_names, +) + + +async def _fetch_and_format(channel_id: str, message_ts: str) -> dict[str, Any]: + """Fetch thread messages and resolve author names.""" + messages = await fetch_slack_thread_messages(channel_id, message_ts) + if not messages: + return {"success": False, "messages": []} + + user_ids = [ + msg.get("user") for msg in messages if isinstance(msg.get("user"), str) and msg.get("user") + ] + user_names = await get_slack_user_names(user_ids) if user_ids else {} + + formatted = format_slack_messages_for_prompt(messages, user_names) + return {"success": True, "formatted": formatted, "count": len(messages)} + + +def slack_read_thread_messages(channel_id: str, message_ts: str) -> dict[str, Any]: + """Read messages from a Slack thread. + + Use this tool to read messages from a Slack channel or thread. + Provide the channel_id and message_ts (thread timestamp) to fetch all + messages in that thread. + + If you encounter a Slack message URL like + https://workspace.slack.com/archives/C0AME1J0/p1776281321762829 + you can extract the channel_id (C0AME1J0) and convert the timestamp + by inserting a dot 6 digits from the end (1776281321.762829). + + Returns formatted thread messages with author names.""" + if not channel_id or not channel_id.strip(): + return {"success": False, "error": "channel_id is required"} + if not message_ts or not message_ts.strip(): + return {"success": False, "error": "message_ts is required"} + + result = asyncio.run(_fetch_and_format(channel_id.strip(), message_ts.strip())) + if not result.get("success"): + return { + "success": False, + "error": "Could not fetch thread messages. The bot may not have access to " + "that channel, or the message may have been deleted.", + } + + return result diff --git a/agent/utils/slack.py b/agent/utils/slack.py index 96bf17b4..93cc94f1 100644 --- a/agent/utils/slack.py +++ b/agent/utils/slack.py @@ -365,6 +365,165 @@ async def fetch_slack_thread_messages(channel_id: str, thread_ts: str) -> list[d return messages +SLACK_MESSAGE_URL_RE = re.compile( + r"https?://[a-zA-Z0-9\-]+\.slack\.com/archives/([A-Za-z0-9]+)/p(\d{16})(?:\?[^\s>]*)?" +) + + +def parse_slack_message_url(url: str) -> tuple[str, str] | None: + """Parse a Slack message URL into (channel_id, message_ts). + + URL format: https://{workspace}.slack.com/archives/{channel_id}/p{ts_without_dot} + The 16-digit timestamp becomes {first_10}.{last_6} (e.g. p1776281321762829 -> 1776281321.762829). + """ + match = SLACK_MESSAGE_URL_RE.search(url) + if not match: + return None + channel_id = match.group(1) + raw_ts = match.group(2) + message_ts = f"{raw_ts[:10]}.{raw_ts[10:]}" + return channel_id, message_ts + + +def extract_slack_message_urls(text: str) -> list[tuple[str, str, str]]: + """Extract all Slack message URLs from text. + + Returns list of (full_url, channel_id, message_ts) tuples. + """ + results: list[tuple[str, str, str]] = [] + for match in SLACK_MESSAGE_URL_RE.finditer(text): + full_url = match.group(0) + parsed = parse_slack_message_url(full_url) + if parsed: + results.append((full_url, parsed[0], parsed[1])) + return results + + +async def fetch_slack_message_by_ts(channel_id: str, message_ts: str) -> dict[str, Any] | None: + """Fetch a single Slack message by channel and timestamp.""" + 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}/conversations.history", + headers=_slack_headers(), + params={ + "channel": channel_id, + "latest": message_ts, + "oldest": message_ts, + "inclusive": "true", + "limit": 1, + }, + ) + response.raise_for_status() + data = response.json() + if not data.get("ok"): + logger.warning( + "Slack conversations.history failed for channel=%s ts=%s: %s", + channel_id, + message_ts, + data.get("error"), + ) + return None + messages = data.get("messages", []) + if messages and isinstance(messages[0], dict): + return messages[0] + except httpx.HTTPError: + logger.exception( + "Slack conversations.history request failed for channel=%s ts=%s", + channel_id, + message_ts, + ) + return None + + +async def resolve_slack_message_url(url: str) -> dict[str, Any] | None: + """Resolve a Slack message URL to its message content. + + Returns a dict with keys: text, user, ts, channel_id, files, thread_ts (if threaded). + """ + parsed = parse_slack_message_url(url) + if not parsed: + return None + + channel_id, message_ts = parsed + message = await fetch_slack_message_by_ts(channel_id, message_ts) + if not message: + return None + + result: dict[str, Any] = { + "channel_id": channel_id, + "ts": message.get("ts", message_ts), + "text": message.get("text", ""), + "user": message.get("user", ""), + "files": message.get("files", []), + } + if message.get("thread_ts"): + result["thread_ts"] = message["thread_ts"] + return result + + +async def resolve_slack_links_in_context( + context_messages: list[dict[str, Any]], + user_names_by_id: dict[str, str], +) -> tuple[str, list[str]]: + """Resolve cross-posted Slack message links found in context messages. + + Returns (resolved_links_section, image_urls) where resolved_links_section + is a formatted markdown string for the prompt, and image_urls is a list + of image URLs from resolved message attachments. + """ + all_context_text = " ".join(msg.get("text", "") for msg in context_messages) + slack_links = extract_slack_message_urls(all_context_text) + if not slack_links: + return "", [] + + resolved_parts: list[str] = [] + image_urls: list[str] = [] + seen_urls: set[str] = set() + + for link_url, _cid, _ts in slack_links: + if link_url in seen_urls: + continue + seen_urls.add(link_url) + try: + resolved = await resolve_slack_message_url(link_url) + if resolved: + author_id = resolved.get("user", "") + author = user_names_by_id.get(author_id, author_id) + if author_id and author == author_id: + extra_names = await get_slack_user_names([author_id]) + author = extra_names.get(author_id, author_id) + resolved_text = resolved.get("text", "(empty message)") + resolved_parts.append( + f"**{link_url}**\n Author: {author}\n Message: {resolved_text}" + ) + for file_info in resolved.get("files", []): + if ( + isinstance(file_info, dict) + and file_info.get("mimetype", "").startswith("image/") + and file_info.get("url_private") + ): + image_urls.append(file_info["url_private"]) + else: + resolved_parts.append( + f"**{link_url}**\n (Could not fetch — bot may not have access)" + ) + except Exception: + logger.exception("Failed to resolve Slack link %s", link_url) + resolved_parts.append(f"**{link_url}**\n (Error resolving link)") + + resolved_links_section = "" + if resolved_parts: + resolved_links_section = "\n\n## Cross-posted Slack Messages\n" + "\n\n".join( + resolved_parts + ) + + return resolved_links_section, image_urls + + async def post_slack_trace_reply(channel_id: str, thread_ts: str, thread_id: str) -> None: """Post a trace URL reply in a Slack thread.""" trace_url = get_langsmith_trace_url(thread_id) diff --git a/agent/webapp.py b/agent/webapp.py index ce7262ae..7fc810c6 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -49,6 +49,7 @@ from .utils.slack import ( get_slack_user_info, get_slack_user_names, post_slack_trace_reply, + resolve_slack_links_in_context, select_slack_context_messages, strip_bot_mention, verify_slack_signature, @@ -753,6 +754,11 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st ) trigger_user = user_name or (f"<@{user_id}>" if user_id else "Unknown user") + # Auto-resolve cross-posted Slack message links in context + resolved_links_section, image_urls_from_links = await resolve_slack_links_in_context( + context_messages, user_names_by_id + ) + prompt = ( "You were mentioned in Slack.\n\n" f"## Repository\n{repo_config.get('owner')}/{repo_config.get('name')}\n\n" @@ -761,8 +767,10 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st 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." + + (f"{resolved_links_section}\n\n" if resolved_links_section else "") + + "Use `slack_thread_reply` to communicate in this Slack thread for clarifications, " + "status updates, and final summaries. Use `slack_read_thread_messages` to read any " + "Slack messages by providing channel_id and message_ts." ) content_blocks: list[dict[str, Any]] = [create_text_block(prompt)] @@ -776,6 +784,7 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st and f.get("mimetype", "").startswith("image/") and f.get("url_private") ] + + image_urls_from_links ) if image_urls: logger.info("Preparing %d image(s) for Slack mention", len(image_urls))