mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 23:13:15 +00:00
270 lines
11 KiB
Python
270 lines
11 KiB
Python
|
|
"""Slack webhook handler — moved out of webapp.py (behavior-identical).
|
||
|
|
|
||
|
|
Helpers and constants stay in webapp.py; they are accessed through the module
|
||
|
|
object (``webapp.X``) so tests that monkeypatch them keep working.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from langchain_core.messages.content import create_text_block
|
||
|
|
|
||
|
|
from agent import webapp
|
||
|
|
|
||
|
|
|
||
|
|
async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[str, str]) -> None:
|
||
|
|
"""Process a Slack app mention by creating a run or queuing a mid-run message."""
|
||
|
|
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:
|
||
|
|
webapp.logger.warning(
|
||
|
|
"Missing Slack event fields (channel_id=%s, thread_ts=%s, event_ts=%s)",
|
||
|
|
channel_id,
|
||
|
|
thread_ts,
|
||
|
|
event_ts,
|
||
|
|
)
|
||
|
|
return
|
||
|
|
|
||
|
|
await webapp.set_slack_assistant_status(channel_id, thread_ts)
|
||
|
|
|
||
|
|
thread_id = webapp.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
||
|
|
|
||
|
|
# Prime the user-mapping cache so login/email/slack-id lookups below are warm.
|
||
|
|
try:
|
||
|
|
await webapp.refresh_user_mapping_cache()
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
webapp.logger.debug("Could not refresh user mapping cache for Slack mention", exc_info=True)
|
||
|
|
|
||
|
|
user_email = None
|
||
|
|
user_name = ""
|
||
|
|
if user_id:
|
||
|
|
slack_user = await webapp.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 webapp.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 = webapp.select_slack_context_messages(
|
||
|
|
thread_messages, event_ts, bot_user_id, webapp.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 webapp.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 = webapp.format_slack_messages_for_prompt(
|
||
|
|
context_messages,
|
||
|
|
user_names_by_id,
|
||
|
|
bot_user_id=bot_user_id,
|
||
|
|
bot_username=webapp.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 = (
|
||
|
|
webapp.strip_bot_mention(text, bot_user_id, bot_username=webapp.SLACK_BOT_USERNAME)
|
||
|
|
or "(no text in mention)"
|
||
|
|
)
|
||
|
|
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 webapp.resolve_slack_links_in_context(
|
||
|
|
context_messages, user_names_by_id
|
||
|
|
)
|
||
|
|
|
||
|
|
prompt = (
|
||
|
|
"You were mentioned in Slack.\n\n"
|
||
|
|
"## Default Repository Hint\n"
|
||
|
|
f"{repo_config.get('owner')}/{repo_config.get('name')}\n"
|
||
|
|
"Use this only if the Slack conversation does not identify a different repository.\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"
|
||
|
|
+ (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)]
|
||
|
|
|
||
|
|
image_urls = webapp.dedupe_urls(
|
||
|
|
[url for msg in context_messages for url in webapp.extract_image_urls(msg.get("text", ""))]
|
||
|
|
+ [
|
||
|
|
f["url_private"]
|
||
|
|
for msg in context_messages
|
||
|
|
for f in msg.get("files", [])
|
||
|
|
if isinstance(f, dict)
|
||
|
|
and f.get("mimetype", "").startswith("image/")
|
||
|
|
and f.get("url_private")
|
||
|
|
]
|
||
|
|
+ image_urls_from_links
|
||
|
|
)
|
||
|
|
|
||
|
|
mapped_login = await webapp.login_for_slack_id(user_id)
|
||
|
|
if not mapped_login and user_email:
|
||
|
|
mapped_login = await webapp.login_for_email(user_email)
|
||
|
|
|
||
|
|
if image_urls:
|
||
|
|
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
|
||
|
|
if webapp.model_supports_images(resolved_model_id):
|
||
|
|
webapp.logger.info("Preparing %d image(s) for Slack mention", len(image_urls))
|
||
|
|
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as http_client:
|
||
|
|
for image_url in image_urls:
|
||
|
|
image_block = await webapp.fetch_image_block(image_url, http_client)
|
||
|
|
if image_block:
|
||
|
|
content_blocks.append(image_block)
|
||
|
|
else:
|
||
|
|
webapp.logger.warning(
|
||
|
|
"Skipping %d image(s) for Slack mention: model %s does not support images",
|
||
|
|
len(image_urls),
|
||
|
|
resolved_model_id,
|
||
|
|
)
|
||
|
|
prompt += webapp.vision_not_supported_warning(resolved_model_id, len(image_urls))
|
||
|
|
content_blocks[0] = create_text_block(prompt)
|
||
|
|
image_urls = []
|
||
|
|
|
||
|
|
# Open SWE opens PRs as the triggering user, so a run only proceeds when we
|
||
|
|
# have a valid user GitHub token. Users who have never signed in with
|
||
|
|
# GitHub, and users whose stored authorization is no longer usable, are
|
||
|
|
# blocked and prompted to set up via the dashboard. Bot-token-only
|
||
|
|
# deployments are exempt — they run on the installation token.
|
||
|
|
user_token: str | None = None
|
||
|
|
if mapped_login:
|
||
|
|
try:
|
||
|
|
user_token = await webapp.get_valid_access_token(mapped_login)
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
webapp.logger.debug(
|
||
|
|
"Failed to resolve GitHub token for %s; treating as unauthenticated",
|
||
|
|
mapped_login,
|
||
|
|
exc_info=True,
|
||
|
|
)
|
||
|
|
user_token = None
|
||
|
|
has_valid_user_token = bool(user_token)
|
||
|
|
|
||
|
|
if not has_valid_user_token and not webapp.is_bot_token_only_mode():
|
||
|
|
# A stored-but-unusable token means "sign in again"; no record at all
|
||
|
|
# means the user has never connected GitHub + Slack via the dashboard.
|
||
|
|
# Guard the store read like token resolution above so a transient
|
||
|
|
# failure still yields an actionable prompt and clears the status.
|
||
|
|
has_token_record = False
|
||
|
|
if mapped_login:
|
||
|
|
try:
|
||
|
|
has_token_record = await webapp.has_access_token_record(mapped_login)
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
webapp.logger.debug(
|
||
|
|
"Failed to check GitHub token record for %s; prompting sign-in",
|
||
|
|
mapped_login,
|
||
|
|
exc_info=True,
|
||
|
|
)
|
||
|
|
reason = "revoked" if has_token_record else "unlinked"
|
||
|
|
webapp.logger.info(
|
||
|
|
"Blocking Slack run for thread %s: no valid user GitHub token (%s)",
|
||
|
|
thread_id,
|
||
|
|
reason,
|
||
|
|
)
|
||
|
|
if user_id:
|
||
|
|
await webapp._post_account_link_prompt(
|
||
|
|
channel_id, thread_ts, user_id, user_email, reason=reason
|
||
|
|
)
|
||
|
|
await webapp.set_slack_assistant_status(channel_id, thread_ts, status="")
|
||
|
|
return
|
||
|
|
|
||
|
|
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",
|
||
|
|
}
|
||
|
|
if mapped_login:
|
||
|
|
configurable["github_login"] = mapped_login
|
||
|
|
|
||
|
|
thread_plan_mode = await webapp._get_thread_plan_mode(thread_id)
|
||
|
|
if thread_plan_mode is not None:
|
||
|
|
configurable["plan_mode"] = thread_plan_mode
|
||
|
|
|
||
|
|
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
||
|
|
is_first_mention = not await webapp._thread_exists(thread_id)
|
||
|
|
await webapp._upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
|
||
|
|
# Pass the login resolved above (from the stable Slack user id) so the thread is
|
||
|
|
# always tagged with github_login — the key the dashboard searches by. Without
|
||
|
|
# it, upsert re-resolves from the Slack profile email, which can miss.
|
||
|
|
await webapp.upsert_agent_thread_owner_metadata(
|
||
|
|
thread_id,
|
||
|
|
source="slack",
|
||
|
|
repo_config=repo_config,
|
||
|
|
github_login=mapped_login or "",
|
||
|
|
user_email=user_email or "",
|
||
|
|
title=clean_text if is_first_mention else "",
|
||
|
|
source_context={"slack_thread": configurable["slack_thread"]},
|
||
|
|
)
|
||
|
|
|
||
|
|
run = await webapp.dispatch_agent_run(
|
||
|
|
thread_id,
|
||
|
|
content_blocks,
|
||
|
|
configurable,
|
||
|
|
source="slack",
|
||
|
|
metadata=webapp._AGENT_VERSION_METADATA,
|
||
|
|
client=langgraph_client,
|
||
|
|
)
|
||
|
|
webapp.logger.info(
|
||
|
|
"Slack LangGraph run %s dispatched for thread %s",
|
||
|
|
webapp._run_id_for_logging(run),
|
||
|
|
thread_id,
|
||
|
|
)
|
||
|
|
run_id = run.get("run_id")
|
||
|
|
if is_first_mention:
|
||
|
|
trace_message_ts = await webapp.post_slack_trace_reply(channel_id, thread_ts, thread_id)
|
||
|
|
await webapp.set_slack_assistant_status(channel_id, thread_ts)
|
||
|
|
if isinstance(run_id, str) and run_id:
|
||
|
|
await webapp.store_slack_run_mapping(
|
||
|
|
langgraph_client,
|
||
|
|
channel_id,
|
||
|
|
thread_ts,
|
||
|
|
run_id,
|
||
|
|
message_ts=trace_message_ts,
|
||
|
|
triggering_user_id=user_id,
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
webapp.logger.info(
|
||
|
|
"Skipping Slack trace reply for thread %s — agent will reply when run completes",
|
||
|
|
thread_id,
|
||
|
|
)
|
||
|
|
if isinstance(run_id, str) and run_id:
|
||
|
|
await webapp.store_slack_run_mapping(
|
||
|
|
langgraph_client,
|
||
|
|
channel_id,
|
||
|
|
thread_ts,
|
||
|
|
run_id,
|
||
|
|
triggering_user_id=user_id,
|
||
|
|
)
|