Merge branch 'simp/r3-29-C' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 23:11:45 -07:00
4 changed files with 153 additions and 264 deletions
+76 -116
View File
@@ -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})
+3 -6
View File
@@ -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))
+50 -96
View File
@@ -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 ""
+24 -46
View File
@@ -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"