Skip to content
Closed
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
119 changes: 119 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,114 @@ def cache_document_from_bytes(data: bytes, filename: str) -> str:
return str(filepath)


# ---------------------------------------------------------------------------
# Per-chat inbound attachment cache
#
# In addition to the type-specific caches above (images / documents / audio /
# video), every inbound media file is also mirrored into a chat-scoped
# attachment directory so:
#
# * The agent receives a stable, tool-accessible local file path even when
# the native vision path swallows the URL into pixels (issue #20899).
# * Two users in different chats can never read each other's cached media
# via shared paths (cross-tenant safety): the path is keyed on
# ``platform / chat_id / message_id``.
#
# Layout (profile-aware via get_hermes_dir):
# <HERMES_HOME>/cache/attachments/<platform>/<chat_id>/<message_id>/<filename>
#
# Retention: files are left in place. A periodic GC hook can be added later
# (mirroring ``cleanup_document_cache``); we deliberately do not silently
# delete user-provided media here.
# ---------------------------------------------------------------------------

ATTACHMENT_CACHE_DIR = get_hermes_dir("cache/attachments", "attachment_cache")


def get_attachment_cache_dir() -> Path:
"""Return the inbound-attachment cache directory, creating it if needed."""
ATTACHMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
return ATTACHMENT_CACHE_DIR


_ATTACHMENT_PATH_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")


def _sanitize_attachment_segment(segment: Any, fallback: str = "unknown") -> str:
"""Sanitize a path segment for use under the attachment cache.

Strips directory separators, control characters, and anything outside the
``[A-Za-z0-9._-]`` charset. Empty / dot-only results fall back to *fallback*.
"""
if segment is None:
return fallback
seg = str(segment).strip().replace("\x00", "")
seg = _ATTACHMENT_PATH_SAFE_RE.sub("_", seg)
seg = seg.strip("._-")
if not seg or seg in (".", ".."):
return fallback
# Cap length so deeply nested IDs don't blow past filesystem limits.
return seg[:128]


def cache_inbound_attachment(
data: bytes,
*,
platform: str,
chat_id: Any,
message_id: Any,
filename: str,
) -> str:
"""Persist inbound media into a per-chat attachment directory.

Args:
data: Raw file bytes.
platform: Platform name (``"telegram"``, ``"discord"``, ...).
chat_id: Platform-specific chat / room / DM id.
message_id: Platform-specific message id (or update id) to scope the
attachment so multiple messages from the same chat can't collide.
filename: Original / human-readable filename. Sanitized; directory
components are stripped.

Returns:
Absolute path to the cached file.

Raises:
ValueError: If the resolved path would escape the attachment cache root.
"""
base = get_attachment_cache_dir()
plat_seg = _sanitize_attachment_segment(platform, "unknown_platform")
chat_seg = _sanitize_attachment_segment(chat_id, "unknown_chat")
msg_seg = _sanitize_attachment_segment(message_id, "unknown_msg")

# Filename sanitation: keep only the basename, then make it filesystem-safe.
raw_name = Path(filename).name if filename else "attachment"
safe_name = _ATTACHMENT_PATH_SAFE_RE.sub("_", raw_name).strip("._-")
if not safe_name or safe_name in (".", ".."):
safe_name = "attachment"
safe_name = safe_name[:160]

target_dir = base / plat_seg / chat_seg / msg_seg
target_dir.mkdir(parents=True, exist_ok=True)

filepath = target_dir / safe_name
# If the same message id ships multiple files with identical names (rare,
# but possible for albums where the client supplies no filename),
# disambiguate with a short uuid suffix.
if filepath.exists():
stem, dot, ext = safe_name.partition(".")
suffix = uuid.uuid4().hex[:8]
disambig = f"{stem}_{suffix}{dot}{ext}" if dot else f"{safe_name}_{suffix}"
filepath = target_dir / disambig

# Final containment check (defence in depth against weird unicode etc.).
if not filepath.resolve().is_relative_to(base.resolve()):
raise ValueError(f"Path traversal rejected for attachment: {filename!r}")

filepath.write_bytes(data)
return str(filepath)


