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
111 changes: 92 additions & 19 deletions plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import signal
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -599,11 +600,17 @@ def _normalize_binary_payload(
payload, name, mime, force_audio=is_voice
)
if cached:
cached_path, cached_mime = cached
cached_type = (
MessageType.VOICE
if is_voice
else _attachment_message_type(cached_mime)
)
return (
"(voice)" if is_voice else "(attachment)",
mtype,
[cached],
[mime or ("audio/mp4" if is_voice else "application/octet-stream")],
cached_type,
[cached_path],
[cached_mime],
)
label = "voice" if is_voice else "attachment"
duration = payload.get("duration")
Expand Down Expand Up @@ -1494,10 +1501,20 @@ def _attachment_message_type(mime: str) -> MessageType:
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/heic": ".jpg",
"image/heif": ".jpg",
"image/tiff": ".jpg",
"image/heic": ".heic",
"image/heif": ".heif",
"image/heic-sequence": ".heic",
"image/heif-sequence": ".heif",
"image/tiff": ".tiff",
}
_APPLE_HEIF_MIMES = {
"image/heic",
"image/heif",
"image/heic-sequence",
"image/heif-sequence",
}
_APPLE_HEIF_SUFFIXES = {".heic", ".heif"}
_MACOS_SIPS_PATH = Path("/usr/bin/sips")
_AUDIO_EXT_BY_MIME = {
"audio/mp3": ".mp3",
"audio/mpeg": ".mp3",
Expand All @@ -1509,21 +1526,70 @@ def _attachment_message_type(mime: str) -> MessageType:
}


def _is_heif_image(mime: str, suffix: str) -> bool:
return (
(mime or "").lower() in _APPLE_HEIF_MIMES
or (suffix or "").lower() in _APPLE_HEIF_SUFFIXES
)


def _transcode_heif_to_jpeg(raw: bytes, suffix: str) -> Optional[bytes]:
"""Use macOS ImageIO via ``sips`` to convert iPhone HEIC/HEIF to JPEG.

Native model/image providers universally accept JPEG, while HEIC often
requires optional Python plugins (``pillow-heif``) that are not installed in
the Hermes runtime venv. Since Photon iMessage runs on the user's Mac, the
OS already has a reliable HEIC decoder through ImageIO; ``sips`` gives us a
small dependency-free bridge to it. Returning ``None`` keeps attachment
handling non-fatal and lets the caller surface a metadata marker instead of
handing the agent an unreadable HEIC path.
"""
if sys.platform != "darwin":
return None
if not _MACOS_SIPS_PATH.is_file():
return None
sips = str(_MACOS_SIPS_PATH)
safe_suffix = (suffix or ".heic").lower()
if safe_suffix not in _APPLE_HEIF_SUFFIXES:
safe_suffix = ".heic"
try:
with tempfile.TemporaryDirectory(prefix="hermes-photon-heif-") as tmp:
src = Path(tmp) / f"source{safe_suffix}"
dst = Path(tmp) / "converted.jpg"
src.write_bytes(raw)
proc = subprocess.run( # noqa: S603 - fixed executable + temp paths

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_transcode_heif_to_jpeg() is reached synchronously from async _dispatch_inbound, so this subprocess.run() blocks Photon event-loop progress for up to 20 seconds. Please offload conversion through an async shared-cache path (for example, asyncio.to_thread) rather than blocking inbound delivery.

[sips, "-s", "format", "jpeg", str(src), "--out", str(dst)],
capture_output=True,
text=True,
timeout=20,
check=False,
)
if proc.returncode != 0 or not dst.is_file():
detail = (proc.stderr or proc.stdout or "sips conversion failed").strip()
logger.warning("[photon] HEIC->JPEG conversion failed: %s", detail[:300])
return None
converted = dst.read_bytes()
except Exception as exc:
logger.warning("[photon] HEIC->JPEG conversion failed: %s", exc)
return None
if not converted.startswith(b"\xff\xd8\xff"):
logger.warning("[photon] HEIC->JPEG conversion produced non-JPEG bytes")
return None
return converted


