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] <open-swe@users.noreply.github.com>
This commit is contained in:
Aran Yogesh 2026-04-29 17:42:27 -07:00 • committed by GitHub
parent 920a8a7624
commit b6ea229a46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 226 additions and 2 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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))