def cleanup_document_cache(max_age_hours: int = 24) -> int:
"""
Delete cached documents older than *max_age_hours*.
Expand Down Expand Up @@ -897,6 +1005,17 @@ class MessageEvent:
# media_urls: local file paths (for vision tool access)
media_urls: List[str] = field(default_factory=list)
media_types: List[str] = field(default_factory=list)

# Tool-accessible inbound attachments (issue #20899). One dict per inbound
# file. Each dict has at minimum:
# {"path": str, "filename": str, "mime_type": str,
# "size": int, "platform": str, "message_id": str | None,
# "chat_id": str | None}
# These paths live under <HERMES_HOME>/cache/attachments/<platform>/<chat>/<msg>/
# and are safe to hand directly to file/terminal tools. Distinct from
# ``media_urls`` (which targets the vision pipeline and may collapse to a
# single shared image cache) so cross-chat isolation is preserved here.
attachments: List[Dict[str, Any]] = field(default_factory=list)

# Reply context
reply_to_message_id: Optional[str] = None
Expand Down
71 changes: 71 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class _MockContextTypes:
cache_audio_from_bytes,
cache_video_from_bytes,
cache_document_from_bytes,
cache_inbound_attachment,
resolve_proxy_url,
SUPPORTED_VIDEO_TYPES,
SUPPORTED_DOCUMENT_TYPES,
Expand Down Expand Up @@ -3111,6 +3112,10 @@ def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None:
else:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
try:
existing.attachments.extend(event.attachments)
except AttributeError:
pass
if event.text:
existing.text = self._merge_caption(existing.text, event.text)

Expand Down Expand Up @@ -3177,6 +3182,36 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=ext)
event.media_urls = [cached_path]
event.media_types = [f"image/{ext.lstrip('.')}" ]

# Also mirror into the per-chat attachment cache so the agent
# has a tool-accessible local path even when the model routes
# this image into native vision (which otherwise hides the
# path from file/terminal tools). See issue #20899.
try:
raw_bytes = bytes(image_bytes)
attach_filename = f"photo_{msg.message_id}{ext}"
attach_path = cache_inbound_attachment(
raw_bytes,
platform="telegram",
chat_id=msg.chat.id,
message_id=msg.message_id,
filename=attach_filename,
)
event.attachments.append({
"path": attach_path,
"filename": attach_filename,
"mime_type": f"image/{ext.lstrip('.')}",
"size": len(raw_bytes),
"platform": "telegram",
"message_id": str(msg.message_id) if msg.message_id is not None else None,
"chat_id": str(msg.chat.id) if msg.chat and msg.chat.id is not None else None,
})
except Exception:
logger.warning(
"[Telegram] Failed to mirror photo into attachment cache",
exc_info=True,
)

logger.info("[Telegram] Cached user photo at %s", cached_path)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
Expand Down Expand Up @@ -3289,6 +3324,36 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
mime_type = SUPPORTED_DOCUMENT_TYPES[ext]
event.media_urls = [cached_path]
event.media_types = [mime_type]

# Also mirror into the per-chat attachment cache (issue #20899).
# Documents already reach the agent via the message-text
# injection in gateway/run.py, but pinning a chat-scoped copy
# here keeps audit trails per-chat and makes cross-tenant
# leakage structurally impossible.
try:
attach_filename = original_filename or f"document{ext}"
attach_path = cache_inbound_attachment(
raw_bytes,
platform="telegram",
chat_id=msg.chat.id,
message_id=msg.message_id,
filename=attach_filename,
)
event.attachments.append({
"path": attach_path,
"filename": attach_filename,
"mime_type": mime_type,
"size": len(raw_bytes),
"platform": "telegram",
"message_id": str(msg.message_id) if msg.message_id is not None else None,
"chat_id": str(msg.chat.id) if msg.chat and msg.chat.id is not None else None,
})
except Exception:
logger.warning(
"[Telegram] Failed to mirror document into attachment cache",
exc_info=True,
)

logger.info("[Telegram] Cached user document at %s", cached_path)

# For text files, inject content into event.text (capped at 100 KB)
Expand Down Expand Up @@ -3333,6 +3398,12 @@ async def _queue_media_group_event(self, media_group_id: str, event: MessageEven
else:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
# Merge tool-accessible attachment metadata too (issue #20899) so
# albums surface every file to the agent, not just the first.
try:
existing.attachments.extend(event.attachments)
except AttributeError:
pass
if event.text:
existing.text = self._merge_caption(existing.text, event.text)

Expand Down
36 changes: 36 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5755,6 +5755,42 @@ async def _prepare_inbound_message_text(
except Exception:
pass

# Surface tool-accessible attachment paths to the agent (issue #20899).
# Native vision routing hides image file paths inside multimodal pixel
# payloads, so file/terminal tools can't see them. Prepend a structured
# ATTACHMENT block listing local cached paths for every inbound file.
# The Telegram document branch below adds richer per-doc context for
# documents specifically; this block ensures *every* attachment
# (especially photos) is at least discoverable as a file path.
attachments_meta = list(getattr(event, "attachments", None) or [])
if attachments_meta:
lines = []
for att in attachments_meta:
path = att.get("path")
if not path:
continue
fname = att.get("filename") or os.path.basename(path)
mime = att.get("mime_type") or ""
size = att.get("size")
size_str = ""
if isinstance(size, int) and size > 0:
if size >= 1024 * 1024:
size_str = f", {size / (1024 * 1024):.1f} MB"
elif size >= 1024:
size_str = f", {size / 1024:.1f} KB"
else:
size_str = f", {size} B"
meta = f" ({mime}{size_str})" if mime or size_str else ""
lines.append(f"- {fname}{meta}: {path}")
if lines:
header = (
"[Inbound attachments cached locally — use file/terminal "
"tools (read, copy, move) on these absolute paths if the "
"user asks you to save, organize, or process them]:"
)
attachment_note = header + "\n" + "\n".join(lines)
message_text = f"{attachment_note}\n\n{message_text}" if message_text else attachment_note

if event.media_urls and event.message_type == MessageType.DOCUMENT:
import mimetypes as _mimetypes

Expand Down
Loading
Loading