mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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.
737 lines
27 KiB
Python
737 lines
27 KiB
Python
"""
|
|
Photon Spectrum (iMessage) platform adapter for Hermes Agent.
|
|
|
|
Inbound:
|
|
Photon delivers signed JSON ``POST``s to a URL we register. The
|
|
adapter spins up an aiohttp server on ``PHOTON_WEBHOOK_PORT``,
|
|
verifies ``X-Spectrum-Signature`` (HMAC-SHA256 of
|
|
``v0:{timestamp}:{body}`` keyed by the per-URL signing secret),
|
|
rejects deliveries with a timestamp drift > 5 minutes, dedupes on
|
|
``message.id``, and dispatches a normalized ``MessageEvent`` to the
|
|
gateway runner via ``BasePlatformAdapter.handle_message``.
|
|
|
|
Outbound:
|
|
Photon does not currently expose a public HTTP send-message
|
|
endpoint, so the adapter spawns a small Node sidecar (see
|
|
``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK. Each
|
|
``send`` / ``send_typing`` call from Hermes is a loopback POST to
|
|
the sidecar with a shared bearer token.
|
|
|
|
When Photon ships an HTTP send endpoint we can collapse the sidecar
|
|
into ``_send_via_http`` and drop the Node dependency entirely.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
try:
|
|
import httpx
|
|
HTTPX_AVAILABLE = True
|
|
except ImportError: # pragma: no cover - httpx is already a Hermes dep
|
|
HTTPX_AVAILABLE = False
|
|
httpx = None # type: ignore[assignment]
|
|
|
|
try:
|
|
from aiohttp import web
|
|
AIOHTTP_AVAILABLE = True
|
|
except ImportError:
|
|
AIOHTTP_AVAILABLE = False
|
|
web = None # type: ignore[assignment]
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import (
|
|
BasePlatformAdapter,
|
|
MessageEvent,
|
|
MessageType,
|
|
SendResult,
|
|
)
|
|
|
|
from .auth import (
|
|
DEFAULT_SPECTRUM_HOST,
|
|
load_project_credentials,
|
|
_spectrum_host,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
|
|
_DEFAULT_WEBHOOK_PORT = 8788
|
|
_DEFAULT_WEBHOOK_PATH = "/photon/webhook"
|
|
_DEFAULT_WEBHOOK_BIND = "0.0.0.0"
|
|
|
|
_DEFAULT_SIDECAR_PORT = 8789
|
|
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
|
|
|
|
# Photon iMessage messages from the SDK side have no documented hard
|
|
# limit, but the underlying iMessage protocol limits practical message
|
|
# size to ~16 KB. Keep a conservative cap that matches BlueBubbles.
|
|
_MAX_MESSAGE_LENGTH = 8000
|
|
|
|
# Spec says reject deliveries older than ~5 minutes for replay protection.
|
|
_TIMESTAMP_DRIFT_SECONDS = 300
|
|
|
|
# Dedup parameters — keep at least 1k IDs for ~48h per Photon's
|
|
# at-least-once guidance.
|
|
_DEDUP_MAX_SIZE = 4000
|
|
_DEDUP_WINDOW_SECONDS = 48 * 3600
|
|
|
|
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level helpers — also used by check_fn / standalone send
|
|
|
|
def _coerce_port(value: Any, default: int) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def check_requirements() -> bool:
|
|
"""Return True when both Python deps and the Node sidecar are available."""
|
|
if not HTTPX_AVAILABLE or not AIOHTTP_AVAILABLE:
|
|
return False
|
|
if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
|
|
return False
|
|
if not (_SIDECAR_DIR / "node_modules").exists():
|
|
# spectrum-ts not installed yet — `hermes photon setup` will
|
|
# install it. check_fn still returns False so the gateway
|
|
# surfaces the missing-deps state in `hermes setup` / status.
|
|
return False
|
|
return True
|
|
|
|
|
|
def validate_config(cfg: PlatformConfig) -> bool:
|
|
extra = cfg.extra or {}
|
|
project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID")
|
|
project_secret = extra.get("project_secret") or os.getenv("PHOTON_PROJECT_SECRET")
|
|
if not project_id or not project_secret:
|
|
# Fall back to auth.json
|
|
stored_id, stored_sec = load_project_credentials()
|
|
return bool(stored_id and stored_sec)
|
|
return True
|
|
|
|
|
|
def is_connected(cfg: PlatformConfig) -> bool:
|
|
return validate_config(cfg)
|
|
|
|
|
|
def _env_enablement() -> Optional[dict]:
|
|
"""Seed PlatformConfig.extra from env so env-only setups appear in status."""
|
|
project_id, project_secret = load_project_credentials()
|
|
if not (project_id and project_secret):
|
|
return None
|
|
return {
|
|
"project_id": project_id,
|
|
"project_secret": project_secret,
|
|
"webhook_port": _coerce_port(os.getenv("PHOTON_WEBHOOK_PORT"), _DEFAULT_WEBHOOK_PORT),
|
|
"webhook_path": os.getenv("PHOTON_WEBHOOK_PATH") or _DEFAULT_WEBHOOK_PATH,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signature verification
|
|
|
|
def verify_signature(
|
|
*,
|
|
body: bytes,
|
|
timestamp_header: str,
|
|
signature_header: str,
|
|
signing_secret: str,
|
|
now: Optional[float] = None,
|
|
drift: int = _TIMESTAMP_DRIFT_SECONDS,
|
|
) -> bool:
|
|
"""Constant-time verify a Photon webhook signature.
|
|
|
|
Returns True iff the timestamp is within ``drift`` of *now* AND
|
|
``signature_header == "v0=" + hmac_sha256(secret, "v0:{ts}:{body}")``.
|
|
|
|
Exposed at module scope so tests can exercise it without an adapter
|
|
instance.
|
|
"""
|
|
if not timestamp_header or not signature_header or not signing_secret:
|
|
return False
|
|
try:
|
|
ts = int(timestamp_header)
|
|
except ValueError:
|
|
return False
|
|
if abs((now or time.time()) - ts) > drift:
|
|
return False
|
|
if not signature_header.startswith("v0="):
|
|
return False
|
|
expected = hmac.new(
|
|
signing_secret.encode("utf-8"),
|
|
f"v0:{ts}:".encode("utf-8") + body,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
return hmac.compare_digest(expected, signature_header[3:])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter
|
|
|
|
class PhotonAdapter(BasePlatformAdapter):
|
|
"""Inbound: signed webhook on aiohttp. Outbound: Node sidecar via loopback HTTP."""
|
|
|
|
MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
super().__init__(config, Platform("photon"))
|
|
extra = config.extra or {}
|
|
|
|
# Project credentials (env wins, then config.extra, then auth.json).
|
|
stored_id, stored_sec = load_project_credentials()
|
|
self._project_id: str = (
|
|
os.getenv("PHOTON_PROJECT_ID")
|
|
or extra.get("project_id")
|
|
or stored_id
|
|
or ""
|
|
)
|
|
self._project_secret: str = (
|
|
os.getenv("PHOTON_PROJECT_SECRET")
|
|
or extra.get("project_secret")
|
|
or stored_sec
|
|
or ""
|
|
)
|
|
|
|
# Webhook receiver
|
|
self._webhook_port = _coerce_port(
|
|
extra.get("webhook_port") or os.getenv("PHOTON_WEBHOOK_PORT"),
|
|
_DEFAULT_WEBHOOK_PORT,
|
|
)
|
|
self._webhook_path = (
|
|
extra.get("webhook_path")
|
|
or os.getenv("PHOTON_WEBHOOK_PATH")
|
|
or _DEFAULT_WEBHOOK_PATH
|
|
)
|
|
self._webhook_bind = (
|
|
extra.get("webhook_bind")
|
|
or os.getenv("PHOTON_WEBHOOK_BIND")
|
|
or _DEFAULT_WEBHOOK_BIND
|
|
)
|
|
self._webhook_secret: str = (
|
|
os.getenv("PHOTON_WEBHOOK_SECRET")
|
|
or extra.get("webhook_secret")
|
|
or ""
|
|
)
|
|
|
|
# Sidecar
|
|
self._sidecar_port = _coerce_port(
|
|
extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
|
|
_DEFAULT_SIDECAR_PORT,
|
|
)
|
|
self._sidecar_bind = _DEFAULT_SIDECAR_BIND
|
|
self._sidecar_token = (
|
|
os.getenv("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16)
|
|
)
|
|
self._autostart_sidecar = str(
|
|
os.getenv("PHOTON_SIDECAR_AUTOSTART", "true")
|
|
).lower() not in ("0", "false", "no")
|
|
self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
|
|
|
|
# Runtime state
|
|
self._runner: Optional["web.AppRunner"] = None
|
|
self._sidecar_proc: Optional[subprocess.Popen] = None
|
|
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
|
|
self._http_client: Optional["httpx.AsyncClient"] = None
|
|
# Lightweight in-memory dedup. Photon's at-least-once guarantee
|
|
# means we WILL see the same message.id more than once.
|
|
self._seen_messages: Dict[str, float] = {}
|
|
|
|
# -- Connection lifecycle ---------------------------------------------
|
|
|
|
async def connect(self) -> bool:
|
|
if not AIOHTTP_AVAILABLE:
|
|
self._set_fatal_error(
|
|
"MISSING_DEP",
|
|
"aiohttp not installed. Run: pip install aiohttp",
|
|
retryable=False,
|
|
)
|
|
return False
|
|
if not HTTPX_AVAILABLE:
|
|
self._set_fatal_error(
|
|
"MISSING_DEP", "httpx not installed", retryable=False
|
|
)
|
|
return False
|
|
if not self._project_id or not self._project_secret:
|
|
self._set_fatal_error(
|
|
"MISSING_CREDENTIALS",
|
|
"PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. "
|
|
"Run: hermes photon setup",
|
|
retryable=False,
|
|
)
|
|
return False
|
|
|
|
# Start the aiohttp receiver first; without it the sidecar would
|
|
# be able to forward inbound traffic to a closed port.
|
|
try:
|
|
await self._start_webhook_server()
|
|
except OSError as e:
|
|
self._set_fatal_error(
|
|
"PORT_IN_USE",
|
|
f"webhook port {self._webhook_port} unavailable: {e}",
|
|
retryable=True,
|
|
)
|
|
return False
|
|
|
|
# Spin up the Node sidecar (required for outbound).
|
|
if self._autostart_sidecar:
|
|
try:
|
|
await self._start_sidecar()
|
|
except Exception as e:
|
|
self._set_fatal_error(
|
|
"SIDECAR_FAILED",
|
|
f"failed to start Photon sidecar: {e}",
|
|
retryable=True,
|
|
)
|
|
await self._stop_webhook_server()
|
|
return False
|
|
else:
|
|
logger.info("[photon] sidecar autostart disabled — outbound will fail")
|
|
|
|
self._http_client = httpx.AsyncClient(timeout=30.0)
|
|
self._mark_connected()
|
|
logger.info(
|
|
"[photon] connected — webhook at %s:%d%s, sidecar on %s:%d",
|
|
self._webhook_bind, self._webhook_port, self._webhook_path,
|
|
self._sidecar_bind, self._sidecar_port,
|
|
)
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
await self._stop_sidecar()
|
|
await self._stop_webhook_server()
|
|
if self._http_client is not None:
|
|
try:
|
|
await self._http_client.aclose()
|
|
except Exception:
|
|
pass
|
|
self._http_client = None
|
|
self._mark_disconnected()
|
|
|
|
# -- Webhook server ----------------------------------------------------
|
|
|
|
async def _start_webhook_server(self) -> None:
|
|
app = web.Application()
|
|
app.router.add_post(self._webhook_path, self._handle_webhook)
|
|
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
|
|
self._runner = web.AppRunner(app)
|
|
await self._runner.setup()
|
|
site = web.TCPSite(self._runner, self._webhook_bind, self._webhook_port)
|
|
await site.start()
|
|
|
|
async def _stop_webhook_server(self) -> None:
|
|
if self._runner is not None:
|
|
try:
|
|
await self._runner.cleanup()
|
|
except Exception:
|
|
pass
|
|
self._runner = None
|
|
|
|
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
|
body = await request.read()
|
|
if self._webhook_secret:
|
|
ts = request.headers.get("X-Spectrum-Timestamp", "")
|
|
sig = request.headers.get("X-Spectrum-Signature", "")
|
|
if not verify_signature(
|
|
body=body,
|
|
timestamp_header=ts,
|
|
signature_header=sig,
|
|
signing_secret=self._webhook_secret,
|
|
):
|
|
logger.warning("[photon] rejected webhook with bad signature")
|
|
return web.Response(status=401, text="invalid signature")
|
|
else:
|
|
logger.warning(
|
|
"[photon] PHOTON_WEBHOOK_SECRET unset — accepting unsigned "
|
|
"deliveries. Set the per-URL signing secret returned by "
|
|
"register-webhook to enable verification."
|
|
)
|
|
|
|
try:
|
|
payload = json.loads(body or b"{}")
|
|
except json.JSONDecodeError:
|
|
return web.Response(status=400, text="invalid json")
|
|
if payload.get("event") != "messages":
|
|
# Photon currently emits only `messages`; any future event
|
|
# types are ack'd 200 so they don't retry.
|
|
return web.Response(text="ok")
|
|
|
|
msg = payload.get("message") or {}
|
|
msg_id = msg.get("id")
|
|
if not msg_id:
|
|
return web.Response(status=400, text="missing message.id")
|
|
if self._is_duplicate(msg_id):
|
|
return web.Response(text="ok (dup)")
|
|
|
|
try:
|
|
await self._dispatch_inbound(payload)
|
|
except Exception:
|
|
logger.exception("[photon] inbound dispatch failed")
|
|
# 200 anyway — we own the dedup; failing here would cause
|
|
# Photon to retry the same id.
|
|
return web.Response(text="ok")
|
|
|
|
def _is_duplicate(self, msg_id: str) -> bool:
|
|
now = time.time()
|
|
if len(self._seen_messages) > _DEDUP_MAX_SIZE:
|
|
cutoff = now - _DEDUP_WINDOW_SECONDS
|
|
self._seen_messages = {
|
|
k: v for k, v in self._seen_messages.items() if v > cutoff
|
|
}
|
|
if msg_id in self._seen_messages:
|
|
return True
|
|
self._seen_messages[msg_id] = now
|
|
return False
|
|
|
|
async def _dispatch_inbound(self, payload: Dict[str, Any]) -> None:
|
|
msg = payload.get("message") or {}
|
|
space = msg.get("space") or payload.get("space") or {}
|
|
sender = msg.get("sender") or {}
|
|
content = msg.get("content") or {}
|
|
|
|
space_id = space.get("id") or ""
|
|
sender_id = sender.get("id") or ""
|
|
if not space_id:
|
|
logger.warning("[photon] inbound missing space.id")
|
|
return
|
|
|
|
# Space type — Photon documents iMessage DM ids as `any;-;+E164`
|
|
# and group ids as `any;+;<chat-guid>`. Use that as the
|
|
# heuristic; everything else is treated as DM.
|
|
chat_type = "group" if ";+;" in space_id else "dm"
|
|
|
|
# Timestamp — ISO 8601 from the platform.
|
|
ts_str = msg.get("timestamp") or ""
|
|
try:
|
|
timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
timestamp = datetime.now(tz=timezone.utc)
|
|
|
|
# Content normalization. Spectrum is a discriminated union;
|
|
# text vs attachment metadata. Attachments are metadata-only
|
|
# today (no download URL) — log + carry the name so the agent
|
|
# at least knows something was sent.
|
|
if content.get("type") == "text":
|
|
text = content.get("text") or ""
|
|
mtype = MessageType.TEXT
|
|
elif content.get("type") == "attachment":
|
|
name = content.get("name") or "(unnamed)"
|
|
mime = content.get("mimeType") or ""
|
|
text = f"[Photon attachment received: {name} ({mime}) — no download URL yet]"
|
|
mtype = _attachment_message_type(mime)
|
|
else:
|
|
text = f"[Photon content type not handled: {content.get('type')}]"
|
|
mtype = MessageType.TEXT
|
|
|
|
source = self.build_source(
|
|
chat_id=space_id,
|
|
chat_name=space_id,
|
|
chat_type=chat_type,
|
|
user_id=sender_id or space_id,
|
|
user_name=sender_id or None,
|
|
)
|
|
event = MessageEvent(
|
|
text=text,
|
|
message_type=mtype,
|
|
source=source,
|
|
message_id=msg.get("id"),
|
|
raw_message=payload,
|
|
timestamp=timestamp,
|
|
)
|
|
await self.handle_message(event)
|
|
|
|
# -- Sidecar lifecycle -------------------------------------------------
|
|
|
|
async def _start_sidecar(self) -> None:
|
|
if not (_SIDECAR_DIR / "node_modules").exists():
|
|
raise RuntimeError(
|
|
f"Photon sidecar deps not installed. Run: "
|
|
f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)"
|
|
)
|
|
env = os.environ.copy()
|
|
env["PHOTON_PROJECT_ID"] = self._project_id
|
|
env["PHOTON_PROJECT_SECRET"] = self._project_secret
|
|
env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port)
|
|
env["PHOTON_SIDECAR_BIND"] = self._sidecar_bind
|
|
env["PHOTON_SIDECAR_TOKEN"] = self._sidecar_token
|
|
|
|
self._sidecar_proc = subprocess.Popen( # noqa: S603
|
|
[self._node_bin, str(_SIDECAR_DIR / "index.mjs")],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
env=env,
|
|
start_new_session=(sys.platform != "win32"),
|
|
)
|
|
|
|
# Pump sidecar stderr/stdout into our logger so users see crashes.
|
|
loop = asyncio.get_event_loop()
|
|
self._sidecar_supervisor_task = loop.create_task(
|
|
self._supervise_sidecar(self._sidecar_proc)
|
|
)
|
|
|
|
# Wait for /healthz to come up — give it up to 15s on cold start.
|
|
deadline = time.time() + 15.0
|
|
last_err: Optional[Exception] = None
|
|
async with httpx.AsyncClient(timeout=2.0) as client:
|
|
while time.time() < deadline:
|
|
if self._sidecar_proc.poll() is not None:
|
|
raise RuntimeError(
|
|
f"Photon sidecar exited with code "
|
|
f"{self._sidecar_proc.returncode} before becoming ready"
|
|
)
|
|
try:
|
|
resp = await client.post(
|
|
f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz",
|
|
headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
|
|
)
|
|
if resp.status_code == 200:
|
|
return
|
|
except httpx.RequestError as e:
|
|
last_err = e
|
|
await asyncio.sleep(0.2)
|
|
raise RuntimeError(
|
|
f"Photon sidecar did not become ready within 15s: {last_err}"
|
|
)
|
|
|
|
async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
|
|
"""Pump the sidecar's stdout/stderr into our logger."""
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
while True:
|
|
line = await loop.run_in_executor(None, proc.stdout.readline)
|
|
if not line:
|
|
break
|
|
logger.info("[photon-sidecar] %s", line.decode("utf-8", "replace").rstrip())
|
|
except Exception as e: # pragma: no cover - defensive
|
|
logger.warning("[photon-sidecar] supervisor exited: %s", e)
|
|
|
|
async def _stop_sidecar(self) -> None:
|
|
proc = self._sidecar_proc
|
|
if proc is None:
|
|
return
|
|
try:
|
|
# Polite shutdown first.
|
|
if self._http_client is not None:
|
|
try:
|
|
await self._http_client.post(
|
|
f"http://{self._sidecar_bind}:{self._sidecar_port}/shutdown",
|
|
headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
|
|
timeout=2.0,
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=3.0)
|
|
except subprocess.TimeoutExpired:
|
|
if sys.platform != "win32":
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
proc.terminate()
|
|
else:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=2.0)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
finally:
|
|
self._sidecar_proc = None
|
|
if self._sidecar_supervisor_task is not None:
|
|
self._sidecar_supervisor_task.cancel()
|
|
self._sidecar_supervisor_task = None
|
|
|
|
# -- Outbound ----------------------------------------------------------
|
|
|
|
async def send(
|
|
self,
|
|
chat_id: str,
|
|
content: str,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
return await self._sidecar_send(chat_id, content, reply_to=reply_to)
|
|
|
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
try:
|
|
await self._sidecar_call("/typing", {"spaceId": chat_id})
|
|
except Exception as e:
|
|
logger.debug("[photon] send_typing failed: %s", e)
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
"""Return whatever we know about a Spectrum space id.
|
|
|
|
Photon's `space.id` is opaque (`any;-;+E164` for DMs,
|
|
`any;+;<guid>` for groups). We surface that shape directly so
|
|
the gateway has something to show in session pickers / logs.
|
|
"""
|
|
chat_type = "group" if ";+;" in chat_id else "dm"
|
|
return {"name": chat_id, "type": chat_type, "id": chat_id}
|
|
|
|
async def _sidecar_send(
|
|
self, space_id: str, text: str, *, reply_to: Optional[str] = None,
|
|
) -> SendResult:
|
|
if len(text) > self.MAX_MESSAGE_LENGTH:
|
|
logger.warning(
|
|
"[photon] truncating outbound from %d to %d chars",
|
|
len(text), self.MAX_MESSAGE_LENGTH,
|
|
)
|
|
text = text[: self.MAX_MESSAGE_LENGTH]
|
|
body: Dict[str, Any] = {"spaceId": space_id, "text": text}
|
|
if reply_to:
|
|
body["replyTo"] = reply_to
|
|
try:
|
|
data = await self._sidecar_call("/send", body)
|
|
except Exception as e:
|
|
return SendResult(success=False, error=str(e))
|
|
return SendResult(success=True, message_id=data.get("messageId"))
|
|
|
|
async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
if self._http_client is None:
|
|
raise RuntimeError("Photon adapter not connected")
|
|
resp = await self._http_client.post(
|
|
f"http://{self._sidecar_bind}:{self._sidecar_port}{path}",
|
|
json=body,
|
|
headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
|
|
timeout=30.0,
|
|
)
|
|
if resp.status_code != 200:
|
|
raise RuntimeError(
|
|
f"Photon sidecar {path} returned {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
data = resp.json() or {}
|
|
if not data.get("ok"):
|
|
raise RuntimeError(
|
|
f"Photon sidecar {path} reported error: {data.get('error')}"
|
|
)
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
|
|
def _attachment_message_type(mime: str) -> MessageType:
|
|
mime = (mime or "").lower()
|
|
if mime.startswith("image/"):
|
|
return MessageType.PHOTO
|
|
if mime.startswith("video/"):
|
|
return MessageType.VIDEO
|
|
if mime.startswith("audio/"):
|
|
return MessageType.AUDIO
|
|
if mime.startswith("application/"):
|
|
return MessageType.DOCUMENT
|
|
return MessageType.DOCUMENT
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Standalone (out-of-process) send for cron deliveries when the gateway
|
|
# is not co-resident. Spins up an ephemeral sidecar call by spawning
|
|
# the existing sidecar binary one-shot; if a live sidecar is already
|
|
# listening on the configured port we reuse it.
|
|
|
|
async def _standalone_send(
|
|
pconfig: PlatformConfig,
|
|
chat_id: str,
|
|
message: str,
|
|
*,
|
|
thread_id: Optional[str] = None, # noqa: ARG001 — Spectrum has no threads yet
|
|
media_files: Optional[list] = None, # noqa: ARG001 — attachment send not supported yet
|
|
force_document: bool = False, # noqa: ARG001
|
|
) -> Dict[str, Any]:
|
|
if not HTTPX_AVAILABLE:
|
|
return {"error": "httpx not installed"}
|
|
port = _coerce_port(
|
|
(pconfig.extra or {}).get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
|
|
_DEFAULT_SIDECAR_PORT,
|
|
)
|
|
token = os.getenv("PHOTON_SIDECAR_TOKEN")
|
|
if not token:
|
|
return {
|
|
"error": (
|
|
"Photon standalone send requires a running sidecar with "
|
|
"PHOTON_SIDECAR_TOKEN set in the environment. Cron processes "
|
|
"cannot spawn the sidecar themselves."
|
|
)
|
|
}
|
|
body: Dict[str, Any] = {"spaceId": chat_id, "text": message[:_MAX_MESSAGE_LENGTH]}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.post(
|
|
f"http://{_DEFAULT_SIDECAR_BIND}:{port}/send",
|
|
json=body,
|
|
headers={"X-Hermes-Sidecar-Token": token},
|
|
)
|
|
if resp.status_code != 200:
|
|
return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"}
|
|
data = resp.json() or {}
|
|
if not data.get("ok"):
|
|
return {"error": data.get("error") or "sidecar reported failure"}
|
|
return {"success": True, "message_id": data.get("messageId")}
|
|
except Exception as e:
|
|
return {"error": f"Photon standalone send failed: {e}"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin entry point
|
|
|
|
def register(ctx) -> None:
|
|
"""Called by the Hermes plugin loader at startup."""
|
|
ctx.register_platform(
|
|
name="photon",
|
|
label="Photon iMessage",
|
|
adapter_factory=lambda cfg: PhotonAdapter(cfg),
|
|
check_fn=check_requirements,
|
|
validate_config=validate_config,
|
|
is_connected=is_connected,
|
|
required_env=["PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET"],
|
|
install_hint=(
|
|
"Run: hermes photon setup (logs in via device flow, creates a "
|
|
"Spectrum project, links your phone number, installs the "
|
|
"spectrum-ts sidecar)."
|
|
),
|
|
env_enablement_fn=_env_enablement,
|
|
cron_deliver_env_var="PHOTON_HOME_CHANNEL",
|
|
standalone_sender_fn=_standalone_send,
|
|
allowed_users_env="PHOTON_ALLOWED_USERS",
|
|
allow_all_env="PHOTON_ALLOW_ALL_USERS",
|
|
max_message_length=_MAX_MESSAGE_LENGTH,
|
|
emoji="📱",
|
|
# iMessage carries E.164 phone numbers — treat session descriptions
|
|
# as PII-sensitive so they get redacted in logs.
|
|
pii_safe=False,
|
|
allow_update_command=True,
|
|
platform_hint=(
|
|
"You are communicating via Photon Spectrum (iMessage). "
|
|
"Treat replies like regular text messages — short, friendly, no "
|
|
"markdown rendering. Recipient identifiers are E.164 phone "
|
|
"numbers; never expose them in responses unless the user asked. "
|
|
"Attachments arrive as metadata only (no download URL yet)."
|
|
),
|
|
)
|
|
|
|
# Register CLI subcommands — `hermes photon ...`
|
|
from . import cli as _cli # local import to avoid argparse at module load
|
|
|
|
ctx.register_cli_command(
|
|
name="photon",
|
|
help="Set up and manage the Photon iMessage integration",
|
|
setup_fn=_cli.register_cli,
|
|
handler_fn=_cli.dispatch,
|
|
)
|