From 4c062b59b36a103613fbd4dd862c155ec5b7f02b Mon Sep 17 00:00:00 2001 From: stabruriss Date: Mon, 20 Apr 2026 14:59:28 -0700 Subject: [PATCH 1/5] Remove VOLUME directive for Railway compatibility --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a684f9fb3105b..0c5ffdd3263fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,5 +50,4 @@ RUN uv venv && \ # ---------- Runtime ---------- ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist ENV HERMES_HOME=/opt/data -VOLUME [ "/opt/data" ] ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ] From 24ad4e584f988866bb7051f37ff76395bee6c3ba Mon Sep 17 00:00:00 2001 From: stabruriss Date: Mon, 20 Apr 2026 15:24:59 -0700 Subject: [PATCH 2/5] Start as root so entrypoint can chown Railway volume mount The upstream Dockerfile sets USER hermes before ENTRYPOINT, which prevents docker/entrypoint.sh from running its gosu-based chown block when Railway mounts a root-owned Volume at /opt/data. Starting as root lets the entrypoint fix ownership, then drop privileges via gosu. --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0c5ffdd3263fb..7047547d21c3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +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 ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ] From bb511a22f587a8a29aa20844662c6e4863490691 Mon Sep 17 00:00:00 2001 From: stabruriss Date: Mon, 20 Apr 2026 15:39:41 -0700 Subject: [PATCH 3/5] Apply HERMES_MODEL env var to config.yaml on startup The Telegram/Discord messaging gateway reads model.default only from config.yaml (see gateway/run.py _resolve_gateway_model), so the HERMES_MODEL env var has no effect on those gateways. Patch the entrypoint to rewrite config.yaml's model.default from HERMES_MODEL when set, making the env var the single source of truth in container deployments. --- docker/entrypoint.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c46497dcc80ff..bd1ee731a4889 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -68,4 +68,31 @@ 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 - < {target}") +PYEOF +fi + exec hermes "$@" From b5d9a18704bced7090879d5d9cb7b18d64896c24 Mon Sep 17 00:00:00 2001 From: stabruriss Date: Mon, 20 Apr 2026 15:46:25 -0700 Subject: [PATCH 4/5] Use PyYAML to update model.default (regex didn't match unquoted scalars) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyYAML writes scalar strings without quotes by default, so the previous regex (which required double-quoted values) silently no-op'd on real config.yaml files. Switch to a yaml.safe_load/safe_dump round trip — robust to either quoting style and to wholly missing model sections. --- docker/entrypoint.sh | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bd1ee731a4889..70493651ed154 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -73,25 +73,29 @@ fi # _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 - < {target}") + 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 From 42b9102d59dd54fbd576e473e756cebd70b4343a Mon Sep 17 00:00:00 2001 From: stabruriss Date: Mon, 27 Apr 2026 14:01:15 -0700 Subject: [PATCH 5/5] fix(telegram): treat image documents (PNG/JPG/WebP/GIF) as images When a user uploaded a PNG via Telegram with "send as file" (so it arrives as msg.document, not msg.photo), the document handler rejected it with "Unsupported document type '.png'" because PNG is not in SUPPORTED_DOCUMENT_TYPES. The vision tool never saw the image. Route image-typed documents through the same image cache path used for native photos so the agent can actually see them. Album and burst buffering work the same as native photos. Re-implements the idea from closed PR #13215. Co-Authored-By: Claude Opus 4.7 --- gateway/platforms/base.py | 8 ++ gateway/platforms/telegram.py | 35 +++++++ tests/gateway/test_telegram_documents.py | 124 +++++++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index bda137cf3bf48..def784cec7481 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -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.""" diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 67be808be40c2..5c4fa90d8f304 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -76,6 +76,7 @@ class _MockContextTypes: resolve_proxy_url, SUPPORTED_VIDEO_TYPES, SUPPORTED_DOCUMENT_TYPES, + SUPPORTED_IMAGE_TYPES, utf16_len, _prefix_within_utf16_limit, ) @@ -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() @@ -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())) diff --git a/tests/gateway/test_telegram_documents.py b/tests/gateway/test_telegram_documents.py index d5564cbf46207..f63ccbfa1046d 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/tests/gateway/test_telegram_documents.py @@ -23,6 +23,7 @@ MessageType, SendResult, SUPPORTED_DOCUMENT_TYPES, + SUPPORTED_IMAGE_TYPES, SUPPORTED_VIDEO_TYPES, ) @@ -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" + ) # --------------------------------------------------------------------------- @@ -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 # ---------------------------------------------------------------------------