def _cache_inbound_attachment(
content: Dict[str, Any],
name: str,
mime: str,
*,
force_audio: bool = False,
) -> Optional[str]:
) -> Optional[tuple[str, str]]:
"""Decode a base64-inlined inbound attachment and cache it locally.

The sidecar inlines the attachment bytes as ``content["data"]`` (base64).
We decode them and route to the shared media cache by MIME type, returning
the cached absolute path so the caller can populate ``media_urls`` (which
the gateway then hands to the model). Returns ``None`` when there are no
bytes (over the sidecar's inline cap or a failed read) or when caching
fails, so the caller can fall back to a text marker.
Returns ``(cached_path, cached_mime)``. The MIME may differ from the inbound
metadata when we transcode an iPhone HEIC/HEIF photo to JPEG for provider
compatibility.
"""
data_b64 = content.get("data")
if not data_b64:
Expand All @@ -1544,21 +1610,28 @@ def _cache_inbound_attachment(
# Prefer the real extension from the filename; fall back to the MIME map.
suffix = Path(name).suffix if name else ""
try:
if _is_heif_image(mime, suffix):
converted = _transcode_heif_to_jpeg(raw, suffix)
if converted is None:
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning None here drops attachment bytes and makes _normalize_binary_payload emit only a metadata marker. Preserve the original HEIC as a document with a non-image MIME when conversion is unavailable or fails; this keeps the attachment inspectable without routing unsupported bytes to native vision.

return cache_image_from_bytes(converted, ".jpg"), "image/jpeg"
if mime.startswith("image/"):
ext = suffix or _IMAGE_EXT_BY_MIME.get(mime, ".jpg")
try:
return cache_image_from_bytes(raw, ext)
return cache_image_from_bytes(raw, ext), (mime or "image/jpeg")
except ValueError:
# Bytes don't look like a supported image (e.g. HEIC magic) —
# still deliver them as a document rather than dropping them.
return cache_document_from_bytes(raw, name)
# The provider labelled this as image/* but the bytes do not
# pass the platform cache's narrow magic check. Preserve the
# image MIME so the gateway's image router can still sniff or
# transcode broader formats like TIFF/BMP/AVIF.
return cache_document_from_bytes(raw, name), mime
if force_audio or mime.startswith("audio/"):
ext = suffix or _AUDIO_EXT_BY_MIME.get(
mime, ".m4a" if force_audio else ".mp3"
)
return cache_audio_from_bytes(raw, ext)
return cache_audio_from_bytes(raw, ext), (mime or "audio/mp4")
# Video, application/*, and everything else → document cache.
return cache_document_from_bytes(raw, name)
return cache_document_from_bytes(raw, name), (mime or "application/octet-stream")
except Exception as exc:
logger.warning("[photon] failed to cache inbound attachment %s: %s", name, exc)
return None
Expand Down
133 changes: 133 additions & 0 deletions tests/plugins/platforms/photon/test_inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
import plugins.platforms.photon.adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter


Expand Down Expand Up @@ -87,6 +88,9 @@ async def test_dispatch_group_type(monkeypatch: pytest.MonkeyPatch) -> None:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
_JPEG_BYTES = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 32
_HEIC_MAGIC_BYTES = b"\x00\x00\x00\x24ftypheic\x00\x00\x00\x00" + b"\x00" * 64
_TIFF_MAGIC_BYTES = b"II*\x00" + b"\x00" * 64


def _attachment_event(
Expand Down Expand Up @@ -168,6 +172,135 @@ async def test_dispatch_attachment_downloads_image(
cached.unlink(missing_ok=True)


@pytest.mark.asyncio
async def test_dispatch_heic_attachment_transcodes_to_jpeg_for_native_vision(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""iPhone HEIC photos must become provider-readable JPEG image media.

Before this regression guard, Photon cached HEIC bytes as a document path
while still labelling the attachment as ``image/heic``. Native image routing
then tried to attach that HEIC path, failed to transcode without
``pillow-heif``, and the model saw only the iMessage placeholder glyph.
"""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)

def fake_sips(args, capture_output, text, timeout, check): # noqa: ANN001
assert args[:4] == [str(fake_sips_path), "-s", "format", "jpeg"]
out_path = Path(args[args.index("--out") + 1])
out_path.write_bytes(_JPEG_BYTES)
return photon_adapter.subprocess.CompletedProcess(args=args, returncode=0)

fake_sips_path = tmp_path / "sips"
fake_sips_path.write_text("", encoding="utf-8")
monkeypatch.setattr(photon_adapter.sys, "platform", "darwin")
monkeypatch.setattr(photon_adapter, "_MACOS_SIPS_PATH", fake_sips_path)
monkeypatch.setattr(photon_adapter.subprocess, "run", fake_sips)

event = _attachment_event(
{
"name": "IMG_4127.HEIC",
"mimeType": "image/heic",
"size": len(_HEIC_MAGIC_BYTES),
"data": base64.b64encode(_HEIC_MAGIC_BYTES).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)

assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.PHOTO
assert ev.media_types == ["image/jpeg"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.suffix.lower() == ".jpg"
assert cached.read_bytes() == _JPEG_BYTES
assert ev.text == "(attachment)"
finally:
cached.unlink(missing_ok=True)


@pytest.mark.asyncio
async def test_dispatch_heic_suffix_transcodes_without_image_mime(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Photon should recover iPhone HEIC photos even with generic MIME metadata."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)

def fake_sips(args, capture_output, text, timeout, check): # noqa: ANN001
assert args[:4] == [str(fake_sips_path), "-s", "format", "jpeg"]
out_path = Path(args[args.index("--out") + 1])
out_path.write_bytes(_JPEG_BYTES)
return photon_adapter.subprocess.CompletedProcess(args=args, returncode=0)

fake_sips_path = tmp_path / "sips"
fake_sips_path.write_text("", encoding="utf-8")
monkeypatch.setattr(photon_adapter.sys, "platform", "darwin")
monkeypatch.setattr(photon_adapter, "_MACOS_SIPS_PATH", fake_sips_path)
monkeypatch.setattr(photon_adapter.subprocess, "run", fake_sips)

event = _attachment_event(
{
"name": "IMG_4127.HEIC",
"mimeType": "application/octet-stream",
"size": len(_HEIC_MAGIC_BYTES),
"data": base64.b64encode(_HEIC_MAGIC_BYTES).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)

assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.PHOTO
assert ev.media_types == ["image/jpeg"]
cached = Path(ev.media_urls[0])
try:
assert cached.suffix.lower() == ".jpg"
assert cached.read_bytes() == _JPEG_BYTES
finally:
cached.unlink(missing_ok=True)


@pytest.mark.asyncio
async def test_dispatch_non_heic_image_preserves_mime_when_cached_as_document(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unsupported image-cache formats should still reach image routing."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)

event = _attachment_event(
{
"name": "scan.tiff",
"mimeType": "image/tiff",
"size": len(_TIFF_MAGIC_BYTES),
"data": base64.b64encode(_TIFF_MAGIC_BYTES).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)

assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.PHOTO
assert ev.media_types == ["image/tiff"]
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.suffix.lower() == ".tiff"
assert cached.read_bytes() == _TIFF_MAGIC_BYTES
finally:
cached.unlink(missing_ok=True)


@pytest.mark.asyncio
async def test_dispatch_group_preserves_text_and_attachment(
monkeypatch: pytest.MonkeyPatch,
Expand Down