mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
feat(gateway): add Photon Spectrum (iMessage) platform plugin
First-class iMessage support via Photon's managed Spectrum platform. Targeted as a successor to the BlueBubbles adapter — Photon allocates the iMessage line, handles delivery, and abuse-prevention so users don't have to run their own Mac relay. Free tier uses Photon's shared line pool. Architecture: - Inbound: signed JSON webhooks (X-Spectrum-Signature, HMAC-SHA256) delivered to a local aiohttp listener. Dedupes on message.id, rejects deliveries with >5min timestamp drift. - Outbound: small supervised Node sidecar that runs the spectrum-ts SDK. Photon does not currently expose a public HTTP send-message endpoint; the sidecar is the only way to call Space.send() today. When Photon ships an HTTP send endpoint we collapse the sidecar into _sidecar_send and drop the Node dep — every other layer of the plugin stays the same. - Setup: 'hermes photon login' runs the RFC 8628 device-code flow; 'hermes photon setup' creates a Spectrum-enabled project, creates a shared user (free tier), installs the sidecar's npm deps. - Webhook management: 'hermes photon webhook register|list|delete'. - Credentials persisted under credential_pool.photon / credential_pool.photon_project in ~/.hermes/auth.json. Plugin path (not built-in) — per current policy (May 2026), all new platforms ship under plugins/platforms/. Registers itself via ctx.register_platform() + ctx.register_cli_command(), zero edits to core gateway code. Tests cover: - HMAC-SHA256 signature verification (happy path, tampered body, wrong secret, drift, missing v0 prefix, empty inputs, non-integer timestamp) - Inbound dispatch for text DMs, group ids (any;+;...), and attachment metadata markers - Deduplication window - check_requirements gating when Node is absent - Device-code flow: request, header-based token return, body-fallback token return, access_denied propagation - Project/user/webhook API clients with mocked httpx Known limitations (current Photon API): - Attachments are metadata only — no download URL yet - Outbound attachment send not wired (sidecar can add easily) - Reactions / message effects not exposed yet Docs: website/docs/user-guide/messaging/photon.md + sidebar entry.
This commit is contained in:
parent
6e7033bb4c
commit
5b4e431e8c
15 changed files with 2587 additions and 0 deletions
1
tests/plugins/platforms/photon/__init__.py
Normal file
1
tests/plugins/platforms/photon/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Unit tests for the Photon Spectrum platform plugin."""
|
||||
211
tests/plugins/platforms/photon/test_auth.py
Normal file
211
tests/plugins/platforms/photon/test_auth.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""Tests for the Photon auth module (device login + project + user creation)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.photon import auth as photon_auth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake httpx — we don't want to hit the real Photon API in unit tests.
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int = 200,
|
||||
json_body: Any = None,
|
||||
headers: Dict[str, str] | None = None,
|
||||
text: str = "",
|
||||
) -> None:
|
||||
self.status_code = status
|
||||
self._json = json_body if json_body is not None else {}
|
||||
self.headers = headers or {}
|
||||
self.text = text
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._json
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# The auth module memoises by reading get_hermes_home at call time
|
||||
# so the env var is what matters.
|
||||
return home
|
||||
|
||||
|
||||
def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_photon_token("abc123def456")
|
||||
assert photon_auth.load_photon_token() == "abc123def456"
|
||||
|
||||
auth_json = json.loads((tmp_hermes_home / "auth.json").read_text())
|
||||
assert "credential_pool" in auth_json
|
||||
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
|
||||
|
||||
|
||||
def test_store_and_load_project_credentials(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_project_credentials(
|
||||
"proj-uuid", "secret-key", name="Test Project",
|
||||
)
|
||||
pid, secret = photon_auth.load_project_credentials()
|
||||
assert pid == "proj-uuid"
|
||||
assert secret == "secret-key"
|
||||
|
||||
|
||||
def test_load_project_credentials_env_override(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
photon_auth.store_project_credentials("from-file", "secret-file")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env")
|
||||
pid, secret = photon_auth.load_project_credentials()
|
||||
assert pid == "from-env"
|
||||
assert secret == "secret-env"
|
||||
|
||||
|
||||
def test_request_device_code(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = json
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev-code-xyz",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": "https://app.photon.codes/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
|
||||
code = photon_auth.request_device_code()
|
||||
assert code.device_code == "dev-code-xyz"
|
||||
assert code.user_code == "ABCD-1234"
|
||||
assert code.expires_in == 600
|
||||
assert "/api/auth/device/code" in captured["url"]
|
||||
assert captured["body"]["client_id"] == "hermes-agent"
|
||||
|
||||
|
||||
def test_poll_for_token_via_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Token from set-auth-token header is the documented mechanism."""
|
||||
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(
|
||||
status=200,
|
||||
json_body={"session": {}, "user": {}},
|
||||
headers={"set-auth-token": "bearer-xyz"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
|
||||
code = photon_auth.DeviceCode(
|
||||
device_code="d", user_code="u",
|
||||
verification_uri="https://x", verification_uri_complete=None,
|
||||
expires_in=10, interval=0,
|
||||
)
|
||||
token = photon_auth.poll_for_token(code, interval=0, timeout=2)
|
||||
assert token == "bearer-xyz"
|
||||
|
||||
|
||||
def test_poll_for_token_via_body_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""If the header is absent we fall back to session.access_token."""
|
||||
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(
|
||||
status=200,
|
||||
json_body={"session": {"access_token": "from-body"}, "user": {}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
code = photon_auth.DeviceCode(
|
||||
device_code="d", user_code="u",
|
||||
verification_uri="https://x", verification_uri_complete=None,
|
||||
expires_in=10, interval=0,
|
||||
)
|
||||
assert photon_auth.poll_for_token(code, interval=0, timeout=2) == "from-body"
|
||||
|
||||
|
||||
def test_poll_for_token_propagates_access_denied(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(
|
||||
status=400, json_body={"error": "access_denied"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
code = photon_auth.DeviceCode(
|
||||
device_code="d", user_code="u",
|
||||
verification_uri="https://x", verification_uri_complete=None,
|
||||
expires_in=10, interval=0,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="access_denied"):
|
||||
photon_auth.poll_for_token(code, interval=0, timeout=2)
|
||||
|
||||
|
||||
def test_create_user_rejects_invalid_phone() -> None:
|
||||
with pytest.raises(ValueError, match="E.164"):
|
||||
photon_auth.create_user(
|
||||
"proj", "secret", phone_number="not-a-number",
|
||||
)
|
||||
|
||||
|
||||
def test_create_user_posts_shared_type(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, *, json: Dict[str, Any], auth: tuple, timeout: float) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = json
|
||||
captured["auth"] = auth
|
||||
return _FakeResponse(json_body={
|
||||
"succeed": True,
|
||||
"data": {
|
||||
"id": "user-uuid",
|
||||
"phoneNumber": "+15551234567",
|
||||
"assignedPhoneNumber": "+15559999999",
|
||||
},
|
||||
})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
user = photon_auth.create_user(
|
||||
"proj-id", "proj-secret",
|
||||
phone_number="+15551234567",
|
||||
)
|
||||
assert user["assignedPhoneNumber"] == "+15559999999"
|
||||
assert captured["auth"] == ("proj-id", "proj-secret")
|
||||
assert captured["body"]["type"] == "shared"
|
||||
assert captured["body"]["phoneNumber"] == "+15551234567"
|
||||
assert "/projects/proj-id/users/" in captured["url"]
|
||||
|
||||
|
||||
def test_register_webhook_surfaces_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], auth: tuple, timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={
|
||||
"succeed": True,
|
||||
"data": {
|
||||
"id": "wh-uuid",
|
||||
"webhookUrl": json["webhookUrl"],
|
||||
"signingSecret": "0" * 64,
|
||||
},
|
||||
})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
data = photon_auth.register_webhook(
|
||||
"proj", "secret", webhook_url="https://x.example.com/hook",
|
||||
)
|
||||
assert data["signingSecret"] == "0" * 64
|
||||
assert data["webhookUrl"] == "https://x.example.com/hook"
|
||||
139
tests/plugins/platforms/photon/test_inbound.py
Normal file
139
tests/plugins/platforms/photon/test_inbound.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Inbound dispatch + dedup tests for PhotonAdapter.
|
||||
|
||||
These tests bypass the aiohttp server — they call ``_dispatch_inbound``
|
||||
and ``_is_duplicate`` directly. That keeps them fast and means we can
|
||||
exercise the message-shape parsing logic without binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
# Avoid touching real auth.json / env.
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = fake_handle # type: ignore[assignment]
|
||||
|
||||
payload = {
|
||||
"event": "messages",
|
||||
"space": {"id": "any;-;+15551234567", "platform": "iMessage"},
|
||||
"message": {
|
||||
"id": "spc-msg-abc",
|
||||
"platform": "iMessage",
|
||||
"direction": "inbound",
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
"sender": {"id": "+15551234567", "platform": "iMessage"},
|
||||
"space": {"id": "any;-;+15551234567", "platform": "iMessage"},
|
||||
"content": {"type": "text", "text": "hello world"},
|
||||
},
|
||||
}
|
||||
await adapter._dispatch_inbound(payload)
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "hello world"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.message_id == "spc-msg-abc"
|
||||
src = event.source
|
||||
assert src is not None
|
||||
assert src.platform == Platform("photon")
|
||||
assert src.chat_id == "any;-;+15551234567"
|
||||
assert src.chat_type == "dm"
|
||||
assert src.user_id == "+15551234567"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_group_id_detected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = fake_handle # type: ignore[assignment]
|
||||
|
||||
payload = {
|
||||
"event": "messages",
|
||||
"space": {"id": "any;+;group-guid-xyz", "platform": "iMessage"},
|
||||
"message": {
|
||||
"id": "spc-msg-grp",
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
"sender": {"id": "+15551234567"},
|
||||
"space": {"id": "any;+;group-guid-xyz"},
|
||||
"content": {"type": "text", "text": "hi group"},
|
||||
},
|
||||
}
|
||||
await adapter._dispatch_inbound(payload)
|
||||
assert captured[0].source.chat_type == "group"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_attachment_surfaces_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = fake_handle # type: ignore[assignment]
|
||||
|
||||
payload = {
|
||||
"event": "messages",
|
||||
"message": {
|
||||
"id": "spc-msg-att",
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
"sender": {"id": "+15551234567"},
|
||||
"space": {"id": "any;-;+15551234567"},
|
||||
"content": {
|
||||
"type": "attachment",
|
||||
"name": "IMG_4127.HEIC",
|
||||
"mimeType": "image/heic",
|
||||
"size": 12345,
|
||||
},
|
||||
},
|
||||
}
|
||||
await adapter._dispatch_inbound(payload)
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
# Attachment carries metadata marker; mime → MessageType.PHOTO.
|
||||
assert "Photon attachment received" in event.text
|
||||
assert "IMG_4127.HEIC" in event.text
|
||||
assert event.message_type == MessageType.PHOTO
|
||||
|
||||
|
||||
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter._is_duplicate("id-1") is False
|
||||
assert adapter._is_duplicate("id-1") is True
|
||||
assert adapter._is_duplicate("id-2") is False
|
||||
assert adapter._is_duplicate("id-1") is True # still dup
|
||||
|
||||
|
||||
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# If no node binary on PATH the adapter should refuse to start.
|
||||
from plugins.platforms.photon import adapter as adapter_mod
|
||||
|
||||
monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
|
||||
assert adapter_mod.check_requirements() is False
|
||||
95
tests/plugins/platforms/photon/test_signature.py
Normal file
95
tests/plugins/platforms/photon/test_signature.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Signature verification tests for the Photon webhook receiver."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.photon.adapter import verify_signature
|
||||
|
||||
|
||||
def _sign(secret: str, body: bytes, ts: int) -> str:
|
||||
return "v0=" + hmac.new(
|
||||
secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_accepts_valid_signature() -> None:
|
||||
secret = "topsecret-32chars-or-whatever"
|
||||
body = b'{"event":"messages"}'
|
||||
ts = int(time.time())
|
||||
sig = _sign(secret, body, ts)
|
||||
assert verify_signature(
|
||||
body=body, timestamp_header=str(ts), signature_header=sig,
|
||||
signing_secret=secret,
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_tampered_body() -> None:
|
||||
secret = "s"
|
||||
body = b'{"event":"messages"}'
|
||||
ts = int(time.time())
|
||||
sig = _sign(secret, body, ts)
|
||||
assert not verify_signature(
|
||||
body=body + b" tamper", timestamp_header=str(ts),
|
||||
signature_header=sig, signing_secret=secret,
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_wrong_secret() -> None:
|
||||
body = b"x"
|
||||
ts = int(time.time())
|
||||
sig = _sign("right", body, ts)
|
||||
assert not verify_signature(
|
||||
body=body, timestamp_header=str(ts), signature_header=sig,
|
||||
signing_secret="wrong",
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_drifted_timestamp() -> None:
|
||||
secret = "s"
|
||||
body = b"x"
|
||||
ts = int(time.time()) - 3600 # 1h old; drift window is 5 min
|
||||
sig = _sign(secret, body, ts)
|
||||
assert not verify_signature(
|
||||
body=body, timestamp_header=str(ts), signature_header=sig,
|
||||
signing_secret=secret,
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_missing_v0_prefix() -> None:
|
||||
secret = "s"
|
||||
body = b"x"
|
||||
ts = int(time.time())
|
||||
raw_hex = hmac.new(
|
||||
secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256,
|
||||
).hexdigest()
|
||||
# Strip the "v0=" prefix — verify_signature must reject.
|
||||
assert not verify_signature(
|
||||
body=body, timestamp_header=str(ts), signature_header=raw_hex,
|
||||
signing_secret=secret,
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_empty_inputs() -> None:
|
||||
assert not verify_signature(
|
||||
body=b"x", timestamp_header="", signature_header="v0=abc",
|
||||
signing_secret="s",
|
||||
)
|
||||
assert not verify_signature(
|
||||
body=b"x", timestamp_header="123", signature_header="",
|
||||
signing_secret="s",
|
||||
)
|
||||
assert not verify_signature(
|
||||
body=b"x", timestamp_header="123", signature_header="v0=abc",
|
||||
signing_secret="",
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_non_integer_timestamp() -> None:
|
||||
assert not verify_signature(
|
||||
body=b"x", timestamp_header="not-an-int",
|
||||
signature_header="v0=abc", signing_secret="s",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue