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
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ RUN uv venv && \
uv pip install --no-cache-dir -e ".[all]"

# ---------- Runtime ----------
# Start as root so entrypoint.sh can chown volume mounts before gosu-dropping
# to the hermes user. Required for Railway-style managed volume mounts.
USER root
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
ENV HERMES_HOME=/opt/data
VOLUME [ "/opt/data" ]
ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ]
31 changes: 31 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,35 @@ if [ -d "$INSTALL_DIR/skills" ]; then
python3 "$INSTALL_DIR/tools/skills_sync.py"
fi

# Apply HERMES_MODEL env var to config.yaml. The Telegram/Discord gateway
# reads model.default from config.yaml only (see gateway/run.py
# _resolve_gateway_model), so HERMES_MODEL must be propagated here for
# the env var to take effect for messaging gateways.
if [ -n "$HERMES_MODEL" ] && [ -f "$HERMES_HOME/config.yaml" ]; then
python3 - <<'PYEOF'
import os, sys
try:
import yaml
except Exception as e:
print(f"entrypoint: yaml unavailable ({e}); skipping HERMES_MODEL apply")
sys.exit(0)
path = os.path.join(os.environ["HERMES_HOME"], "config.yaml")
target = os.environ["HERMES_MODEL"]
with open(path) as f:
cfg = yaml.safe_load(f) or {}
m = cfg.get("model")
if isinstance(m, str):
cfg["model"] = {"default": target}
elif isinstance(m, dict):
if m.get("default") == target:
sys.exit(0)
m["default"] = target
else:
cfg["model"] = {"default": target}
with open(path, "w") as f:
yaml.safe_dump(cfg, f, sort_keys=False, allow_unicode=True)
print(f"entrypoint: updated config.yaml model.default -> {target}")
PYEOF
fi

exec hermes "$@"
8 changes: 8 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,14 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
".avi": "video/x-msvideo",
}

SUPPORTED_IMAGE_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}


def get_video_cache_dir() -> Path:
"""Return the video cache directory, creating it if it doesn't exist."""
Expand Down
35 changes: 35 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class _MockContextTypes:
resolve_proxy_url,
SUPPORTED_VIDEO_TYPES,
SUPPORTED_DOCUMENT_TYPES,
SUPPORTED_IMAGE_TYPES,
utf16_len,
_prefix_within_utf16_limit,
)
Expand Down Expand Up @@ -2674,6 +2675,10 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
ext = video_mime_to_ext.get(doc.mime_type, "")

if not ext and doc.mime_type:
image_mime_to_ext = {v: k for k, v in SUPPORTED_IMAGE_TYPES.items()}
ext = image_mime_to_ext.get(doc.mime_type.lower(), "")

if ext in SUPPORTED_VIDEO_TYPES:
file_obj = await doc.get_file()
video_bytes = await file_obj.download_as_bytearray()
Expand All @@ -2685,6 +2690,36 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
await self.handle_message(event)
return

# Image documents (PNG/JPG/WebP/GIF uploaded "as file" rather
# than "as photo") — route through the image cache so the
# vision tool can see them, just like native photos.
if ext in SUPPORTED_IMAGE_TYPES:
MAX_IMAGE_DOC_BYTES = 20 * 1024 * 1024
if not doc.file_size or doc.file_size > MAX_IMAGE_DOC_BYTES:
event.text = (
"The image is too large or its size could not be verified. "
"Maximum: 20 MB."
)
logger.info("[Telegram] Image document too large: %s bytes", doc.file_size)
await self.handle_message(event)
return

file_obj = await doc.get_file()
image_bytes = await file_obj.download_as_bytearray()
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=ext)
event.media_urls = [cached_path]
event.media_types = [SUPPORTED_IMAGE_TYPES[ext]]
event.message_type = MessageType.PHOTO
logger.info("[Telegram] Cached user image document at %s", cached_path)

media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
await self._queue_media_group_event(str(media_group_id), event)
else:
batch_key = self._photo_batch_key(event, msg)
self._enqueue_photo_event(batch_key, event)
return

# Check if supported
if ext not in SUPPORTED_DOCUMENT_TYPES:
supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys()))
Expand Down
124 changes: 124 additions & 0 deletions tests/gateway/test_telegram_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
MessageType,
SendResult,
SUPPORTED_DOCUMENT_TYPES,
SUPPORTED_IMAGE_TYPES,
SUPPORTED_VIDEO_TYPES,
)

