Files
hermes-agent/plugins/platforms/photon/adapter.py
Teknium 4d1880e0bf fix(integration): restore subprocess encoding/stdin guards dropped in simplification
Simplification workers collapsed subprocess call sites into shared kwargs
helpers and dropped the Windows/TUI safety kwargs on the way:

- encoding='utf-8', errors='replace' restored on text=True runs in
  copilot_acp_client, hermes_cli/setup (vercel install), managed_uv
  (codesign steps), local_runtime/hardware._stdout, a2a adapter.
- stdin=subprocess.DEVNULL restored on copilot probe, verify/runner
  _SUBPROCESS_KW, iron_proxy._run, google_meet playwright/system_profiler,
  simplex convert, whatsapp _RUN_TEXT, mem0 ollama serve Popen.
  google_meet sudo/brew install keeps inherited stdin (user-confirmed,
  may prompt) — marked noqa: subprocess-stdin.
- Windows-safe SIGKILL: getattr(signal, 'SIGKILL', SIGTERM) in
  verify/runner; photon _kill call re-marked windows-footgun: ok
  (unreachable on win32).
- scripts/check_subprocess_stdin.py now recognizes **kwargs splats
  (**_KW / **_kw(...)) ONLY when the same-file definition provably sets
  stdin= — covers tui_gateway _capture_run_kwargs/run_kw. Parity test added.
2026-09-02 16:36:10 -07:00

1919 lines
87 KiB
Python

