From dbaab75cb70f241f268e3dca6ab2da2bcfe07559 Mon Sep 17 00:00:00 2001 From: keeendaaa <122747995+keeendaaa@users.noreply.github.com> Date: Sun, 10 May 2026 01:40:55 +0500 Subject: [PATCH] send image paths mentioned by agent --- src/bot/orchestrator.py | 15 +++++- src/bot/utils/image_extractor.py | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 6d9719f0..625edb2a 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -36,6 +36,7 @@ from .utils.html_format import escape_html from .utils.image_extractor import ( ImageAttachment, + extract_image_paths_from_text, should_send_as_photo, validate_image_path, ) @@ -1079,8 +1080,18 @@ async def agentic_text( except Exception: logger.debug("Failed to delete progress message, ignoring") - # Use MCP-collected images (from send_image_to_user tool calls) - images: List[ImageAttachment] = mcp_images + # Use MCP-collected images and image paths mentioned in the agent text. + images: List[ImageAttachment] = list(mcp_images) + if success: + extracted_images = extract_image_paths_from_text( + claude_response.content, + self.settings.approved_directory, + current_dir, + ) + known_paths = {img.path for img in images} + images.extend( + img for img in extracted_images if img.path not in known_paths + ) # Try to combine text + images in one message when possible caption_sent = False diff --git a/src/bot/utils/image_extractor.py b/src/bot/utils/image_extractor.py index 403097c5..8765a152 100644 --- a/src/bot/utils/image_extractor.py +++ b/src/bot/utils/image_extractor.py @@ -5,6 +5,7 @@ :class:`ImageAttachment` objects for later Telegram delivery. """ +import re from dataclasses import dataclass from pathlib import Path from typing import Optional @@ -32,6 +33,20 @@ MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB PHOTO_SIZE_LIMIT = 10 * 1024 * 1024 # 10 MB — Telegram photo API limit +_IMAGE_EXT_PATTERN = "png|jpe?g|gif|webp|bmp|svg" +_ABSOLUTE_IMAGE_RE = re.compile( + rf"(?P/[^\s`<>\"']+?\.(?:{_IMAGE_EXT_PATTERN}))", + re.IGNORECASE, +) +_QUOTED_IMAGE_RE = re.compile( + rf"[`'\"](?P[^`'\"]+?\.(?:{_IMAGE_EXT_PATTERN}))[`'\"]", + re.IGNORECASE, +) +_BARE_IMAGE_RE = re.compile( + rf"(?[\w./-]+\.(?:{_IMAGE_EXT_PATTERN}))(?![\w/.-])", + re.IGNORECASE, +) + @dataclass class ImageAttachment: @@ -93,6 +108,71 @@ def validate_image_path( return None +def extract_image_paths_from_text( + text: str, + approved_directory: Path, + working_directory: Path, + limit: int = MAX_IMAGES_PER_RESPONSE, +) -> list[ImageAttachment]: + """Extract existing image paths from agent text for Telegram delivery. + + OpenCode may answer with a path like ``/workspace/foo.png`` or simply + ``foo.png``. Only existing image files inside *approved_directory* are + returned. Bare filenames are resolved relative to *working_directory*, then + searched recursively under it as a convenience for prompts like "send it". + """ + if not text.strip(): + return [] + + approved_directory = approved_directory.resolve() + working_directory = working_directory.resolve() + candidates: list[str] = [] + + for regex in (_ABSOLUTE_IMAGE_RE, _QUOTED_IMAGE_RE, _BARE_IMAGE_RE): + for match in regex.finditer(text): + candidate = match.group("path").strip().rstrip(".,;:)]}") + if candidate and candidate not in candidates: + candidates.append(candidate) + + images: list[ImageAttachment] = [] + seen: set[Path] = set() + + def add_candidate(path: Path, reference: str) -> None: + if len(images) >= limit: + return + img = validate_image_path(str(path), approved_directory, reference) + if img and img.path not in seen: + seen.add(img.path) + images.append(img) + + for candidate in candidates: + path = Path(candidate) + if path.is_absolute(): + add_candidate(path, candidate) + continue + + direct_path = working_directory / path + add_candidate(direct_path, candidate) + if len(images) >= limit: + break + + if path.parent == Path(".") and not direct_path.is_file(): + try: + for match in working_directory.rglob(path.name): + add_candidate(match, candidate) + if len(images) >= limit: + break + except OSError as e: + logger.debug( + "Image filename search failed", + filename=path.name, + working_directory=str(working_directory), + error=str(e), + ) + + return images + + def should_send_as_photo(path: Path) -> bool: """Return True if the image should be sent via reply_photo().