From f79e824d8ed761fb47192778e269aa288501833d Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Fri, 20 Mar 2026 14:34:14 -0700 Subject: [PATCH] feat: add Slack image support with url_private auth and content-type validation [closes: OPE-23] (#1073) * feat: add Slack image support with url_private auth and content-type validation * fix: simplify Slack image fetch by removing manual redirect logic * fix: use urlparse hostname check to resolve CodeQL URL sanitization warning * Update agent/utils/multimodal.py Co-authored-by: Brace Sproul * fix: simplify host matching conditions in multimodal image fetching * reverting changes --------- Co-authored-by: Brace Sproul --- agent/utils/multimodal.py | 24 ++++++++++++++++++++++-- agent/webapp.py | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/agent/utils/multimodal.py b/agent/utils/multimodal.py index bc2b7ff6..0cbc885c 100644 --- a/agent/utils/multimodal.py +++ b/agent/utils/multimodal.py @@ -8,6 +8,7 @@ import mimetypes import os import re from typing import Any +from urllib.parse import urlparse import httpx from langchain_core.messages.content import create_image_block @@ -44,7 +45,8 @@ async def fetch_image_block( try: logger.debug("Fetching image from %s", image_url) headers = None - if "uploads.linear.app" in image_url: + host = (urlparse(image_url).hostname or "").lower() + if host == "uploads.linear.app" or host.endswith(".uploads.linear.app"): linear_api_key = os.environ.get("LINEAR_API_KEY", "") if linear_api_key: headers = {"Authorization": linear_api_key} @@ -53,7 +55,16 @@ async def fetch_image_block( "LINEAR_API_KEY not set; cannot authenticate image fetch for %s", image_url, ) - response = await client.get(image_url, headers=headers) + elif host == "files.slack.com" or host.endswith(".files.slack.com"): + slack_bot_token = os.environ.get("SLACK_BOT_TOKEN", "") + if slack_bot_token: + headers = {"Authorization": f"Bearer {slack_bot_token}"} + else: + logger.warning( + "SLACK_BOT_TOKEN not set; cannot authenticate image fetch for %s", + image_url, + ) + response = await client.get(image_url, headers=headers, follow_redirects=True) response.raise_for_status() content_type = response.headers.get("Content-Type", "").split(";")[0].strip() if not content_type: @@ -66,6 +77,15 @@ async def fetch_image_block( return None content_type = guessed + supported_types = {"image/jpeg", "image/png", "image/gif", "image/webp"} + if content_type not in supported_types: + logger.warning( + "Unsupported content type '%s' for %s; skipping image", + content_type, + image_url, + ) + return None + encoded = base64.b64encode(response.content).decode("ascii") logger.info( "Fetched image %s (%s, %d bytes)", diff --git a/agent/webapp.py b/agent/webapp.py index c8a7f958..6eda31f3 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -771,6 +771,25 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st ) content_blocks: list[dict[str, Any]] = [create_text_block(prompt)] + image_urls = dedupe_urls( + [url for msg in context_messages for url in 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") + ] + ) + if image_urls: + logger.info("Preparing %d image(s) for Slack mention", len(image_urls)) + async with httpx.AsyncClient() as http_client: + for image_url in image_urls: + image_block = await fetch_image_block(image_url, http_client) + if image_block: + content_blocks.append(image_block) + configurable: dict[str, Any] = { "repo": repo_config, "slack_thread": {