mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(photon): wire outbound media via spectrum-ts attachment() (#42397)
Photon now exposes attachment send (Ray Sun, photon-nousresearch), so the Photon plugin gains outbound media to match the BlueBubbles iMessage channel. - sidecar: new /send-attachment endpoint wrapping space.send(attachment()) / space.send(voice()); caption sent as a trailing text bubble. - adapter: override send_image/send_image_file/send_voice/send_video/ send_document/send_animation. URL helpers cache to a local path first (cache_image_from_url), file helpers pass through. Defense-in-depth path re-validation before the path reaches the Node sidecar. - _standalone_send (cron): send text first, then each media_file as a /send-attachment call (is_voice -> voice builder). - docs/README: flip the 'outbound attachments not wired' note.
This commit is contained in:
parent
5e9d7a7661
commit
4615e08d3d
5 changed files with 507 additions and 25 deletions
255
tests/plugins/platforms/photon/test_outbound_media.py
Normal file
255
tests/plugins/platforms/photon/test_outbound_media.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"""Outbound-media tests for PhotonAdapter.
|
||||
|
||||
Photon ships outbound attachments via spectrum-ts' ``attachment()`` /
|
||||
``voice()`` content builders, reached through the Node sidecar's
|
||||
``/send-attachment`` endpoint. These tests stub ``_sidecar_call`` so we
|
||||
can assert the endpoint + body shape each ``send_*`` override produces
|
||||
without spawning Node or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Replace ``_sidecar_call`` with a recorder that returns a fixed id."""
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def real_file(tmp_path) -> str:
|
||||
p = tmp_path / "photo.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
|
||||
return str(p)
|
||||
|
||||
|
||||
def _patch_safe_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Make path validation a passthrough so tmp files outside the cache pass."""
|
||||
monkeypatch.setattr(
|
||||
PhotonAdapter,
|
||||
"validate_media_delivery_path",
|
||||
staticmethod(lambda p: p if os.path.exists(p) else None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_hits_attachment_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch, real_file: str
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_image_file(
|
||||
"any;-;+15551234567", real_file, caption="look"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg-123"
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["spaceId"] == "any;-;+15551234567"
|
||||
assert body["path"] == real_file
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["caption"] == "look"
|
||||
assert body["mimeType"] == "image/jpeg" # inferred from .jpg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_marks_kind_voice(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
audio = tmp_path / "note.m4a"
|
||||
audio.write_bytes(b"fake-audio")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_voice("any;-;+1", str(audio))
|
||||
|
||||
assert result.success is True
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["kind"] == "voice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_passes_filename(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
doc = tmp_path / "report.pdf"
|
||||
doc.write_bytes(b"%PDF-1.4 fake")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send_document("any;-;+1", str(doc), file_name="Q3.pdf")
|
||||
|
||||
_, body = calls[0]
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["name"] == "Q3.pdf"
|
||||
assert body["mimeType"] == "application/pdf"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_video_passes_through(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
vid = tmp_path / "clip.mp4"
|
||||
vid.write_bytes(b"fake-mp4")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send_video("any;+;groupguid", str(vid), caption="watch")
|
||||
|
||||
_, body = calls[0]
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["caption"] == "watch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_url_caches_then_sends_attachment(
|
||||
monkeypatch: pytest.MonkeyPatch, real_file: str
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
async def _fake_cache(url: str, *a, **k) -> str:
|
||||
assert url == "https://example.com/cat.jpg"
|
||||
return real_file
|
||||
|
||||
import gateway.platforms.base as base_mod
|
||||
|
||||
monkeypatch.setattr(base_mod, "cache_image_from_url", _fake_cache)
|
||||
|
||||
result = await adapter.send_image(
|
||||
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["path"] == real_file
|
||||
assert body["caption"] == "cat"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_url_fetch_failure_falls_back_to_text(
|
||||
monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
async def _boom(url: str, *a, **k) -> str:
|
||||
raise RuntimeError("network down")
|
||||
|
||||
import gateway.platforms.base as base_mod
|
||||
|
||||
monkeypatch.setattr(base_mod, "cache_image_from_url", _boom)
|
||||
|
||||
result = await adapter.send_image(
|
||||
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
|
||||
)
|
||||
|
||||
# Fallback path: base send_image() routes to send() → /send (text).
|
||||
assert result.success is True
|
||||
assert calls[0][0] == "/send"
|
||||
assert "https://example.com/cat.jpg" in calls[0][1]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_attachment_rejects_unsafe_path(
|
||||
monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Default validation (no passthrough patch) should reject a nonexistent /
|
||||
# traversal path, returning a failed SendResult without calling the sidecar.
|
||||
monkeypatch.setattr(
|
||||
PhotonAdapter,
|
||||
"validate_media_delivery_path",
|
||||
staticmethod(lambda p: None),
|
||||
)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_image_file("any;-;+1", "/etc/passwd")
|
||||
|
||||
assert result.success is False
|
||||
assert "unsafe" in (result.error or "")
|
||||
assert calls == [] # never reached the sidecar
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_text_then_attachments(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
img = tmp_path / "a.png"
|
||||
img.write_bytes(b"\x89PNG fake")
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(
|
||||
cfg,
|
||||
"any;-;+1",
|
||||
"hello",
|
||||
media_files=[(str(img), False)],
|
||||
)
|
||||
|
||||
assert result.get("success") is True
|
||||
# First call is the text /send, second is /send-attachment.
|
||||
assert posted[0][0].endswith("/send")
|
||||
assert posted[0][1]["text"] == "hello"
|
||||
assert posted[1][0].endswith("/send-attachment")
|
||||
assert posted[1][1]["path"] == str(img)
|
||||
assert posted[1][1]["kind"] == "attachment"
|
||||
assert posted[1][1]["mimeType"] == "image/png"
|
||||
Loading…
Add table
Add a link
Reference in a new issue