mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
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:
parent
920a8a7624
commit
b6ea229a46
5 changed files with 226 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
52
agent/tools/slack_read_thread_messages.py
Normal file
52
agent/tools/slack_read_thread_messages.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue