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
8 changes: 4 additions & 4 deletions src/bot/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,13 @@ async def handle_text_message(

# Enhanced stream updates handler with progress tracking
async def stream_handler(update_obj):
# Intercept send_image_to_user MCP tool calls.
# Intercept send_file_to_user / send_image_to_user MCP tool calls.
# The SDK namespaces MCP tools as "mcp__<server>__<tool>".
if update_obj.tool_calls:
for tc in update_obj.tool_calls:
tc_name = tc.get("name", "")
if tc_name == "send_image_to_user" or tc_name.endswith(
"__send_image_to_user"
if tc_name in ("send_file_to_user", "send_image_to_user") or tc_name.endswith(
("__send_file_to_user", "__send_image_to_user")
):
tc_input = tc.get("input", {})
file_path = tc_input.get("file_path", "")
Expand Down Expand Up @@ -439,7 +439,7 @@ async def stream_handler(update_obj):
# Delete progress message
await progress_msg.delete()

# Use MCP-collected images (from send_image_to_user tool calls)
# Use MCP-collected files (from send_file_to_user tool calls)
images: list[ImageAttachment] = mcp_images

# Try to combine text + images when response fits in a caption
Expand Down
633 changes: 458 additions & 175 deletions src/bot/orchestrator.py

Large diffs are not rendered by default.

52 changes: 33 additions & 19 deletions src/bot/utils/image_extractor.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Validate image file paths and prepare them for Telegram delivery.
"""Validate file paths and prepare them for Telegram delivery.

Used by the MCP ``send_image_to_user`` tool intercept — the stream callback
validates each path via :func:`validate_image_path` and collects
:class:`ImageAttachment` objects for later Telegram delivery.
Used by the MCP ``send_file_to_user`` tool intercept — the stream callback
validates each path via :func:`validate_file_path` and collects
:class:`FileAttachment` objects for later Telegram delivery.

Backwards-compatible aliases (:class:`ImageAttachment`,
:func:`validate_image_path`) are kept so existing code continues to work.
"""

import mimetypes
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
Expand All @@ -28,29 +32,37 @@
TELEGRAM_PHOTO_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}

# Safety caps
MAX_IMAGES_PER_RESPONSE = 10
MAX_FILES_PER_RESPONSE = 10
MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB
PHOTO_SIZE_LIMIT = 10 * 1024 * 1024 # 10 MB — Telegram photo API limit

# Backwards-compat alias
MAX_IMAGES_PER_RESPONSE = MAX_FILES_PER_RESPONSE


@dataclass
class ImageAttachment:
"""An image file to attach to a Telegram response."""
class FileAttachment:
"""A file to attach to a Telegram response."""

path: Path
mime_type: str
original_reference: str


def validate_image_path(
# Backwards-compat alias
ImageAttachment = FileAttachment


def validate_file_path(
file_path: str,
approved_directory: Path,
caption: str = "",
) -> Optional[ImageAttachment]:
"""Validate a single image path from an MCP ``send_image_to_user`` call.
) -> Optional[FileAttachment]:
"""Validate a file path from an MCP ``send_file_to_user`` call.

Returns an :class:`ImageAttachment` if the path is a valid, existing image
Returns a :class:`FileAttachment` if the path is a valid, existing file
inside *approved_directory*, or ``None`` otherwise.
Accepts **any** file type (images, PDFs, audio, etc.).
"""
try:
path = Path(file_path)
Expand All @@ -64,7 +76,7 @@ def validate_image_path(
resolved.relative_to(approved_directory.resolve())
except ValueError:
logger.debug(
"MCP image path outside approved directory",
"MCP file path outside approved directory",
path=str(resolved),
approved=str(approved_directory),
)
Expand All @@ -75,28 +87,30 @@ def validate_image_path(

file_size = resolved.stat().st_size
if file_size > MAX_FILE_SIZE_BYTES:
logger.debug("MCP image file too large", path=str(resolved), size=file_size)
logger.debug("MCP file too large", path=str(resolved), size=file_size)
return None

ext = resolved.suffix.lower()
mime_type = IMAGE_EXTENSIONS.get(ext)
if not mime_type:
return None
mime_type = IMAGE_EXTENSIONS.get(ext) or mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"

return ImageAttachment(
return FileAttachment(
path=resolved,
mime_type=mime_type,
original_reference=caption or file_path,
)
except (OSError, ValueError) as e:
logger.debug("MCP image path validation failed", path=file_path, error=str(e))
logger.debug("MCP file path validation failed", path=file_path, error=str(e))
return None


# Backwards-compat alias
validate_image_path = validate_file_path


def should_send_as_photo(path: Path) -> bool:
"""Return True if the image should be sent via reply_photo().

Raster images 10 MB are sent as photos (inline preview).
Raster images <= 10 MB are sent as photos (inline preview).
SVGs and large files are sent as documents.
"""
ext = path.suffix.lower()
Expand Down
Loading