Expand Down Expand Up @@ -146,6 +147,9 @@ def _redirect_cache(tmp_path, monkeypatch):
monkeypatch.setattr(
"gateway.platforms.base.VIDEO_CACHE_DIR", tmp_path / "video_cache"
)
monkeypatch.setattr(
"gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "image_cache"
)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -389,6 +393,126 @@ async def test_mp4_document_is_treated_as_video(self, adapter):
assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]


class TestImageDocumentHandling:
"""PNG/JPG/WebP/GIF uploaded as Telegram 'document' (i.e. unchecked
'send as file' in TG) should be routed to the image cache like native
photos, NOT rejected as 'unsupported document type'."""

@pytest.mark.asyncio
async def test_png_document_is_treated_as_image(self, adapter):
file_obj = _make_file_obj(b"\x89PNG\r\n\x1a\nfake-png-bytes")
doc = _make_document(
file_name="screenshot.png", mime_type="image/png",
file_size=128, file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)

with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/img.png"):
await adapter._handle_media_message(update, MagicMock())
await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.message_type == MessageType.PHOTO
assert event.media_urls == ["/tmp/img.png"]
assert event.media_types == ["image/png"]
# Make sure we didn't rejection-message the user
assert "Unsupported" not in (event.text or "")

@pytest.mark.asyncio
async def test_jpeg_document_is_treated_as_image(self, adapter):
file_obj = _make_file_obj(b"\xff\xd8\xff\xe0fake-jpeg")
doc = _make_document(
file_name="photo.jpeg", mime_type="image/jpeg",
file_size=64, file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)

with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/img.jpeg"):
await adapter._handle_media_message(update, MagicMock())
await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

event = adapter.handle_message.await_args.args[0]
assert event.message_type == MessageType.PHOTO
assert event.media_types == ["image/jpeg"]

@pytest.mark.asyncio
async def test_webp_document_is_treated_as_image(self, adapter):
file_obj = _make_file_obj(b"RIFFfake-webp")
doc = _make_document(
file_name="sticker.webp", mime_type="image/webp",
file_size=64, file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)

with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/img.webp"):
await adapter._handle_media_message(update, MagicMock())
await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

event = adapter.handle_message.await_args.args[0]
assert event.message_type == MessageType.PHOTO
assert event.media_types == ["image/webp"]

@pytest.mark.asyncio
async def test_image_doc_resolved_via_mime_when_filename_missing(self, adapter):
"""No filename, MIME alone should resolve png extension."""
file_obj = _make_file_obj(b"fake-png")
doc = _make_document(
file_name=None, mime_type="image/png",
file_size=64, file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)

with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/img.png"):
await adapter._handle_media_message(update, MagicMock())
await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

event = adapter.handle_message.await_args.args[0]
assert event.message_type == MessageType.PHOTO
assert event.media_types == ["image/png"]

@pytest.mark.asyncio
async def test_oversized_image_doc_rejected(self, adapter):
doc = _make_document(
file_name="huge.png", mime_type="image/png",
file_size=25 * 1024 * 1024,
)
msg = _make_message(document=doc)
update = _make_update(msg)

await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert "too large" in event.text

@pytest.mark.asyncio
async def test_image_doc_album_buffered_with_other_photos(self, adapter):
"""A PNG-as-document and a native photo in the same media group
should be buffered together in one event."""
photo_file = _make_file_obj(b"native-photo")
png_file = _make_file_obj(b"\x89PNGfake")
png_doc = _make_document(
file_name="img.png", mime_type="image/png",
file_size=64, file_obj=png_file,
)
msg1 = _make_message(caption="mixed album", media_group_id="mix-1", photo=[_make_photo(photo_file)])
msg2 = _make_message(media_group_id="mix-1", document=png_doc)

with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/native.jpg", "/tmp/png-doc.png"]):
await adapter._handle_media_message(_make_update(msg1), MagicMock())
await adapter._handle_media_message(_make_update(msg2), MagicMock())
assert adapter.handle_message.await_count == 0
await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)

adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.media_urls == ["/tmp/native.jpg", "/tmp/png-doc.png"]
assert len(event.media_types) == 2


# ---------------------------------------------------------------------------
# TestMediaGroups — media group (album) buffering
# ---------------------------------------------------------------------------
Expand Down