diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index d8da9350fc..b5fd83d4d6 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -158,9 +158,8 @@ class PhotonSidecarStartupError(RuntimeError): 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: + 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 @@ -171,9 +170,8 @@ class PhotonSidecarError(RuntimeError): 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: +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) @@ -199,13 +197,6 @@ def sidecar_deps_installed() -> bool: 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)): @@ -259,8 +250,7 @@ def _reinstall_sidecar_deps() -> None: 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(), - ) + errors="replace", check=False, timeout=_NPM_REINSTALL_TIMEOUT, creationflags=windows_hide_flags()) try: result = _run("ci") if result.returncode != 0: @@ -270,7 +260,8 @@ def _reinstall_sidecar_deps() -> None: 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()) + logger.error("[photon] sidecar dependency reinstall failed: %s", + (result.stderr or result.stdout or "").strip()) else: logger.info("[photon] sidecar dependencies reinstalled from lockfile") @@ -437,23 +428,25 @@ def _normalize_group_content(content: Dict[str, Any]) -> _Normalized: return text, mtype, media_urls, media_types +_CONTENT_NORMALIZERS: Dict[Any, Callable[[Dict[str, Any]], _Normalized]] = { + "text": lambda c: (c.get("text") or "", MessageType.TEXT, [], []), + "attachment": _normalize_binary_payload, "voice": _normalize_binary_payload, + "richlink": lambda c: (_format_richlink_content(c), MessageType.TEXT, [], []), + "group": _normalize_group_content, +} + + 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, [], [] + normalize = _CONTENT_NORMALIZERS.get(ctype) if isinstance(ctype, str) else None + if normalize is None: + return f"[Photon content type not handled: {ctype}]", MessageType.TEXT, [], [] + return normalize(content) -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]: +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} @@ -503,8 +496,7 @@ class PhotonAdapter(BasePlatformAdapter): self._project_secret: str = ( _get_scoped_secret("PHOTON_PROJECT_SECRET") or extra.get("project_secret") or stored_sec or "") self._sidecar_port = _coerce_port( - extra.get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"), _DEFAULT_SIDECAR_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() @@ -517,24 +509,24 @@ class PhotonAdapter(BasePlatformAdapter): value = extra.get(key) if value is None: value = _get_scoped_secret(env) - return _coerce(cast, value, default) + try: + return cast(value) + except (TypeError, ValueError): + return 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 self.supports_code_blocks = _markdown_enabled() # markdown on => fences pass through 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._respawn_lock: Optional[asyncio.Lock] = None + self._sidecar_supervisor_task = self._inbound_task = self._sidecar_health_task = None self._watchdog_task: Optional[asyncio.Task] = None - self._watchdog_running = False + self._inbound_running = self._watchdog_running = False + self._sidecar_health_interval = 15.0 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 @@ -547,7 +539,8 @@ class PhotonAdapter(BasePlatformAdapter): _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")) + extra["mention_patterns"] if "mention_patterns" in extra + else _get_scoped_secret("PHOTON_MENTION_PATTERNS")) # -- Group-mention gating (parity with BlueBubbles) ---------------------------- @@ -588,14 +581,12 @@ class PhotonAdapter(BasePlatformAdapter): 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", + "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: + if self._autostart_sidecar: # the sidecar holds the gRPC stream for BOTH directions: required now try: await self._start_sidecar() except Exception as e: @@ -628,8 +619,7 @@ class PhotonAdapter(BasePlatformAdapter): 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() + await self._stop_watchdog() # first, so it can't respawn while we tear the sidecar down task, self._sidecar_health_task = self._sidecar_health_task, None await _cancel_task(task) task, self._inbound_task = self._inbound_task, None @@ -712,7 +702,8 @@ class PhotonAdapter(BasePlatformAdapter): # Loud line for a suspected zombie stream before the sidecar's degraded->exit-75 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)", + 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 @@ -725,8 +716,7 @@ class PhotonAdapter(BasePlatformAdapter): break async def _on_inbound_line(self, line: str) -> None: - # Any inbound line proves the upstream gRPC channel is live. - self._note_upstream_activity() + self._note_upstream_activity() # any inbound line proves the upstream gRPC channel is live try: event = json.loads(line) except json.JSONDecodeError: @@ -785,8 +775,7 @@ class PhotonAdapter(BasePlatformAdapter): 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). + if ctype in {"read", "read_receipt"}: # presence signal, not a user turn (receipts for our sends) logger.debug("[photon] outbound message read: %s", content.get("targetMessageId") or "unknown") return if ctype == "reaction": @@ -957,8 +946,7 @@ class PhotonAdapter(BasePlatformAdapter): "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", - }) + "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: @@ -973,8 +961,7 @@ class PhotonAdapter(BasePlatformAdapter): 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 + deadline = time.time() + 15.0 # wait for /healthz — up to 15s on cold start last_err: Optional[Exception] = None async with httpx.AsyncClient(timeout=2.0, trust_env=False) as client: while time.time() < deadline: @@ -984,8 +971,7 @@ class PhotonAdapter(BasePlatformAdapter): f"Photon sidecar exited with code {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. + if resp.status_code == 200: # let out-of-process senders (cron) reach this sidecar _write_runtime_record(self._sidecar_port, self._sidecar_token, self._sidecar_proc.pid) return except httpx.RequestError as e: @@ -1021,8 +1007,7 @@ class PhotonAdapter(BasePlatformAdapter): _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: + if proc.stdin is not None: # closing our stdin end is itself a shutdown signal (EOF watch) with contextlib.suppress(Exception): proc.stdin.close() if self._http_client is not None: # polite shutdown first @@ -1104,8 +1089,7 @@ class PhotonAdapter(BasePlatformAdapter): 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() + self._note_upstream_activity() # fresh stream: give it a full interval before probing again logger.info("[photon] presence watchdog: sidecar respawned, gRPC stream renewed") async def _presence_watchdog(self) -> None: @@ -1142,16 +1126,12 @@ class PhotonAdapter(BasePlatformAdapter): # -- Outbound ------------------------------------------------------------------ - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: + 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)) - 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: + 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: """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.""" @@ -1170,9 +1150,8 @@ class PhotonAdapter(BasePlatformAdapter): # -- 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: + 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) @@ -1180,38 +1159,28 @@ class PhotonAdapter(BasePlatformAdapter): 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: + 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: + 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: + 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: + 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) # send_animation: base falls back to send_image (iMessage renders GIFs inline as images). - 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: @@ -1330,8 +1299,7 @@ class PhotonAdapter(BasePlatformAdapter): return {"name": chat_id, "type": "dm", "id": chat_id} def format_message(self, content: str) -> str: - # Markdown passes through verbatim (sidecar markdown() builder); stripping is the - # PHOTON_MARKDOWN=false kill-switch. + # Markdown passes through verbatim (sidecar markdown() builder); PHOTON_MARKDOWN=false strips. return content if _markdown_enabled() else strip_markdown(content) @staticmethod @@ -1353,9 +1321,8 @@ class PhotonAdapter(BasePlatformAdapter): 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: + 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) @@ -1413,15 +1380,11 @@ class PhotonAdapter(BasePlatformAdapter): 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: + 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) + rich_result = await self._post_send("/send-richlink", {"spaceId": space_id, "url": 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) @@ -1430,8 +1393,7 @@ class PhotonAdapter(BasePlatformAdapter): 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(): + if markdown and _markdown_enabled(): # key omitted when disabled: pre-`format` sidecars still accept body["format"] = "markdown" return await self._post_send("/send", body, structured=True) @@ -1445,13 +1407,12 @@ class PhotonAdapter(BasePlatformAdapter): body = {"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: + 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)) + safe_path = self.validate_media_delivery_path(str(path)) # send_*_file / cron may pass arbitrary strings if not safe_path: return SendResult(success=False, error=f"unsafe or missing attachment path: {path}") body = _attachment_body( @@ -1493,8 +1454,8 @@ _AUDIO_EXT_BY_MIME = { "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]: +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.""" if not content.get("data"): @@ -1590,8 +1551,7 @@ async def _standalone_send( 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: + if message: # 1. text body first, so it leads the conversation rich_url = _richlink_candidate(message) if rich_url: _resp, data = await _post("/send-richlink", {"spaceId": chat_id, "url": rich_url}) diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 9e6cb595c3..cc18c0a2dc 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -531,8 +531,7 @@ def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]: def create_project( - token: str, *, name: str = DEFAULT_PROJECT_NAME, location: str = "United States", -) -> Dict[str, Any]: + token: str, *, name: str = DEFAULT_PROJECT_NAME, location: str = "United States") -> Dict[str, Any]: """POST ``/api/projects`` and return the project (Spectrum is always provisioned; the request carries no ``spectrum`` flag).""" body: Dict[str, Any] = {"name": name, "location": location, "template": False, "observability": False} @@ -587,8 +586,7 @@ def find_user_by_phone(project_id: str, project_secret: str, phone_number: str) def create_user( project_id: str, project_secret: str, *, phone_number: str, first_name: Optional[str] = None, - last_name: Optional[str] = None, email: Optional[str] = None, send_invite: bool = False, -) -> Dict[str, Any]: + last_name: Optional[str] = None, email: Optional[str] = None, send_invite: bool = False) -> Dict[str, Any]: """POST Spectrum Cloud ``/projects/{id}/users/`` and return the user.""" _require_httpx(" user creation") if not E164_RE.match(phone_number): @@ -737,6 +735,5 @@ def print_credential_summary(emit: Any = print) -> None: " project id : " + (sid if sid else "✗ missing"), " project secret : " + ("✓ stored" if sec else "✗ missing"), " my number : " + (phone if phone else "✗ missing (run `hermes photon setup --phone ...`)"), - " assigned number : " + (assigned if assigned else "✗ missing (run `hermes photon setup`)"), - ] + " assigned number : " + (assigned if assigned else "✗ missing (run `hermes photon setup`)")] emit("\n".join(rows)) diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 4ed01fa619..2734c18663 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -1,12 +1,7 @@ """``hermes photon ...`` CLI subcommands (registered via ``ctx.register_cli_command()``): - - setup full first-time setup (device login + project + user + sidecar) - status show login + project + sidecar dep state - install-sidecar npm install inside plugins/platforms/photon/sidecar/ - telemetry show or toggle Spectrum SDK telemetry (on/off) - -Device login is the first step of ``setup`` (no standalone ``login`` verb); inbound is -the spectrum-ts gRPC stream, so there are no webhook subcommands. +setup (device login + project + user + sidecar), status, install-sidecar (npm install in +the sidecar dir), telemetry [on|off]. Device login is the first step of ``setup`` (no +standalone ``login`` verb); inbound is the gRPC stream, so there are no webhook subcommands. """ from __future__ import annotations @@ -26,9 +21,6 @@ from .sidecar_paths import _NPM_ERROR_LOG_MAX_CHARS, _npm_error_log, _sidecar_di import contextlib -# --------------------------------------------------------------------------- -# argparse wiring - def register_cli(parser: argparse.ArgumentParser) -> None: """Wire up `hermes photon ...` subcommands.""" subs = parser.add_subparsers(dest="photon_command", required=False) @@ -51,9 +43,6 @@ def register_cli(parser: argparse.ArgumentParser) -> None: parser.set_defaults(func=dispatch) -# --------------------------------------------------------------------------- -# Dispatch - def dispatch(args: argparse.Namespace) -> int: sub = getattr(args, "photon_command", None) if sub is None: @@ -65,20 +54,16 @@ def dispatch(args: argparse.Namespace) -> int: return handler(args) -# --------------------------------------------------------------------------- -# Subcommand handlers +# -- Subcommand handlers ------------------------------------------------------------- def _run_device_login(args: argparse.Namespace) -> int: """Run the RFC 8628 device-code login flow and persist the token (first step of ``setup``).""" def _print_code(code): - target = code.verification_uri_complete or code.verification_uri - print() - print("┌─ Photon device login ────────────────────────────────────────") - print(f"│ Open this URL: {target}") - print(f"│ Enter the code: {code.user_code}") - print("│ (waiting for approval — Ctrl-C to cancel)") - print("└──────────────────────────────────────────────────────────────") - print() + print("\n┌─ Photon device login ────────────────────────────────────────\n" + f"│ Open this URL: {code.verification_uri_complete or code.verification_uri}\n" + f"│ Enter the code: {code.user_code}\n" + "│ (waiting for approval — Ctrl-C to cancel)\n" + "└──────────────────────────────────────────────────────────────\n") try: photon_auth.login_device_flow(open_browser=not args.no_browser, on_user_code=_print_code) except Exception as e: @@ -93,8 +78,8 @@ def _setup_token(args: argparse.Namespace) -> Optional[str]: """[1/5] Reuse a valid dashboard token or run device login; None on failure.""" token = photon_auth.load_photon_token() if token: - # The dashboard token has a short TTL (~3-4 days); a stale one makes every - # management call 401, so validate upfront and fall back to a fresh login. + # The dashboard token has a short TTL (~3-4 days); a stale one makes every management + # call 401, so validate upfront and fall back to a fresh login. print("[1/5] Checking existing Photon token...") if photon_auth.check_photon_token_valid(token): print(" ✓ token is valid") @@ -141,34 +126,29 @@ def _setup_project(token: str, name: str) -> Optional[str]: def _setup_credentials(token: str, dashboard_id: str, name: str) -> Optional[str]: - """[3/5] Provision Spectrum credentials (runtime -> .env, ids -> auth.json); the - dashboard id *is* the Spectrum id. A valid existing secret is reused: regenerating - breaks a running sidecar's sends until restart. Returns the secret or None.""" + """[3/5] Provision Spectrum credentials (runtime -> .env, ids -> auth.json); the dashboard + id *is* the Spectrum id. A valid existing secret is reused: regenerating breaks a running + sidecar's sends until restart. Returns the secret or None.""" try: print("[3/5] Provisioning Spectrum credentials...") existing_id, existing_secret = photon_auth.load_project_credentials() secret: str = "" - reused = False if existing_id and existing_secret: - try: + with contextlib.suppress(Exception): # failure => fall through to regeneration photon_auth.list_users(existing_id, existing_secret) # lightweight validation secret = existing_secret - reused = True - except Exception: - secret = "" # fall through to regeneration + reused = bool(secret) if not secret: secret = photon_auth.regenerate_project_secret(token, dashboard_id) photon_auth.store_project_credentials( - spectrum_project_id=dashboard_id, project_secret=secret, dashboard_project_id=dashboard_id, name=name, - ) + spectrum_project_id=dashboard_id, project_secret=secret, dashboard_project_id=dashboard_id, name=name) if reused: print(f" ✓ Spectrum ready (project id {dashboard_id}) — existing credentials valid") else: print(f" ✓ Spectrum ready (project id {dashboard_id}) — new secret saved") - print( - " ⚠ Project secret was regenerated. If the gateway is running, " - "restart it so the sidecar picks up the new secret:\n" - " hermes gateway restart") + print(" ⚠ Project secret was regenerated. If the gateway is running, " + "restart it so the sidecar picks up the new secret:\n" + " hermes gateway restart") except Exception as e: print(f"spectrum provisioning failed: {e}", file=sys.stderr) return None @@ -187,14 +167,12 @@ def _cmd_setup(args: argparse.Namespace) -> int: if not secret: return 1 # 4. Register the operator's phone number as a Spectrum user (idempotent). - phone = args.phone or _prompt( - color("[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ", Colors.CYAN)) + phone = args.phone or _prompt(color("[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ", Colors.CYAN)) agent_number = registered_phone = registered_user_id = None if not phone: print(" Skipped user registration (no phone given). Re-run with --phone later.") else: - # Name/email are optional and never prompted for (--first-name / --email). - try: + try: # name/email are optional and never prompted for (--first-name / --email) user, created = photon_auth.register_user_if_absent( dashboard_id, secret, phone_number=phone, first_name=args.first_name, last_name=args.last_name, email=args.email) @@ -207,11 +185,10 @@ def _cmd_setup(args: argparse.Namespace) -> int: print(" ✓ phone registered" if created else " ✓ phone already registered") registered_phone = phone registered_user_id = user.get("id") - # The number to text the agent is the user's assigned line ("TEXTS ON"); - # shared-number plans have no dedicated /lines entry. + # The number to text the agent is the user's assigned line ("TEXTS ON"); shared-number + # plans have no dedicated /lines entry. agent_number = photon_auth.user_assigned_line(user) - # Otherwise the gateway denies the operator's own inbound ("Unauthorized user") - # and has no default space for cron delivery. + # Otherwise the gateway denies the operator's own inbound and has no cron home space. _autoconfigure_access(phone) # 5. Surface the agent's iMessage number. if not agent_number: @@ -233,8 +210,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: try: photon_auth.store_user_numbers( phone_number=registered_phone, assigned_phone_number=agent_number, - user_id=str(registered_user_id) if registered_user_id else None, dashboard_project_id=dashboard_id, - ) + user_id=str(registered_user_id) if registered_user_id else None, dashboard_project_id=dashboard_id) except Exception as e: print(f" (could not save Photon status metadata: {e})", file=sys.stderr) # 6. Sidecar deps (spectrum-ts). @@ -252,22 +228,19 @@ def _cmd_setup(args: argparse.Namespace) -> int: print(" ✓ photon platform enabled in config.yaml") except Exception as e: print(f" (could not enable Photon in config: {e})", file=sys.stderr) - print() - print("✓ Photon setup complete.") - print(" Start the gateway: hermes gateway start") + print("\n✓ Photon setup complete.\n Start the gateway: hermes gateway start") return 0 def _autoconfigure_access(phone: str) -> None: - """Set PHOTON_ALLOWED_USERS and PHOTON_HOME_CHANNEL to the operator's number, - each only when unset so a hand-tuned value is never clobbered on re-run.""" + """Set PHOTON_ALLOWED_USERS and PHOTON_HOME_CHANNEL to the operator's number, each only + when unset so a hand-tuned value is never clobbered on re-run.""" try: from hermes_cli.config import get_env_value, save_env_value except ImportError: return - for key, label in ( - ("PHOTON_ALLOWED_USERS", "allowlisted your number"), - ("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel")): + for key, label in (("PHOTON_ALLOWED_USERS", "allowlisted your number"), + ("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel")): try: if get_env_value(key): print(f" {key} already set — leaving it as-is.") @@ -279,31 +252,24 @@ def _autoconfigure_access(phone: str) -> None: def _cmd_status(_args: argparse.Namespace) -> int: - _refresh_status_numbers() + phone, assigned = photon_auth.load_user_numbers() + if not (phone and assigned): + spectrum_id, project_secret = photon_auth.load_project_credentials() + if spectrum_id and project_secret: + try: + photon_auth.refresh_user_numbers(spectrum_id, project_secret) + except Exception as e: + print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr) # auth.print_credential_summary's emit callback is the only sink that sees # credential-derived strings (keeps cli.py taint-free for CodeQL). photon_auth.print_credential_summary(print) node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") - sidecar_installed = sidecar_deps_installed() print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}") - print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}") + print(f" sidecar deps : {'✓ installed' if sidecar_deps_installed() else '✗ run `hermes photon install-sidecar`'}") print(f" telemetry : {'on' if _telemetry_enabled() else 'off'} (`hermes photon telemetry on|off`)") return 0 -def _refresh_status_numbers() -> None: - phone, assigned = photon_auth.load_user_numbers() - if phone and assigned: - return - spectrum_id, project_secret = photon_auth.load_project_credentials() - if not spectrum_id or not project_secret: - return - try: - photon_auth.refresh_user_numbers(spectrum_id, project_secret) - except Exception as e: - print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr) - - def _telemetry_enabled() -> bool: """PHOTON_TELEMETRY from env / ~/.hermes/.env; truthy set mirrors the sidecar's.""" try: @@ -336,15 +302,14 @@ def _install_sidecar() -> int: if not shutil.which(npm): print("npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) and re-run.", file=sys.stderr) return 1 - # spectrum-ts is pinned exactly (the SDK ships breaking majors); upgrades are - # deliberate — never `@latest` (see README "Upgrading spectrum-ts"). `npm ci` - # installs the lockfile verbatim; `npm install` is the fallback for a - # missing/drifted lockfile. + # spectrum-ts is pinned exactly (the SDK ships breaking majors); upgrades are deliberate — + # never `@latest` (see README "Upgrading spectrum-ts"). `npm ci` installs the lockfile + # verbatim; `npm install` is the fallback for a missing/drifted lockfile. print(f" $ cd {_sidecar_dir()} && {npm} ci") def _run(verb: str) -> subprocess.CompletedProcess: - # stdout streams to the terminal; stderr is captured so the failure reason - # can be persisted for check_requirements() to surface later. + # stdout streams to the terminal; stderr is captured so the failure reason can be + # persisted for check_requirements() to surface later. proc = subprocess.run( # noqa: S603 [npm, verb], cwd=str(_sidecar_dir()), check=False, stderr=subprocess.PIPE, text=True) if proc.stderr: @@ -356,8 +321,7 @@ def _install_sidecar() -> int: proc = _run("install") if proc.returncode != 0: print("npm install failed", file=sys.stderr) - # Bounded to what check_requirements() will ever surface. - error = (proc.stderr or "").strip()[:_NPM_ERROR_LOG_MAX_CHARS] + error = (proc.stderr or "").strip()[:_NPM_ERROR_LOG_MAX_CHARS] # bounded to what check_requirements surfaces if error: with contextlib.suppress(OSError): _npm_error_log().write_text(error, encoding="utf-8") @@ -368,33 +332,23 @@ def _install_sidecar() -> int: _COMMANDS = { - "setup": _cmd_setup, - "status": _cmd_status, - "install-sidecar": lambda _args: _install_sidecar(), + "setup": _cmd_setup, "status": _cmd_status, "install-sidecar": lambda _args: _install_sidecar(), "telemetry": _cmd_telemetry} -# --------------------------------------------------------------------------- -# Gateway-setup entry point - def gateway_setup() -> None: - """Run Photon first-time setup from the unified `hermes gateway setup` wizard - (same flow as ``hermes photon setup``; phone is prompted when stdin is a TTY).""" + """Run Photon first-time setup from the unified `hermes gateway setup` wizard (same flow + as ``hermes photon setup``; phone is prompted when stdin is a TTY).""" _cmd_setup(argparse.Namespace( photon_command="setup", project_name=None, phone=None, first_name=None, last_name=None, email=None, no_browser=False, skip_sidecar_install=False)) -# --------------------------------------------------------------------------- -# Small interactive helpers - def _prompt(prompt: str, *, secret: bool = False) -> str: if not sys.stdin.isatty(): return "" try: - if secret: - return getpass.getpass(prompt).strip() - return input(prompt).strip() + return (getpass.getpass(prompt) if secret else input(prompt)).strip() except (KeyboardInterrupt, EOFError): print() return "" diff --git a/plugins/platforms/photon/sidecar_paths.py b/plugins/platforms/photon/sidecar_paths.py index eb7be09c0f..b87f1cd361 100644 --- a/plugins/platforms/photon/sidecar_paths.py +++ b/plugins/platforms/photon/sidecar_paths.py @@ -1,25 +1,13 @@ +"""Resolve where the Photon sidecar runs from and where its Node deps live. + +Hosted images keep the plugin tree read-only (EROFS), so, mirroring +``resolve_whatsapp_bridge_dir``: (1) ``PHOTON_SIDECAR_DIR`` override as-is; (2) writable +source dir → run in place; (3) read-only with baked, current ``node_modules`` → in place; +(4) read-only and deps missing/stale → mirror the source files to +``$HERMES_HOME/photon/sidecar``. The mirror is refreshed by content compare; ``node_modules`` +is left alone so the lockfile-vs-install-marker check triggers ``npm ci`` inside the mirror. +Resolution never happens at import time (it probes/copies on disk). """ -Resolve where the Photon sidecar runs from and where its Node deps live. - -The sidecar source ships inside the installed plugin tree. Hosted/managed -images keep that tree read-only (``/opt/hermes``, EROFS on every install / -self-heal path), so resolution mirrors ``resolve_whatsapp_bridge_dir``: - -1. ``PHOTON_SIDECAR_DIR`` env override — used as-is. -2. Source dir writable → run in place (dev installs). -3. Source dir read-only but ``node_modules`` is baked and current → run in - place (the Dockerfile bakes deps with ``npm ci`` at build time). -4. Source dir read-only and deps missing/stale → mirror the sidecar source - files to ``$HERMES_HOME/photon/sidecar`` (durable, writable) and return it. - -The mirror is refreshed on every resolve by content compare (not mtime); -``node_modules`` is left alone so the adapter's lockfile-vs-install-marker -staleness check triggers the ``npm ci`` self-heal inside the mirror. - -Import-light on purpose: both ``adapter.py`` and ``cli.py`` use it, and -resolution never happens at import time (it probes/copies on disk). -""" - from __future__ import annotations import filecmp @@ -32,30 +20,27 @@ from typing import Optional logger = logging.getLogger(__name__) SOURCE_SIDECAR_DIR = Path(__file__).parent / "sidecar" - -# Files that define the sidecar; node_modules is deliberately absent (baked -# on managed images or installed by npm in the mirror). +# Files that define the sidecar; node_modules is deliberately absent (baked on managed +# images or installed by npm in the mirror). _MIRROR_FILES = ("index.mjs", "package.json", "package-lock.json", "patch-spectrum-mixed-attachments.mjs") - -# Tests monkeypatch these module globals directly; the accessors honor a -# non-None value and only resolve/derive when unset. +# Tests monkeypatch these module globals directly; the accessors honor a non-None value. _SIDECAR_DIR: Optional[Path] = None -# Written by `hermes photon install-sidecar` on npm failure so -# check_requirements() can surface the root cause later; cleared on success. +# Written by `hermes photon install-sidecar` on npm failure so check_requirements() can +# surface the root cause later; cleared on success. _NPM_ERROR_LOG: Optional[Path] = None _NPM_ERROR_LOG_MAX_CHARS = 300 def dir_writable(path: Path) -> bool: - """True when we can create files in ``path`` (probe, not stat — stat lies on - root-squash / read-only bind mounts).""" + """True when we can create files in ``path`` (probe, not stat — stat lies on root-squash + / read-only bind mounts).""" probe = path / ".hermes-write-probe" try: probe.touch() probe.unlink() - return True except OSError: return False + return True _dir_writable = dir_writable @@ -63,8 +48,8 @@ _dir_writable = dir_writable def _lock_newer_than_install(sidecar_dir: Path) -> bool: """True when the committed lockfile postdates npm's install marker - (``node_modules/.package-lock.json``) — the same signal ``npm ci`` uses. - False on any stat failure so an odd filesystem never blocks start.""" + (``node_modules/.package-lock.json``) — the same signal ``npm ci`` uses. False on any + stat failure so an odd filesystem never blocks start.""" lockfile = sidecar_dir / "package-lock.json" marker = sidecar_dir / "node_modules" / ".package-lock.json" try: @@ -81,8 +66,7 @@ def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path: return Path(override) if _dir_writable(source): return source - # Read-only tree with baked, current deps: run in place (the sidecar never - # writes inside its own directory). + # Read-only tree with baked, current deps: run in place (the sidecar never writes there). if (source / "node_modules").exists() and not _lock_newer_than_install(source): return source from hermes_constants import get_hermes_home @@ -90,11 +74,8 @@ def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path: try: mirror.mkdir(parents=True, exist_ok=True) for name in _MIRROR_FILES: - src = source / name - if not src.exists(): - continue - dst = mirror / name - if not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False): + src, dst = source / name, mirror / name + if src.exists() and (not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False)): shutil.copy2(str(src), str(dst)) return mirror except OSError as exc: @@ -102,8 +83,7 @@ def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path: "[photon] install tree is read-only and mirroring the sidecar " "to %s failed (%s) — falling back to the read-only source dir; " "dependency installs will not be possible", - mirror, - exc) + mirror, exc) return source @@ -117,6 +97,4 @@ def _sidecar_dir() -> Path: def _npm_error_log() -> Path: """Path of the persisted npm-failure log (derived from the sidecar dir).""" - if _NPM_ERROR_LOG is not None: - return _NPM_ERROR_LOG - return _sidecar_dir() / ".photon-npm-error.log" + return _NPM_ERROR_LOG if _NPM_ERROR_LOG is not None else _sidecar_dir() / ".photon-npm-error.log"