Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/bot/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions src/bot/utils/image_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<path>/[^\s`<>\"']+?\.(?:{_IMAGE_EXT_PATTERN}))",
re.IGNORECASE,
)
_QUOTED_IMAGE_RE = re.compile(
rf"[`'\"](?P<path>[^`'\"]+?\.(?:{_IMAGE_EXT_PATTERN}))[`'\"]",
re.IGNORECASE,
)
_BARE_IMAGE_RE = re.compile(
rf"(?<![\w/.-])(?P<path>[\w./-]+\.(?:{_IMAGE_EXT_PATTERN}))(?![\w/.-])",
re.IGNORECASE,
)


@dataclass
class ImageAttachment:
Expand Down Expand Up @@ -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().

Expand Down