"""Photon Spectrum (iMessage) platform adapter.
Both directions flow through a supervised Node sidecar (``sidecar/index.mjs``) running
the TypeScript-only ``spectrum-ts`` SDK. Inbound: the SDK's gRPC stream re-emitted as
NDJSON over loopback ``GET /inbound`` (no webhook / public URL). Outbound: loopback
POSTs to the sidecar's control endpoints with a shared bearer token.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import re
import secrets
import shutil
import signal
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urlparse
if TYPE_CHECKING:
# Type checkers see httpx as always-imported; runtime keeps it optional
# (every use site is guarded by HTTPX_AVAILABLE).
import httpx
HTTPX_AVAILABLE = True
else:
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError: # pragma: no cover - httpx is already a Hermes dep
HTTPX_AVAILABLE = False
httpx = None
from gateway.config import Platform, PlatformConfig
from gateway.platforms._shared import coerce_port as _coerce_port
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
from gateway.platforms.helpers import compile_mention_patterns, strip_markdown
from .auth import load_project_credentials
# Sidecar dir resolution is deliberately lazy (never at import): it probes the
# filesystem and may mirror files. Tests monkeypatch sidecar_paths._SIDECAR_DIR.
from .sidecar_paths import _NPM_ERROR_LOG_MAX_CHARS, _lock_newer_than_install, _npm_error_log, _sidecar_dir
from .sidecar_paths import dir_writable as _dir_writable
import contextlib
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
_DEFAULT_SIDECAR_PORT = 8789
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
# iMessage caps practical message size at ~16 KB; conservative cap matching BlueBubbles.
_MAX_MESSAGE_LENGTH = 8000
# Out-of-process senders (cron, `hermes send`, dashboard) go through
# ``_standalone_send`` and need the live sidecar's port + token, which is
# generated at spawn time. The gateway persists this record once /healthz
# passes and removes it on every stop / failed-start path.
_RUNTIME_RECORD_NAME = "photon-sidecar.json"
# Dedup: the gRPC stream is at-least-once and a reconnect can replay.
_DEDUP_MAX_SIZE = 4000
_DEDUP_WINDOW_SECONDS = 48 * 3600
_FFFC_WAIT_SECONDS = 15.0 # wait for the real attachment after a U+FFFC placeholder
# Cap on a self-heal `npm ci`; a wedged npm must not stall connect indefinitely.
_NPM_REINSTALL_TIMEOUT = 600
# Photon / Envoy / spectrum-ts substrings meaning transient upstream overload.
_PHOTON_RETRYABLE_PATTERNS = (
"internal sidecar error", "upstream connect error", "upstream unavailable", "connection dropped",
"reset reason: overflow", "upstream_overflow", "upstream_unavailable",
)
# iMessage may emit Open Graph preview art as image attachments right after a
# URL/richlink message; suppress those so Hermes sees the link once.
_RICHLINK_PREVIEW_SUPPRESS_SECONDS = 30.0
_RICHLINK_PREVIEW_ATTACHMENT_SUFFIX = ".pluginpayloadattachment"
# Min seconds between typing calls per chat (reduces gRPC pressure during overflow).
_TYPING_COOLDOWN_SECONDS = 5.0
# Group-chat wake words — same defaults as BlueBubbles so both iMessage adapters gate alike.
_DEFAULT_MENTION_PATTERNS = [r"(?<![\w@])@?hermes\s+agent\b[,:\-]?", r"(?<![\w@])@?hermes\b[,:\-]?"]
# Shared/free-tier lines can only reply to conversations the target initiated.
_TARGET_NOT_ALLOWED_MESSAGE = (
"shared/free-tier Photon lines cannot initiate outbound sends to new "
"targets — upgrade to a dedicated line or use another delivery channel"
)
# ---------------------------------------------------------------------------
# Sidecar runtime record
def _runtime_record_path() -> Path:
from hermes_constants import get_hermes_home # honors profile overrides
return get_hermes_home() / "runtime" / _RUNTIME_RECORD_NAME
def _write_runtime_record(port: int, token: str, pid: int) -> None:
"""Atomically persist ``{port, token, pid}`` with owner-only perms (best-effort)."""
import tempfile
try:
path = _runtime_record_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".photon-sidecar.", suffix=".tmp")
try:
# Restrict perms BEFORE the token hits disk (belt-and-braces over mkstemp's 0600).
try:
os.chmod(tmp, 0o600)
except OSError: # pragma: no cover - Windows / odd fs
pass
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump({"port": port, "token": token, "pid": pid}, fh)
os.replace(tmp, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
except Exception as e:
logger.warning("[photon] failed to write sidecar runtime record: %s", e)
def _read_runtime_record() -> Optional[Dict[str, Any]]:
try:
raw = json.loads(_runtime_record_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return raw if isinstance(raw, dict) else None
def _delete_runtime_record() -> None:
with contextlib.suppress(OSError):
_runtime_record_path().unlink(missing_ok=True)
def _sidecar_pid_alive(pid: Any) -> bool:
"""Best-effort liveness check for the recorded sidecar pid."""
try:
pid_int = int(pid)
except (TypeError, ValueError):
return False
if pid_int <= 0:
return False
try:
from gateway.status import _pid_exists # psutil-backed, Windows-safe
return bool(_pid_exists(pid_int))
except Exception:
pass
if os.name == "posix":
try:
os.kill(pid_int, 0) # windows-footgun: ok — inside os.name == "posix" guard
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
# Windows without psutil: os.kill(pid, 0) is destructive — assume alive and
# let the HTTP send be the arbiter.
return True
# ---------------------------------------------------------------------------
# Errors
class PhotonSidecarStartupError(RuntimeError):
"""Typed startup failure from ``_start_sidecar``. ``retryable`` defaults True; only
deterministic failures (deps can't install, node missing) mark False so they
surface as fatal instead of spinning in the reconnect queue."""
def __init__(self, message: str, *, code: str = "SIDECAR_FAILED", retryable: bool = True) -> None:
self.code = code
self.retryable = retryable
super().__init__(message)
class PhotonSidecarError(RuntimeError):
"""Structured failure returned by the supervised Photon sidecar."""
def __init__(
self, *, path: str, status_code: int, error: str,
error_class: str = "sidecar_error", retryable: bool = False,
) -> None:
self.path = path
self.status_code = status_code
self.error = error
self.error_class = error_class
self.retryable = retryable
super().__init__(
f"Photon sidecar {path} returned {status_code} "
f"({error_class}, retryable={retryable}): {error}"
)
def _sidecar_error_from_response(
path: str, status_code: int, text: str, data: Optional[Dict[str, Any]] = None,
) -> PhotonSidecarError:
if data is None:
try:
parsed = json.loads(text)
except Exception:
parsed = {}
data = parsed if isinstance(parsed, dict) else {}
error = str(data.get("error") or text[:200] or "sidecar error")
error_class = str(data.get("error_class") or "sidecar_error")
retryable = bool(data.get("retryable"))
if error_class == "target_not_allowed":
# Canonical user-facing explanation, never raw upstream error text.
error = _TARGET_NOT_ALLOWED_MESSAGE
retryable = False
return PhotonSidecarError(
path=path, status_code=status_code, error=error, error_class=error_class, retryable=retryable,
)
# ---------------------------------------------------------------------------
# Module-level helpers — also used by check_fn / standalone send
def sidecar_deps_installed() -> bool:
"""True when spectrum-ts is present under node_modules/ (not just node_modules/
itself: npm creates it before aborting on ENOSPC/timeout/EACCES)."""
return (_sidecar_dir() / "node_modules" / "spectrum-ts").exists()
def _coerce(cast: Callable[[Any], Any], value: Any, default: Any) -> Any:
try:
return cast(value)
except (TypeError, ValueError):
return default
def _is_timeout_error(exc: BaseException) -> bool:
"""True when *exc* indicates the request timed out (call hung)."""
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
return True
if HTTPX_AVAILABLE:
timeout_exc = getattr(httpx, "TimeoutException", None)
if timeout_exc is not None and isinstance(exc, timeout_exc):
return True
return "timeout" in type(exc).__name__.lower()
def check_requirements() -> bool:
"""Return True when both Python deps and the Node sidecar are available."""
if not HTTPX_AVAILABLE:
logger.warning("photon: httpx not installed — pip install httpx")
return False
if not shutil.which(_get_scoped_secret("PHOTON_NODE_BIN") or "node"):
logger.warning(
"photon: node binary '%s' not found on PATH",
_get_scoped_secret("PHOTON_NODE_BIN") or "node",
)
return False
if not sidecar_deps_installed():
# Self-install possible at connect time (npm on PATH + writable sidecar dir):
# report available so the gateway creates the adapter and _start_sidecar
# cold-installs — hosted images have no CLI to run `hermes photon setup`.
if bool(shutil.which("npm")) and _dir_writable(_sidecar_dir()):
return True
# DEBUG, not WARNING: this is the normal pre-setup state and check_fn is
# polled from several hot paths (status, config load, API polling).
npm_error = ""
try:
if _npm_error_log().exists():
npm_error = _npm_error_log().read_text(encoding="utf-8").strip()[:_NPM_ERROR_LOG_MAX_CHARS]
except OSError:
pass
hint = f" (last npm error: {npm_error})" if npm_error else ""
logger.debug("photon: spectrum-ts not installed at %s%s — run: hermes photon setup", _sidecar_dir(), hint)
return False
return True
def _sidecar_deps_stale() -> bool:
"""True when node_modules predates the committed lockfile (`hermes update` rewrites
package-lock.json without reinstalling); False if either file is missing."""
return _lock_newer_than_install(_sidecar_dir())
def _reinstall_sidecar_deps() -> None:
"""``npm ci`` (fallback ``npm install``) the sidecar deps; blocking, best-effort —
on failure the stale deps stay and the readiness check reports the real error."""
npm = shutil.which("npm")
if not npm:
logger.warning("[photon] cannot reinstall stale sidecar deps: npm not on PATH")
return
from hermes_cli._subprocess_compat import windows_hide_flags # no console flash on Windows
def _run(verb: str) -> subprocess.CompletedProcess:
return subprocess.run( # noqa: S603
[npm, verb], cwd=str(_sidecar_dir()), capture_output=True, text=True, encoding="utf-8",
errors="replace", check=False, timeout=_NPM_REINSTALL_TIMEOUT, creationflags=windows_hide_flags(),
)
try:
result = _run("ci")
if result.returncode != 0:
logger.warning("[photon] sidecar `npm ci` failed; falling back to `npm install`")
result = _run("install")
except subprocess.TimeoutExpired:
# Wedged npm must not stall connect forever; retried on the next reconnect tick.
logger.error("[photon] sidecar dependency reinstall timed out after %ss", _NPM_REINSTALL_TIMEOUT)
return
if result.returncode != 0:
logger.error(
"[photon] sidecar dependency reinstall failed: %s",
(result.stderr or result.stdout or "").strip(),
)
else:
logger.info("[photon] sidecar dependencies reinstalled from lockfile")
def validate_config(cfg: PlatformConfig) -> bool:
extra = cfg.extra or {}
project_id = extra.get("project_id") or _get_scoped_secret("PHOTON_PROJECT_ID")
project_secret = extra.get("project_secret") or _get_scoped_secret("PHOTON_PROJECT_SECRET")
if not project_id or not project_secret:
stored_id, stored_sec = load_project_credentials() # auth.json fallback
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
(``home_channel`` becomes a ``HomeChannel`` via the core plugin hook)."""
project_id, project_secret = load_project_credentials()
if not (project_id and project_secret):
return None
seed: dict = {"project_id": project_id, "project_secret": project_secret}
home = _get_scoped_secret("PHOTON_HOME_CHANNEL", "").strip()
if home:
seed["home_channel"] = {
"chat_id": home,
"name": _get_scoped_secret("PHOTON_HOME_CHANNEL_NAME", "Home"),
}
return seed
def _markdown_enabled() -> bool:
"""Send replies as markdown (spectrum-ts ``markdown()``); ``PHOTON_MARKDOWN=false``
is the kill-switch back to stripped plain text (rendering can't be unit-tested)."""
return _get_scoped_secret("PHOTON_MARKDOWN", "true").strip().lower() not in {"false", "0", "no"}
def _url_only_candidate(text: str) -> Optional[str]:
candidate = (text or "").strip()
if not re.fullmatch(r"https?://\S+", candidate, flags=re.IGNORECASE):
return None
try:
parsed = urlparse(candidate)
except ValueError:
return None
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
return None
return candidate
def _richlink_candidate(text: str) -> Optional[str]:
"""URL to send via ``richlink()`` — only exact http(s) URL messages; prose with
URLs and Markdown links stay on the text path so labels aren't dropped."""
if not _markdown_enabled():
return None
return _url_only_candidate(text)
def _format_richlink_content(content: Dict[str, Any]) -> str:
url = str(content.get("url") or "").strip()
title = str(content.get("title") or "").strip()
summary = str(content.get("summary") or "").strip()
parts: List[str] = []
if title:
parts.append(title)
if summary and summary != title:
parts.append(summary)
if url:
parts.append(url)
return "\n".join(parts) if parts else "[Photon rich link received with no URL]"
def _group_item_contents(content: Dict[str, Any]) -> List[Dict[str, Any]]:
"""The dict ``content`` of every well-formed item in a ``group`` payload."""
out: List[Dict[str, Any]] = []
for item in content.get("items") or []:
item_content = item.get("content") if isinstance(item, dict) else None
if isinstance(item_content, dict):
out.append(item_content)
return out
def _richlink_url_from_content(content: Dict[str, Any]) -> Optional[str]:
ctype = content.get("type")
if ctype == "text":
return _url_only_candidate(content.get("text") or "")
if ctype == "richlink":
return _url_only_candidate(content.get("url") or "")
if ctype == "group":
for item_content in _group_item_contents(content):
url = _richlink_url_from_content(item_content)
if url:
return url
return None
def _is_richlink_preview_attachment(payload: Dict[str, Any]) -> bool:
# Live preview art can carry an opaque MIME (application/octet-stream); the
# name/id marker is the reliable signal, the recent-link window guards real files.
if payload.get("type") != "attachment":
return False
name = str(payload.get("name") or "").lower()
attachment_id = str(payload.get("id") or "").lower()
return (
_RICHLINK_PREVIEW_ATTACHMENT_SUFFIX in name
or _RICHLINK_PREVIEW_ATTACHMENT_SUFFIX in attachment_id
)
def _richlink_preview_label(content: Dict[str, Any]) -> str:
if content.get("type") == "attachment":
return str(content.get("name") or content.get("id") or "(unnamed)")
if content.get("type") == "group":
labels = [str(c.get("name") or c.get("id") or "(unnamed)") for c in _group_item_contents(content)]
return ", ".join(labels) or "(group)"
return "(unknown)"
def _is_richlink_preview_content(content: Dict[str, Any]) -> bool:
"""A preview attachment, or a non-empty group made ONLY of preview attachments."""
if _is_richlink_preview_attachment(content):
return True
if content.get("type") != "group":
return False
items = content.get("items") or []
contents = _group_item_contents(content)
return bool(items) and len(contents) == len(items) and all(
_is_richlink_preview_attachment(c) for c in contents
)
def _parse_timestamp(ts_str: str) -> datetime:
try:
return (
datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
if ts_str
else datetime.now(tz=timezone.utc)
)
except ValueError:
return datetime.now(tz=timezone.utc)
_Normalized = Tuple[str, MessageType, List[str], List[str]] # text, type, media_urls, media_types
def _normalize_binary_payload(payload: Dict[str, Any]) -> _Normalized:
"""Cache an inline attachment/voice payload; fall back to a text marker."""
is_voice = payload.get("type") == "voice"
name = payload.get("name") or ("voice" if is_voice else "(unnamed)")
mime = payload.get("mimeType") or ""
# iMessage voice notes are CAF; the sidecar may send "(unnamed)", so MIME is
# the reliable signal alongside the filename.
if not is_voice and (name.lower().endswith(".caf") or mime == "audio/x-caf"):
is_voice = True
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
cached = _cache_inbound_attachment(payload, name, mime, force_audio=is_voice)
if cached:
return (
"(voice)" if is_voice else "(attachment)",
mtype,
[cached],
[mime or ("audio/mp4" if is_voice else "application/octet-stream")],
)
label = "voice" if is_voice else "attachment"
duration = payload.get("duration")
duration_text = f", duration: {duration}s" if isinstance(duration, (int, float)) else ""
return (f"[Photon {label} received: {name} ({mime or 'unknown MIME'}{duration_text})]", mtype, [], [])
def _normalize_group_content(content: Dict[str, Any]) -> _Normalized:
text_parts: List[str] = []
mtype = MessageType.TEXT
media_urls: List[str] = []
media_types: List[str] = []
for item_content in _group_item_contents(content):
item_type = item_content.get("type")
if item_type == "text":
item_text = item_content.get("text") or ""
if item_text:
text_parts.append(item_text)
elif item_type == "richlink":
text_parts.append(_format_richlink_content(item_content))
elif item_type in {"attachment", "voice"}:
marker, item_mtype, item_urls, item_types = _normalize_binary_payload(item_content)
if mtype == MessageType.TEXT:
mtype = item_mtype
media_urls.extend(item_urls)
media_types.extend(item_types)
if not item_urls:
text_parts.append(marker)
elif item_type:
text_parts.append(f"[Photon content type not handled: {item_type}]")
if media_urls and mtype == MessageType.TEXT:
mtype = MessageType.DOCUMENT
text = "\n".join(part for part in text_parts if part).strip()
if not text:
text = "(attachment)" if media_urls else "[Photon empty group received]"
return text, mtype, media_urls, media_types
def _normalize_content(content: Dict[str, Any]) -> _Normalized:
"""Turn a sidecar ``content`` payload into (text, type, media_urls, media_types)."""
ctype = content.get("type")
if ctype == "text":
return content.get("text") or "", MessageType.TEXT, [], []
if ctype in {"attachment", "voice"}:
return _normalize_binary_payload(content)
if ctype == "richlink":
return _format_richlink_content(content), MessageType.TEXT, [], []
if ctype == "group":
return _normalize_group_content(content)
return f"[Photon content type not handled: {ctype}]", MessageType.TEXT, [], []
def _attachment_body(
space_id: str, safe_path: str, *, kind: str, name: Optional[str] = None,
mime_type: Optional[str] = None, caption: Optional[str] = None,
) -> Dict[str, Any]:
"""``/send-attachment`` body; spectrum-ts infers name/mimeType from the extension,
so optional keys are only sent when Hermes supplied them."""
body: Dict[str, Any] = {"spaceId": space_id, "path": safe_path, "kind": kind}
if name:
body["name"] = name
if mime_type:
body["mimeType"] = mime_type
if caption:
body["caption"] = caption
return body
def _guess_mime(path: str) -> Optional[str]:
import mimetypes
guessed, _ = mimetypes.guess_type(path)
return guessed or None
def _bounded_put(store: Dict[str, Any], key: str, value: Any, max_size: int) -> None:
"""Insert with insertion-order refresh and a HARD size bound (evict oldest)."""
if key in store:
del store[key]
store[key] = value
if len(store) > max_size:
for old in list(store.keys())[: len(store) - max_size]:
del store[old]
async def _cancel_task(task: Optional[asyncio.Task]) -> None:
"""Cancel *task* and wait for it, unless it is the current task."""
if task is None:
return
task.cancel()
if task is not asyncio.current_task():
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
# ---------------------------------------------------------------------------
# Adapter
class PhotonAdapter(BasePlatformAdapter):
"""Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar."""
MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
# iMessage has no edit API; streaming must suppress the cursor rather than
# leave a stale tofu square (▉) when edits fail.
SUPPORTS_MESSAGE_EDITING = False
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("photon"))
extra = config.extra or {}
# Project credentials (env wins, then config.extra, then auth.json).
# ``project_id`` is the spectrumProjectId the spectrum-ts SDK authenticates with.
stored_id, stored_sec = load_project_credentials()
self._project_id: str = _get_scoped_secret("PHOTON_PROJECT_ID") or extra.get("project_id") or stored_id or ""
self._project_secret: str = (
_get_scoped_secret("PHOTON_PROJECT_SECRET") or extra.get("project_secret") or stored_sec or ""
)
# Sidecar
self._sidecar_port = _coerce_port(
extra.get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"), _DEFAULT_SIDECAR_PORT,
)
self._sidecar_bind = _DEFAULT_SIDECAR_BIND
self._sidecar_token = _get_scoped_secret("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16)
autostart = str(_get_scoped_secret("PHOTON_SIDECAR_AUTOSTART", "true")).lower()
self._autostart_sidecar = autostart not in ("0", "false", "no")
self._node_bin = _get_scoped_secret("PHOTON_NODE_BIN") or shutil.which("node") or "node"
# Presence watchdog (second layer behind the sidecar's own zombie-stream
# detection): only respawns when the sidecar's HTTP loop hangs; 10-min
# interval because shared lines are legitimately quiet for hours. Config
# key wins, then env; None-aware so an explicit 0 disables it.
def _setting(key: str, env: str, default: Any, cast: Callable[[Any], Any]) -> Any:
value = extra.get(key)
if value is None:
value = _get_scoped_secret(env)
return _coerce(cast, value, default)
self._probe_interval = _setting("probe_interval_seconds", "PHOTON_PROBE_INTERVAL_SECONDS", 600.0, float)
self._probe_timeout = _setting("probe_timeout_seconds", "PHOTON_PROBE_TIMEOUT_SECONDS", 10.0, float)
self._probe_max_failures = _setting("probe_max_failures", "PHOTON_PROBE_MAX_FAILURES", 3, int)
self._probe_enabled = self._probe_interval > 0
# With markdown on, format_message preserves fences for the sidecar's markdown() builder.
self.supports_code_blocks = _markdown_enabled()
self._sidecar_proc: Optional[subprocess.Popen] = None
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
self._inbound_task: Optional[asyncio.Task] = None
self._sidecar_health_task: Optional[asyncio.Task] = None
self._inbound_running = False
self._http_client: Optional["httpx.AsyncClient"] = None
self._sidecar_health_interval = 15.0
self._watchdog_task: Optional[asyncio.Task] = None
self._watchdog_running = False
self._probe_failures = 0
self._last_upstream_activity = 0.0 # monotonic; watchdog skips probe if traffic proved liveness
self._respawn_lock: Optional[asyncio.Lock] = None
self._seen_messages: Dict[str, float] = {} # at-least-once stream dedup
self._sent_message_ids: Dict[str, float] = {} # only reactions targeting OUR sends are routed
self._last_inbound_by_chat: Dict[str, str] = {} # default target for the react action
self._recent_richlinks_by_chat: Dict[str, float] = {} # coalesce preview-art attachments
self._typing_last_sent: Dict[str, float] = {}
self._pending_fffc: Dict[str, tuple[float, Any]] = {} # chat_key → (timestamp, asyncio.Task)
# Group-chat mention gating (parity with BlueBubbles); DMs are never gated.
_require_mention = extra.get("require_mention")
if _require_mention is None:
_require_mention = _get_scoped_secret("PHOTON_REQUIRE_MENTION")
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
self._mention_patterns = self._compile_mention_patterns(
extra["mention_patterns"]
if "mention_patterns" in extra
else _get_scoped_secret("PHOTON_MENTION_PATTERNS")
)
# -- Group-mention gating (parity with BlueBubbles) -------------------
@staticmethod
def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]":
"""``raw``: list, string (JSON list or comma/newline-separated) or None (defaults)."""
return compile_mention_patterns(
raw, log_prefix="photon", defaults=_DEFAULT_MENTION_PATTERNS, logger_=logger,
)
def _message_matches_mention_patterns(self, text: str) -> bool:
if not text or not self._mention_patterns:
return False
return any(pattern.search(text) for pattern in self._mention_patterns)
def _clean_mention_text(self, text: str) -> str:
"""Strip a leading wake word only (patterns are regexes; never touch later words)."""
if not text:
return text
for pattern in self._mention_patterns:
match = pattern.match(text.lstrip())
if match:
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
return cleaned or text
return text
# -- Sidecar HTTP plumbing ---------------------------------------------
def _sidecar_url(self, path: str) -> str:
return f"http://{self._sidecar_bind}:{self._sidecar_port}{path}"
def _sidecar_headers(self) -> Dict[str, str]:
return {"X-Hermes-Sidecar-Token": self._sidecar_token}
# -- Connection lifecycle ---------------------------------------------
async def connect(self, *, is_reconnect: bool = False) -> bool:
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
client = httpx.AsyncClient(timeout=30.0, trust_env=False)
self._http_client = client
# The sidecar holds the gRPC stream for BOTH directions, so it is required now.
if self._autostart_sidecar:
try:
await self._start_sidecar()
except Exception as e:
# Typed deterministic failures are retryable=False (surface as fatal);
# everything else stays retryable, with the gateway's NEEDS_ATTENTION
# escalation as the backstop for long-lived loops.
if isinstance(e, PhotonSidecarStartupError):
self._set_fatal_error(e.code, str(e), retryable=e.retryable)
else:
self._set_fatal_error(
"SIDECAR_FAILED", f"failed to start Photon sidecar: {e}", retryable=True,
)
_delete_runtime_record() # no live sidecar — don't mislead standalone senders
await client.aclose()
self._http_client = None
return False
else:
logger.warning("[photon] sidecar autostart disabled — inbound + outbound will fail")
loop = asyncio.get_event_loop()
self._inbound_running = True
self._inbound_task = loop.create_task(self._inbound_loop())
self._sidecar_health_task = loop.create_task(self._monitor_sidecar_health())
self._last_upstream_activity = time.monotonic()
if self._probe_enabled and self._autostart_sidecar:
self._respawn_lock = asyncio.Lock()
self._watchdog_running = True
self._watchdog_task = loop.create_task(self._presence_watchdog())
self._mark_connected()
logger.info(
"[photon] connected — sidecar on %s:%d, streaming inbound over gRPC",
self._sidecar_bind, self._sidecar_port,
)
self._wire_plugin_handlers(None) # ctx.register_platform_handler natives
return True
async def disconnect(self) -> None:
self._inbound_running = False
# Stop the watchdog first so it can't respawn while we tear the sidecar down.
await self._stop_watchdog()
task, self._sidecar_health_task = self._sidecar_health_task, None
await _cancel_task(task)
task, self._inbound_task = self._inbound_task, None
await _cancel_task(task)
for _chat_key, (_, fffc_task) in list(self._pending_fffc.items()):
if fffc_task and not fffc_task.done():
fffc_task.cancel()
self._pending_fffc.clear()
await self._stop_sidecar()
if self._http_client is not None:
with contextlib.suppress(Exception):
await self._http_client.aclose()
self._http_client = None
self._mark_disconnected()
def _dispatch_fatal_notification(self) -> None:
"""Notify the gateway of a fatal error from a detached task.
The health/supervisor tasks must NOT ``await self._notify_fatal_error()``
inline: the gateway answers by calling ``disconnect()``, which cancels and
awaits those very tasks. The ``current_task()`` guard doesn't help because
the gateway wraps ``disconnect()`` in its own task, so the CancelledError
(a BaseException) kills the notifying task mid-handoff — no log, no
reconnect. A fresh task (same pattern as DiscordAdapter) can be cancelled
freely without reaching back into the handoff.
"""
asyncio.create_task(self._notify_fatal_error_logged())
async def _notify_fatal_error_logged(self) -> None:
try:
await self._notify_fatal_error()
except Exception as exc: # pragma: no cover - defensive
logger.warning("[photon] fatal-error notification failed: %s", exc)
# -- Inbound stream consumer ------------------------------------------
async def _inbound_loop(self) -> None:
"""Consume the sidecar's ``/inbound`` NDJSON stream, re-opening it if it drops
(the sidecar owns the gRPC reconnect to Photon)."""
client = self._http_client
if client is None:
return
url = self._sidecar_url("/inbound")
headers = self._sidecar_headers()
backoff = 1.0
while self._inbound_running:
try:
async with client.stream("GET", url, headers=headers, timeout=None) as resp:
if resp.status_code != 200:
raise RuntimeError(f"/inbound returned {resp.status_code}")
backoff = 1.0
async for line in resp.aiter_lines():
if not self._inbound_running:
break
line = line.strip()
if not line:
continue # heartbeat
await self._on_inbound_line(line)
except asyncio.CancelledError:
raise
except Exception as e:
if not self._inbound_running:
break
logger.warning("[photon] inbound stream dropped (%s); reconnecting in %.1fs", e, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30.0)
async def _monitor_sidecar_health(self) -> None:
"""Promote a degraded upstream stream (per ``/healthz``) into a reconnect, so a
live sidecar HTTP process with a dead gRPC stream isn't a silent outage."""
while self._inbound_running:
await asyncio.sleep(self._sidecar_health_interval)
if not self._inbound_running:
break
try:
data = await self._sidecar_call("/healthz", {})
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug("[photon] sidecar health check failed: %s", exc)
continue
stream = data.get("stream") if isinstance(data, dict) else None
if not isinstance(stream, dict):
continue
# Loud log line for a suspected zombie stream even before the sidecar's
# degraded->exit-75 path fires.
staleness = stream.get("staleness")
if isinstance(staleness, dict) and staleness.get("zombieSuspected") is True:
logger.warning(
"[photon] sidecar reports suspected zombie stream"
" (silentForMs=%s, lastProbeOutcome=%s)",
staleness.get("silentForMs"),
staleness.get("lastProbeOutcome"),
)
if stream.get("ok") is not False:
continue
message = (
f"Photon upstream stream degraded (state={stream.get('state') or 'unknown'}, "
f"degradedForMs={stream.get('degradedForMs')}): "
f"{stream.get('lastIssue') or 'unknown stream issue'}"
)
logger.error("[photon] %s", message)
self._set_fatal_error("UPSTREAM_STREAM_DEGRADED", message, retryable=True)
self._dispatch_fatal_notification()
break
async def _on_inbound_line(self, line: str) -> None:
# Any inbound line proves the upstream gRPC channel is live.
self._note_upstream_activity()
try:
event = json.loads(line)
except json.JSONDecodeError:
logger.debug("[photon] skipping non-JSON inbound line")
return
msg_id = event.get("messageId")
if msg_id and self._is_duplicate(msg_id):
return
try:
await self._dispatch_inbound(event)
except Exception:
logger.exception("[photon] inbound dispatch failed")
def _is_duplicate(self, msg_id: str) -> bool:
now = time.time()
t = self._seen_messages.get(msg_id)
if t is not None and now - t < _DEDUP_WINDOW_SECONDS:
return True
_bounded_put(self._seen_messages, msg_id, now, _DEDUP_MAX_SIZE)
return False
async def _fffc_timeout_handler(self, chat_key: str, message_id: str) -> None:
await asyncio.sleep(_FFFC_WAIT_SECONDS)
if self._pending_fffc.pop(chat_key, None):
logger.warning(
"[photon] wait for attachment was too long, can't retrieve attachment data "
"(message %s, chat %s)", message_id, chat_key,
)
def _cancel_pending_fffc(self, chat_key: str) -> bool:
"""Pop and cancel a pending U+FFFC timeout; True when a live task was cancelled."""
prev = self._pending_fffc.pop(chat_key, None)
if prev and prev[1] and not prev[1].done():
prev[1].cancel()
return True
return False
async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
"""Normalize a sidecar inbound event and dispatch it to the gateway.
Event: ``{messageId, space: {id, type: dm|group, phone}, sender: {id},
content: {type: text|attachment|voice|reaction|richlink|group|poll_option|
read, ...}, timestamp}``. Attachment/voice bytes arrive inline as base64
``data`` when under the sidecar's cap; otherwise metadata only → marker.
"""
space = event.get("space") or {}
sender = event.get("sender") or {}
content = event.get("content") or {}
space_id = space.get("id") or ""
if not space_id:
logger.warning("[photon] inbound missing space.id")
return
chat_type = "group" if space.get("type") == "group" else "dm"
sender_id = sender.get("id") or space.get("phone") or space_id
timestamp = _parse_timestamp(event.get("timestamp") or "")
message_id = event.get("messageId")
ctype = content.get("type")
def _event(text: str, mtype: MessageType = MessageType.TEXT, **kwargs: Any) -> MessageEvent:
source = self.build_source(
chat_id=space_id,
chat_name=space_id,
chat_type=chat_type,
user_id=sender_id,
user_name=sender_id or None,
)
return MessageEvent(
text=text, message_type=mtype, source=source, message_id=message_id,
raw_message=event, timestamp=timestamp, **kwargs,
)
if ctype in {"read", "read_receipt"}:
# Presence signal, not a user turn (sidecar only forwards receipts for our sends).
logger.debug("[photon] outbound message read: %s", content.get("targetMessageId") or "unknown")
return
if ctype == "reaction":
# Only tapbacks on messages WE sent are addressed to the bot. Checked
# before the mention gate: a tapback never carries a wake word.
target_id = content.get("targetMessageId")
is_ours = content.get("targetDirection") == "outbound" or (
target_id and target_id in self._sent_message_ids
)
if not is_ours:
logger.debug("[photon] ignoring reaction on a message we didn't send")
return
# reply_to_is_own_message holds by construction, so the gateway injects
# `[Replying to your previous message: "..."]` when targetText is present.
await self.handle_message(_event(
f"reaction:added:{content.get('emoji') or ''}",
reply_to_message_id=target_id,
reply_to_text=content.get("targetText") or None,
reply_to_is_own_message=True,
))
return
# U+FFFC placeholder: wait for the real attachment. Detected before
# _record_last_inbound so the placeholder isn't the reaction target.
if ctype == "text" and (content.get("text") or "").strip() == "\ufffc":
self._cancel_pending_fffc(space_id)
task = asyncio.create_task(self._fffc_timeout_handler(space_id, message_id or ""))
self._pending_fffc[space_id] = (time.monotonic(), task)
logger.debug("[photon] U+FFFC placeholder received — waiting for attachment")
return
if ctype in {"attachment", "voice"} and self._cancel_pending_fffc(space_id):
logger.debug("[photon] attachment arrived — cancelling U+FFFC timeout")
# Preview art for a just-received URL must not become a second user prompt;
# suppress before recording it as reactable or decoding image bytes.
if self._is_recent_richlink_preview(space_id, content):
logger.info(
"[photon] suppressing rich-link preview attachment: %s",
_richlink_preview_label(content),
)
return
# Everything past here is a real (reactable) message. Recorded before the
# mention gate: reacting to a non-wake-word group message is valid.
self._record_last_inbound(space_id, message_id)
if ctype == "poll_option":
# Native poll vote: a selection is forwarded as if typed (the gateway's
# pending-clarify intercept resolves it); a deselection is dropped.
if content.get("selected") is False:
logger.debug("[photon] ignoring poll deselection")
return
choice = (content.get("title") or "").strip()
if not choice:
logger.debug("[photon] ignoring poll vote with empty title")
return
await self.handle_message(_event(choice))
return
text, mtype, media_urls, media_types = _normalize_content(content)
if chat_type == "group" and self.require_mention:
if not self._message_matches_mention_patterns(text):
logger.debug(
"[photon] ignoring group message "
"(require_mention=true, no mention pattern matched)"
)
return
text = self._clean_mention_text(text)
self._record_recent_richlink(space_id, _richlink_url_from_content(content) or text)
await self.handle_message(_event(text, mtype, media_urls=media_urls, media_types=media_types))
# -- Sidecar lifecycle -------------------------------------------------
@staticmethod
def _quick_stdout(cmd: List[str]) -> Optional[str]:
"""stdout of a short shell-out, or None if it failed to run."""
try:
out = subprocess.run( # noqa: S603, S607
cmd, capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=5.0, check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
return out.stdout
@classmethod
def _find_listener_pids(cls, port: int) -> List[int]:
"""PIDs listening on a local TCP port (empty if none/undeterminable)."""
out = cls._quick_stdout(["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"])
if out is None:
return []
return [int(tok) for tok in out.split() if tok.strip().isdigit()]
@classmethod
def _pid_is_sidecar(cls, pid: int) -> bool:
"""True if ``pid``'s command line is a Photon sidecar (any Hermes checkout)."""
out = cls._quick_stdout(["ps", "-p", str(pid), "-o", "command="])
return out is not None and "photon/sidecar/index.mjs" in out
@staticmethod
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0) # windows-footgun: ok — only called from _reap_stale_sidecar which win32-guards early
return True
except OSError:
return False
async def _reap_stale_sidecar(self) -> None:
"""Kill an orphaned sidecar squatting our port (a SIGKILLed gateway leaves one
whose token we don't know, so every respawn dies on EADDRINUSE). Listeners are
verified by command line before being signalled."""
if sys.platform == "win32": # lsof/ps; orphaning is a POSIX-only path
return
try:
async with httpx.AsyncClient(timeout=2.0, trust_env=False) as client:
await client.post(self._sidecar_url("/healthz"), headers=self._sidecar_headers())
except httpx.RequestError:
return # nothing listening — the normal case
# Off the event loop: lsof + one `ps` per pid can hold the loop 5+5·N s,
# and this runs on every reconnect of a live gateway.
def _inspect():
found = self._find_listener_pids(self._sidecar_port)
mine = [pid for pid in found if self._pid_is_sidecar(pid)]
return mine, [pid for pid in found if pid not in mine]
stale, foreign = await asyncio.to_thread(_inspect)
fix = "free it or set PHOTON_SIDECAR_PORT to a different port"
if not stale:
raise RuntimeError(
f"port {self._sidecar_port} is in use by another process "
f"(pids: {foreign or 'unknown'}, not a Photon sidecar) — {fix}"
)
def _kill(pid: int, sig: int) -> None:
try:
os.kill(pid, sig) # windows-footgun: ok — unreachable on win32 (early return above)
except OSError:
pass
for pid in stale:
logger.warning("[photon] reaping orphaned sidecar (pid %d) on port %d", pid, self._sidecar_port)
_kill(pid, signal.SIGTERM)
deadline = time.time() + 3.0
while time.time() < deadline and any(self._pid_alive(p) for p in stale):
await asyncio.sleep(0.1)
for pid in stale:
if self._pid_alive(pid):
_kill(pid, signal.SIGKILL) # windows-footgun: ok — unreachable on win32 (early return above)
await asyncio.sleep(0.2) # let the OS release the listening socket
if foreign:
raise RuntimeError(
f"port {self._sidecar_port} is also held by non-sidecar processes (pids: {foreign}) — {fix}"
)
async def _ensure_sidecar_deps(self) -> None:
"""Cold-install or refresh sidecar node_modules before spawn (off the loop)."""
if not sidecar_deps_installed():
# Hosted images have no CLI for `hermes photon setup`, so connect must
# bootstrap deps itself (into the writable resolved dir).
logger.info("[photon] sidecar deps not installed; installing into %s", _sidecar_dir())
await asyncio.to_thread(_reinstall_sidecar_deps)
if not sidecar_deps_installed():
# Deterministic on immutable images — non-retryable so it doesn't spin silently.
raise PhotonSidecarStartupError(
f"Photon sidecar deps could not be installed into "
f"{_sidecar_dir()} (see log for the npm error). "
f"Run: cd {_sidecar_dir()} && npm ci (or `hermes photon setup`)",
code="SIDECAR_DEPS_MISSING",
retryable=False,
)
# `hermes update` bumps the lockfile without reinstalling node_modules, so
# the sidecar would spawn against stale deps and die on every reconnect.
if _sidecar_deps_stale():
logger.warning(
"[photon] sidecar deps are stale (lockfile newer than install); "
"reinstalling before start"
)
await asyncio.to_thread(_reinstall_sidecar_deps)
async def _apply_spectrum_patch(self, hide_flags: int) -> None:
"""Run the mixed-attachment patch script (best-effort, off the loop: node
spawn + up to 10s wait, on every reconnect)."""
try:
patch = await asyncio.to_thread(
subprocess.run, # noqa: S603
[self._node_bin, str(_sidecar_dir() / "patch-spectrum-mixed-attachments.mjs"), str(_sidecar_dir())],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10, check=False,
creationflags=hide_flags,
)
if patch.returncode != 0:
raise RuntimeError((patch.stderr or patch.stdout or "").strip())
if patch.stderr.strip():
logger.debug("[photon] %s", patch.stderr.strip())
except Exception as exc:
logger.warning("[photon] failed to apply Spectrum mixed attachment patch: %s", exc)
async def _start_sidecar(self) -> None:
await self._ensure_sidecar_deps()
await self._reap_stale_sidecar()
env = os.environ.copy()
env.update({
"PHOTON_PROJECT_ID": self._project_id,
"PHOTON_PROJECT_SECRET": self._project_secret,
"PHOTON_SIDECAR_PORT": str(self._sidecar_port),
"PHOTON_SIDECAR_BIND": self._sidecar_bind,
"PHOTON_SIDECAR_TOKEN": self._sidecar_token,
# Exit on stdin EOF so ANY gateway death (incl. SIGKILL) can't orphan it on the port.
"PHOTON_SIDECAR_WATCH_STDIN": "1",
})
from hermes_cli._subprocess_compat import windows_hide_flags # hide child console on Windows
await self._apply_spectrum_patch(windows_hide_flags())
try:
self._sidecar_proc = subprocess.Popen( # noqa: S603
[self._node_bin, str(_sidecar_dir() / "index.mjs")],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
start_new_session=(sys.platform != "win32"),
creationflags=windows_hide_flags(), # CREATE_NO_WINDOW only (no DETACHED_PROCESS): pipes stay usable
)
except FileNotFoundError as exc:
# Deterministic: retrying can never fix a missing binary.
raise PhotonSidecarStartupError(
f"node binary not found ({self._node_bin!r}) — install Node.js "
f"or set PHOTON_NODE_BIN: {exc}",
code="SIDECAR_NODE_MISSING",
retryable=False,
) from exc
loop = asyncio.get_event_loop()
self._sidecar_supervisor_task = loop.create_task(self._supervise_sidecar(self._sidecar_proc))
# Wait for /healthz — up to 15s on cold start.
deadline = time.time() + 15.0
last_err: Optional[Exception] = None
async with httpx.AsyncClient(timeout=2.0, trust_env=False) as client:
while time.time() < deadline:
if self._sidecar_proc.poll() is not None:
_delete_runtime_record()
raise RuntimeError(
f"Photon sidecar exited with code "
f"{self._sidecar_proc.returncode} before becoming ready"
)
try:
resp = await client.post(self._sidecar_url("/healthz"), headers=self._sidecar_headers())
if resp.status_code == 200:
# Let out-of-process senders (cron, `hermes send`) reach this sidecar.
_write_runtime_record(self._sidecar_port, self._sidecar_token, self._sidecar_proc.pid)
return
except httpx.RequestError as e:
last_err = e
await asyncio.sleep(0.2)
_delete_runtime_record()
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."""
if proc.stdout is None: # launched without stdout=PIPE
return
stdout = proc.stdout
loop = asyncio.get_event_loop()
try:
while True:
line = await loop.run_in_executor(None, 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)
if self._inbound_running:
exit_code = proc.poll()
logger.error("[photon] sidecar exited unexpectedly (code %s) — triggering reconnect", exit_code)
self._set_fatal_error(
"SIDECAR_CRASHED",
f"Photon sidecar exited unexpectedly (code {exit_code})",
retryable=True,
)
self._dispatch_fatal_notification()
async def _stop_sidecar(self) -> None:
proc = self._sidecar_proc
if proc is None:
_delete_runtime_record() # never leave a record behind on disconnect
return
try:
# Closing our stdin end is itself a shutdown signal (sidecar watches EOF).
if proc.stdin is not None:
with contextlib.suppress(Exception):
proc.stdin.close()
if self._http_client is not None: # polite shutdown first
with contextlib.suppress(Exception):
await self._http_client.post(
self._sidecar_url("/shutdown"), headers=self._sidecar_headers(), timeout=2.0,
)
try:
proc.wait(timeout=3.0)
except subprocess.TimeoutExpired:
if sys.platform != "win32":
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) # windows-footgun: ok
except (ProcessLookupError, PermissionError):
proc.terminate()
else:
proc.terminate()
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
proc.kill()
finally:
self._sidecar_proc = None
_delete_runtime_record()
if self._sidecar_supervisor_task is not None:
# This may run INSIDE the supervisor task's own crash-handling chain
# (sidecar exit -> fatal notify -> gateway disconnect() -> here). A
# task cancelling itself raises CancelledError (BaseException) into
# the fatal-error handler before it queues the reconnect, leaving
# Photon permanently dead — so let it finish exiting on its own.
if self._sidecar_supervisor_task is not asyncio.current_task():
self._sidecar_supervisor_task.cancel()
self._sidecar_supervisor_task = None
# -- Presence watchdog -------------------------------------------------
def _note_upstream_activity(self) -> None:
"""Record proof the upstream gRPC channel is live (inbound line or good probe)."""
self._last_upstream_activity = time.monotonic()
self._probe_failures = 0
async def _probe_once(self) -> str:
"""One ``/probe`` round-trip → ``"alive"`` (HTTP 200, the only proof of liveness),
``"hung"`` (the HTTP call itself timed out; counts toward respawn) or
``"inconclusive"`` (503/refused/transport error: never counts either way — the
network may just be down and a dead process is the supervisor's job)."""
client = self._http_client
if client is None:
return "inconclusive"
try:
resp = await client.post(
self._sidecar_url("/probe"), headers=self._sidecar_headers(), timeout=self._probe_timeout,
)
except asyncio.CancelledError:
raise
except Exception as e:
if _is_timeout_error(e):
logger.debug("[photon] probe HTTP call hung: %s", e)
return "hung"
logger.debug("[photon] probe transport error (inconclusive): %s", e)
return "inconclusive"
return "alive" if resp.status_code == 200 else "inconclusive"
async def _respawn_sidecar(self, reason: str) -> None:
"""Restart the sidecar to recover a dead gRPC stream (a fresh ``Spectrum()``
re-subscribes; the inbound loop re-opens ``/inbound`` on its own). Locked so
overlapping triggers can't double-spawn."""
lock = self._respawn_lock
if lock is None:
lock = self._respawn_lock = asyncio.Lock()
if lock.locked():
logger.info("[photon] respawn already in progress; skipping")
return
async with lock:
logger.warning("[photon] presence watchdog: %s — respawning sidecar", reason)
try:
await self._stop_sidecar()
except Exception:
logger.exception("[photon] error stopping sidecar during respawn")
try:
await self._start_sidecar()
except Exception:
logger.exception("[photon] failed to respawn sidecar; watchdog will retry")
return
# Fresh stream: give it a full interval before probing again.
self._note_upstream_activity()
logger.info("[photon] presence watchdog: sidecar respawned, gRPC stream renewed")
async def _presence_watchdog(self) -> None:
"""Probe on a long interval, skipping when inbound traffic already proved
liveness; only *hung* probes count toward respawn (``_probe_max_failures``)."""
# Stagger the first probe (fleet restarts, sidecar warm-up).
await asyncio.sleep(self._probe_interval)
while self._watchdog_running:
try:
idle = time.monotonic() - self._last_upstream_activity
if idle < self._probe_interval:
await asyncio.sleep(self._probe_interval - idle)
continue
verdict = await self._probe_once()
if verdict == "alive":
self._note_upstream_activity()
elif verdict == "hung":
self._probe_failures += 1
logger.warning(
"[photon] presence probe hung (%d/%d)",
self._probe_failures, self._probe_max_failures,
)
if self._probe_failures >= self._probe_max_failures:
await self._respawn_sidecar(f"{self._probe_failures} consecutive hung probes")
else:
logger.debug("[photon] presence probe inconclusive; taking no action")
except asyncio.CancelledError:
raise
except Exception:
logger.exception("[photon] presence watchdog iteration failed")
await asyncio.sleep(self._probe_interval)
async def _stop_watchdog(self) -> None:
self._watchdog_running = False
task, self._watchdog_task = self._watchdog_task, None
await _cancel_task(task)
# -- 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, self.format_message(content))
# -- Clarify: multiple-choice renders as a native poll; the vote comes back as a
# `poll_option` event that _dispatch_inbound turns into plain text, so the clarify
# is flipped into text-capture mode like the base fallback.
async def send_clarify(
self, chat_id: str, question: str, choices: Optional[list], clarify_id: str,
session_key: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
if not choices: # open-ended: base plain-text behaviour is right
return await super().send_clarify(chat_id, question, choices, clarify_id, session_key, metadata)
from tools.clarify_gateway import mark_awaiting_text
mark_awaiting_text(clarify_id)
result = await self._sidecar_send_poll(chat_id, question, list(choices))
if not result.success:
# Old sidecar without /send-poll or a send error: fall back to the
# numbered-text clarify (base also calls mark_awaiting_text; harmless).
logger.warning("[photon] poll clarify failed (%s); falling back to text list", result.error)
return await super().send_clarify(chat_id, question, choices, clarify_id, session_key, metadata)
return result
# -- Outbound media (parity with BlueBubbles): URL-based helpers cache to a local
# path first; file-based ones pass the path straight to /send-attachment.
async def send_image(
self, chat_id: str, image_url: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
try:
from gateway.platforms.base import cache_image_from_url
local_path = await cache_image_from_url(image_url)
except Exception: # couldn't fetch — send the URL as text
return await super().send_image(chat_id, image_url, caption, reply_to)
return await self._sidecar_send_attachment(chat_id, local_path, caption=caption)
async def send_image_file(
self, chat_id: str, image_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(chat_id, image_path, caption=caption)
async def send_voice(
self, chat_id: str, audio_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(chat_id, audio_path, caption=caption, kind="voice")
async def send_video(
self, chat_id: str, video_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(chat_id, video_path, caption=caption)
async def send_document(
self, chat_id: str, file_path: str, caption: Optional[str] = None,
file_name: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, **kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(chat_id, file_path, name=file_name, caption=caption)
async def send_animation(
self, chat_id: str, animation_url: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
# iMessage renders GIFs inline as ordinary image attachments.
return await self.send_image(chat_id, animation_url, caption, reply_to, metadata)
async def send_poll(
self, chat_id: str, title: str, options: list[str], metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a native iMessage poll (thin wrapper over ``_sidecar_send_poll``)."""
return await self._sidecar_send_poll(chat_id, title, list(options or []))
async def _sidecar_try(self, path: str, body: Dict[str, Any], what: str) -> bool:
"""Soft-failing sidecar call: True on success, False (debug-logged) on any error."""
try:
await self._sidecar_call(path, body)
return True
except Exception as e:
logger.debug("[photon] %s failed: %s", what, e)
return False
async def send_typing(self, chat_id: str, metadata=None) -> None:
now = time.time()
if now - self._typing_last_sent.get(chat_id, 0.0) < _TYPING_COOLDOWN_SECONDS:
return
self._typing_last_sent[chat_id] = now
await self._sidecar_try("/typing", {"spaceId": chat_id, "state": "start"}, "send_typing")
async def stop_typing(self, chat_id: str) -> None:
self._typing_last_sent.pop(chat_id, None)
await self._sidecar_try("/typing", {"spaceId": chat_id, "state": "stop"}, "stop_typing")
# -- Reactions (tapbacks). Lifecycle hooks (👀 while processing, 👍/👎 on
# completion) are opt-in via PHOTON_REACTIONS — noisy on a personal channel.
_SENT_IDS_MAX = 1000
_LAST_INBOUND_CHATS_MAX = 200
def _record_sent_message(self, message_id: Optional[str]) -> None:
if message_id:
_bounded_put(self._sent_message_ids, message_id, time.time(), self._SENT_IDS_MAX)
# A DM space is addressable as the chat GUID (`any;-;+1555...`) inbound events
# carry, or the bare E.164 phone home-channel config uses; the sidecar's
# resolveSpace treats them as one space, so normalize to the bare phone
# (mirrors phoneTargetFromSpaceId in sidecar/index.mjs).
_DM_CHAT_GUID_RE = re.compile(r"^any;-;(\+\d{6,})$")
@classmethod
def _normalize_chat_key(cls, chat_id: str) -> str:
match = cls._DM_CHAT_GUID_RE.match(chat_id)
return match.group(1) if match else chat_id
def _put_by_chat(self, store: Dict[str, Any], chat_id: str, value: Any) -> None:
_bounded_put(store, self._normalize_chat_key(chat_id), value, self._LAST_INBOUND_CHATS_MAX)
def _record_last_inbound(self, chat_id: Optional[str], message_id: Optional[str]) -> None:
if chat_id and message_id:
self._put_by_chat(self._last_inbound_by_chat, chat_id, message_id)
def _record_recent_richlink(self, chat_id: str, text: str) -> None:
if chat_id and _url_only_candidate(text):
self._put_by_chat(self._recent_richlinks_by_chat, chat_id, time.time())
def _is_recent_richlink_preview(self, chat_id: str, content: Dict[str, Any]) -> bool:
if not chat_id or not _is_richlink_preview_content(content):
return False
key = self._normalize_chat_key(chat_id)
last = self._recent_richlinks_by_chat.get(key)
if last is None:
return False
if time.time() - last > _RICHLINK_PREVIEW_SUPPRESS_SECONDS:
self._recent_richlinks_by_chat.pop(key, None)
return False
return True
def _reactions_enabled(self) -> bool:
return _get_scoped_secret("PHOTON_REACTIONS", "false").strip().lower() in {"true", "1", "yes", "on"}
async def _add_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
"""Tapback ``emoji`` onto a message. Soft-fails (False), never raises."""
body = {"spaceId": chat_id, "messageId": message_id, "emoji": emoji}
return await self._sidecar_try("/react", body, "add_reaction")
async def _remove_reaction(self, chat_id: str, message_id: str) -> bool:
"""Retract our tapback (best-effort: the sidecar's per-message reaction
handle is lost on restart). Soft-fails (False), never raises."""
body = {"spaceId": chat_id, "messageId": message_id}
return await self._sidecar_try("/unreact", body, "remove_reaction")
# -- Agent-facing reactions (send_message action="react"): deliberate intents,
# so NOT gated by PHOTON_REACTIONS.
async def add_reaction(
self, chat_id: str, emoji: str, message_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Tapback ``emoji`` onto a message (default: the chat's latest inbound). iMessage
maps ❤️👍👎😂‼️❓ to native tapbacks; anything else is a custom-emoji reaction."""
target = message_id or self._last_inbound_by_chat.get(self._normalize_chat_key(chat_id))
if not target:
return {
"success": False,
"error": "no message to react to — pass message_id (no "
"inbound message seen in this chat since the gateway started)",
}
if not await self._add_reaction(chat_id, target, emoji):
return {"success": False, "error": "reaction failed (see gateway debug log)"}
return {"success": True, "message_id": target}
async def remove_reaction(self, chat_id: str, message_id: Optional[str] = None) -> Dict[str, Any]:
"""Retract our tapback from a message (best-effort)."""
target = message_id or self._last_inbound_by_chat.get(self._normalize_chat_key(chat_id))
if not target:
return {"success": False, "error": "no message to unreact — pass message_id"}
if not await self._remove_reaction(chat_id, target):
return {"success": False, "error": "unreact failed (see gateway debug log)"}
return {"success": True, "message_id": target}
async def on_processing_start(self, event: MessageEvent) -> None:
"""Tapback 👀 on the triggering message while the agent works."""
if not self._reactions_enabled():
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if chat_id and message_id:
await self._add_reaction(chat_id, message_id, "\U0001f440")
# base.on_processing_complete swaps 👀 for 👍/👎 (remove-then-add keeps the
# sidecar's reaction-handle slot coherent); CANCELLED leaves it unreacted.
_OK_EMOJI = "\U0001f44d"
_FAIL_EMOJI = "\U0001f44e"
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Photon's ``space.id`` is opaque; with only the id, infer conservatively."""
return {"name": chat_id, "type": "dm", "id": chat_id}
def format_message(self, content: str) -> str:
# Markdown passes through verbatim (sidecar markdown() builder); the strip
# path is the PHOTON_MARKDOWN=false kill-switch.
if _markdown_enabled():
return content
return strip_markdown(content)
@staticmethod
def _is_retryable_error(error: Optional[str]) -> bool:
if BasePlatformAdapter._is_retryable_error(error):
return True
if not error:
return False
lowered = error.lower()
if "retryable=false" in lowered or "auth_or_config" in lowered:
return False
return any(pat in lowered for pat in _PHOTON_RETRYABLE_PATTERNS)
@staticmethod
def _is_permanent_sidecar_failure(result: SendResult) -> bool:
"""``auth_or_config`` / ``target_not_allowed`` can't be fixed by retrying or
by the plain-text resend — either would just double-send a doomed request."""
raw = result.raw_response
return (
isinstance(raw, dict)
and raw.get("retryable") is False
and raw.get("error_class") in ("auth_or_config", "target_not_allowed")
)
async def _send_with_retry(
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Any = None,
max_retries: int = 1, base_delay: float = 2.0,
) -> SendResult:
"""Retry sends without the generic Markdown banner (replies are markdown or
already-stripped plain text, so it never applies)."""
text = self.format_message(content)
async def _send() -> SendResult:
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
result = await _send()
if result.success:
return result
if self._is_permanent_sidecar_failure(result):
return result # structured failure already carries the user-facing explanation
error_str = result.error or ""
is_network = result.retryable or self._is_retryable_error(error_str)
if not is_network and self._is_timeout_error(error_str):
return result
if is_network:
for attempt in range(1, max_retries + 1):
delay = base_delay * (2 ** (attempt - 1))
logger.warning(
"[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s",
attempt, max_retries, delay, error_str,
)
await asyncio.sleep(delay)
result = await _send()
if result.success:
return result
error_str = result.error or ""
if self._is_permanent_sidecar_failure(result):
return result
if not (result.retryable or self._is_retryable_error(error_str)):
break
else:
logger.error(
"[photon] Failed to deliver response after %d retries: %s",
max_retries, error_str,
)
# Fall through to plain text; for URL-only responses this bypasses
# richlink() so a rich-link outage doesn't strand a sendable URL.
logger.warning("[photon] Send failed: %s - retrying plain-text message", error_str)
fallback_result = await self._sidecar_send(
chat_id, text[: self.MAX_MESSAGE_LENGTH], richlink=False, markdown=False,
)
if not fallback_result.success:
logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error)
return fallback_result
async def _post_send(self, path: str, body: Dict[str, Any], *, structured: bool = False) -> SendResult:
"""POST a send-like body and wrap the outcome as a SendResult. ``structured``
carries a ``PhotonSidecarError``'s class/retryability so ``_send_with_retry``
can recognise permanent failures."""
try:
data = await self._sidecar_call(path, body)
except PhotonSidecarError as e:
if not structured:
return SendResult(success=False, error=str(e))
return SendResult(
success=False,
error=str(e),
raw_response={"error_class": e.error_class, "retryable": e.retryable},
retryable=e.retryable,
)
except Exception as e:
return SendResult(success=False, error=str(e))
self._record_sent_message(data.get("messageId"))
return SendResult(success=True, message_id=data.get("messageId"))
async def _sidecar_send_richlink(self, space_id: str, url: str) -> SendResult:
return await self._post_send("/send-richlink", {"spaceId": space_id, "url": url})
async def _sidecar_send(
self, space_id: str, text: str, *, richlink: bool = True, markdown: bool = True,
) -> SendResult:
rich_url = _richlink_candidate(text) if richlink else None
if rich_url:
rich_result = await self._sidecar_send_richlink(space_id, rich_url)
if rich_result.success:
return rich_result
logger.warning(
"[photon] rich-link send failed, falling back to plain text: %s",
rich_result.error,
)
markdown = False
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}
# Omit the key when disabled so a pre-`format` sidecar keeps accepting the body.
if markdown and _markdown_enabled():
body["format"] = "markdown"
return await self._post_send("/send", body, structured=True)
async def _sidecar_send_poll(self, space_id: str, title: str, options: list) -> SendResult:
"""POST a native poll to ``/send-poll`` (degrades to a numbered list elsewhere)."""
opts = [str(o).strip() for o in (options or []) if str(o).strip()]
if not title or not title.strip():
return SendResult(success=False, error="poll title is required")
if len(opts) < 2:
return SendResult(success=False, error="poll needs at least two options")
body: Dict[str, Any] = {
"spaceId": space_id,
"title": title.strip()[: self.MAX_MESSAGE_LENGTH],
"options": opts,
}
return await self._post_send("/send-poll", body)
async def _sidecar_send_attachment(
self, space_id: str, path: str, *, name: Optional[str] = None, mime_type: Optional[str] = None,
caption: Optional[str] = None, kind: str = "attachment",
) -> SendResult:
"""POST a local file to ``/send-attachment``. ``kind="voice"`` sends audio as
a voice note (downgrades to a plain audio attachment where unsupported)."""
# Defense-in-depth: send_*_file / cron callers may pass arbitrary strings.
safe_path = self.validate_media_delivery_path(str(path))
if not safe_path:
return SendResult(success=False, error=f"unsafe or missing attachment path: {path}")
body = _attachment_body(
space_id, safe_path, kind="voice" if kind == "voice" else "attachment",
name=name, mime_type=mime_type or _guess_mime(safe_path), caption=caption,
)
return await self._post_send("/send-attachment", body, structured=True)
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")
# Fresh client per call so this is safe from a worker thread with its own
# loop (send_message_tool via _run_async); the inbound loop keeps using
# _http_client since it always runs on the gateway loop.
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
resp = await client.post(self._sidecar_url(path), json=body, headers=self._sidecar_headers())
if resp.status_code != 200:
raise _sidecar_error_from_response(path, resp.status_code, resp.text)
data = resp.json() or {}
if not data.get("ok"):
raise _sidecar_error_from_response(path, resp.status_code, resp.text, data)
return data
# ---------------------------------------------------------------------------
# Inbound media helpers
def _attachment_message_type(mime: str) -> MessageType:
mime = (mime or "").lower()
for prefix, mtype in (("image/", MessageType.PHOTO), ("video/", MessageType.VIDEO), ("audio/", MessageType.AUDIO)):
if mime.startswith(prefix):
return mtype
return MessageType.DOCUMENT
# MIME → extension maps for cached inbound bytes (mirror BlueBubbles naming).
_IMAGE_EXT_BY_MIME = {
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp",
"image/heic": ".jpg", "image/heif": ".jpg", "image/tiff": ".jpg",
}
_AUDIO_EXT_BY_MIME = {
"audio/mp3": ".mp3", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav",
"audio/x-caf": ".caf", "audio/mp4": ".m4a", "audio/aac": ".m4a",
}
def _cache_inbound_attachment(
content: Dict[str, Any], name: str, mime: str, *, force_audio: bool = False,
) -> Optional[str]:
"""Decode base64-inlined ``content["data"]`` into the shared media cache by MIME;
None when there are no bytes (over the inline cap) or caching fails → marker."""
data_b64 = content.get("data")
if not data_b64:
return None
try:
raw = base64.b64decode(data_b64)
except (ValueError, TypeError) as exc:
logger.warning("[photon] failed to decode inbound attachment bytes: %s", exc)
return None
from gateway.platforms.base import cache_audio_from_bytes, cache_document_from_bytes, cache_image_from_bytes
mime = (mime or "").lower()
suffix = Path(name).suffix if name else "" # prefer the real extension
try:
if mime.startswith("image/"):
ext = suffix or _IMAGE_EXT_BY_MIME.get(mime, ".jpg")
try:
return cache_image_from_bytes(raw, ext)
except ValueError:
# Unsupported image bytes (e.g. HEIC magic): deliver as a document.
return cache_document_from_bytes(raw, name)
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_document_from_bytes(raw, name) # video, application/*, everything else
except Exception as exc:
logger.warning("[photon] failed to cache inbound attachment %s: %s", name, exc)
return None
# ---------------------------------------------------------------------------
# Standalone (out-of-process) send for cron deliveries when the gateway is not
# co-resident. Reuses a live sidecar (cron processes cannot spawn one).
def _standalone_error(resp: Any) -> Dict[str, Any]:
"""Structured error dict for a failed standalone call (mirrors
``_sidecar_error_from_response``, incl. the canonical target_not_allowed text)."""
try:
data = resp.json() or {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
error_class = str(data.get("error_class") or "sidecar_error")
retryable = bool(data.get("retryable"))
if error_class == "target_not_allowed":
error = _TARGET_NOT_ALLOWED_MESSAGE
retryable = False
elif resp.status_code != 200:
error = f"sidecar returned {resp.status_code}: {resp.text[:200]}"
else:
error = str(data.get("error") or "sidecar reported failure")
return {"error": error, "error_class": error_class, "retryable": retryable}
def _standalone_token_from_record(port: int) -> Tuple[Optional[str], int, str]:
"""``(token, port, error)`` from the runtime record the gateway persists once the
sidecar passes /healthz — the token otherwise exists only in the gateway env."""
record = _read_runtime_record()
stale_hint = ""
if record and record.get("token"):
if _sidecar_pid_alive(record.get("pid")):
return str(record["token"]), _coerce_port(record.get("port"), port), ""
stale_hint = (
" A stale sidecar runtime record was found (pid "
f"{record.get('pid')} is not running) — the gateway "
"appears to be down."
)
return None, port, (
"Photon standalone send requires a running sidecar. "
"Start the Hermes gateway (which spawns the sidecar and "
"records its address under <hermes-home>/runtime/"
f"{_RUNTIME_RECORD_NAME}), or set PHOTON_SIDECAR_TOKEN "
"in this process's environment." + stale_hint
)
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,
force_document: bool = False, # noqa: ARG001 — iMessage auto-detects file kind
) -> Dict[str, Any]:
if not HTTPX_AVAILABLE:
return {"error": "httpx not installed"}
port = _coerce_port(
(pconfig.extra or {}).get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"),
_DEFAULT_SIDECAR_PORT,
)
token = _get_scoped_secret("PHOTON_SIDECAR_TOKEN")
if not token:
token, port, error = _standalone_token_from_record(port)
if not token:
return {"error": error}
base = f"http://{_DEFAULT_SIDECAR_BIND}:{port}"
headers = {"X-Hermes-Sidecar-Token": token}
last_message_id: Optional[str] = None
try:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
async def _post(path: str, body: Dict[str, Any]) -> Tuple[Any, Optional[Dict[str, Any]]]:
"""(response, data-if-ok-else-None)."""
resp = await client.post(f"{base}{path}", json=body, headers=headers)
if resp.status_code != 200:
return resp, None
data = resp.json() or {}
return resp, (data if data.get("ok") else None)
# 1. Text body first (if any), so it leads the conversation.
if message:
rich_url = _richlink_candidate(message)
if rich_url:
_resp, data = await _post("/send-richlink", {"spaceId": chat_id, "url": rich_url})
if data:
last_message_id = data.get("messageId")
else:
rich_url = None
if not rich_url:
send_body: Dict[str, Any] = {"spaceId": chat_id, "text": message[:_MAX_MESSAGE_LENGTH]}
if _markdown_enabled() and not _richlink_candidate(message):
send_body["format"] = "markdown"
resp, data = await _post("/send", send_body)
if not data:
return _standalone_error(resp)
last_message_id = data.get("messageId")
# 2. Each attachment as a separate /send-attachment call.
# media_files is List[Tuple[path, is_voice]] (filter_media_delivery_paths).
for media_path, is_voice in media_files or []:
safe_path = BasePlatformAdapter.validate_media_delivery_path(str(media_path))
if not safe_path:
logger.warning("[photon] standalone send skipping unsafe path")
continue
att_body = _attachment_body(
chat_id, safe_path, kind="voice" if is_voice else "attachment",
mime_type=_guess_mime(safe_path),
)
resp, data = await _post("/send-attachment", att_body)
if not data:
return _standalone_error(resp)
last_message_id = data.get("messageId") or last_message_id
return {"success": True, "message_id": last_message_id}
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."""
from . import cli as _cli # local: avoid argparse work at module load
ctx.register_platform(
name="photon",
label="iMessage via Photon",
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)."
),
# Surfaces Photon in the unified `hermes gateway setup` wizard.
setup_fn=_cli.gateway_setup,
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="📱",
# E.164 phone numbers: redact session descriptions before they reach the LLM.
pii_safe=True,
allow_update_command=True,
platform_hint=(
"You are communicating via Photon Spectrum (iMessage). "
"Treat replies like regular text messages — short and friendly. "
"Markdown is rendered (bold, italics, lists, code), but keep "
"formatting light and conversational. Recipient identifiers are "
"E.164 phone numbers; never expose them in responses unless the "
"user asked. Attachments arrive as metadata only."
),
)
ctx.register_cli_command(
name="photon",
help="Set up and manage the Photon iMessage integration",
setup_fn=_cli.register_cli,
handler_fn=_cli.dispatch,
)