From 03376589049a1877cb9dd2f07fac8f49a7c6f222 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Mon, 8 Jun 2026 21:06:39 -0700 Subject: [PATCH] fix(photon): migrate user API calls to Spectrum backend Switch `list_users`, `find_user_by_phone`, `create_user`, `register_user_if_absent`, and `refresh_user_numbers` from the Dashboard API (Bearer token) to the Spectrum API (Basic auth with project credentials). Update response unwrapping to handle the nested `data.users` envelope returned by Spectrum, add `_spectrum_host()` resolver, `_basic()` header helper, and structured error helpers. Update tests, docs, and plugin.yaml accordingly. --- plugins/platforms/photon/README.md | 1 + plugins/platforms/photon/auth.py | 118 +++++++++++++++----- plugins/platforms/photon/cli.py | 9 +- plugins/platforms/photon/plugin.yaml | 4 + tests/plugins/platforms/photon/test_auth.py | 39 ++++--- website/docs/user-guide/messaging/photon.md | 1 + 6 files changed, 122 insertions(+), 50 deletions(-) diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index f07acf4f25c..eb307fa94c3 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -111,6 +111,7 @@ All env vars are documented in `plugin.yaml`. The most important: | `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar | | `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect | | `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host | +| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host | | `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) | | `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist | | `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word | diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 37b9e83b77c..502a6349ea5 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -41,6 +41,7 @@ import logging import os import re import time +from base64 import b64encode from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple @@ -68,6 +69,7 @@ DEFAULT_CLIENT_ID = "photon-cli" DEFAULT_SCOPE = "openid profile email" DEFAULT_DASHBOARD_HOST = "https://app.photon.codes" +DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes" # Default name of the project Hermes provisions for the operator. DEFAULT_PROJECT_NAME = "Hermes Agent" @@ -273,10 +275,43 @@ def _dashboard_host() -> str: return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/") +def _spectrum_host() -> str: + return (os.getenv("PHOTON_SPECTRUM_HOST") or DEFAULT_SPECTRUM_HOST).rstrip("/") + + def _bearer(token: str) -> Dict[str, str]: return {"Authorization": f"Bearer {token}"} +def _basic(project_id: str, project_secret: str) -> Dict[str, str]: + token = b64encode(f"{project_id}:{project_secret}".encode("utf-8")).decode("ascii") + return {"Authorization": f"Basic {token}"} + + +def _response_error_detail(resp: Any) -> str: + try: + data = resp.json() + except Exception: + data = None + if isinstance(data, dict): + for key in ("error", "message", "detail"): + val = data.get(key) + if val: + return str(val) + return json.dumps(data, sort_keys=True)[:500] + text = getattr(resp, "text", "") or "" + return text[:500] if text else "no response body" + + +def _raise_for_status(resp: Any, action: str) -> None: + status = getattr(resp, "status_code", 200) + if status < 400: + return + raise RuntimeError( + f"Photon {action} failed: HTTP {status}: {_response_error_detail(resp)}" + ) + + def request_device_code( *, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = DEFAULT_SCOPE, ) -> DeviceCode: @@ -584,6 +619,11 @@ def _unwrap_list(data: Any) -> List[Dict[str, Any]]: inner = data.get(key) if isinstance(inner, list): return inner + if isinstance(inner, dict): + for nested_key in ("projects", "users", "lines", "items"): + nested = inner.get(nested_key) + if isinstance(nested, list): + return nested return [] @@ -687,37 +727,37 @@ def regenerate_project_secret(token: str, project_id: str) -> str: # --------------------------------------------------------------------------- -# Dashboard API: spectrum users +# Spectrum API: users def _normalize_phone(phone: str) -> str: """Reduce a phone string to ``+`` and digits for dedup comparison.""" return re.sub(r"[^\d+]", "", phone or "") -def list_users(token: str, project_id: str) -> List[Dict[str, Any]]: - """GET ``/api/projects/{id}/spectrum/users`` → ``SpectrumUser[]``.""" +def list_users(project_id: str, project_secret: str) -> List[Dict[str, Any]]: + """GET Spectrum Cloud ``/projects/{id}/users/`` → ``SpectrumUser[]``.""" if httpx is None: raise RuntimeError("httpx is required for Photon") - url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users" - resp = httpx.get(url, headers=_bearer(token), timeout=30.0) - resp.raise_for_status() + url = f"{_spectrum_host()}/projects/{project_id}/users/" + resp = httpx.get(url, headers=_basic(project_id, project_secret), timeout=30.0) + _raise_for_status(resp, "list-users") return _unwrap_list(resp.json()) def find_user_by_phone( - token: str, project_id: str, phone_number: str, + project_id: str, project_secret: str, phone_number: str, ) -> Optional[Dict[str, Any]]: """Return an existing Spectrum user with the given phone number, or None.""" target = _normalize_phone(phone_number) - for user in list_users(token, project_id): + for user in list_users(project_id, project_secret): if _normalize_phone(user.get("phoneNumber") or "") == target: return user return None def create_user( - token: str, project_id: str, + project_secret: str, *, phone_number: str, first_name: Optional[str] = None, @@ -725,32 +765,42 @@ def create_user( email: Optional[str] = None, send_invite: bool = False, ) -> Dict[str, Any]: - """POST ``/api/projects/{id}/spectrum/users`` and return the created user.""" + """POST Spectrum Cloud ``/projects/{id}/users/`` and return the user.""" if httpx is None: raise RuntimeError("httpx is required for Photon user creation") if not E164_RE.match(phone_number): raise ValueError( f"phone_number must be E.164 (e.g. +15551234567); got {phone_number!r}" ) - url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users" - body: Dict[str, Any] = {"phoneNumber": phone_number, "sendInvite": send_invite} + url = f"{_spectrum_host()}/projects/{project_id}/users/" + body: Dict[str, Any] = {"type": "shared", "phoneNumber": phone_number} + if send_invite: + logger.debug("photon: send_invite is ignored by Spectrum shared-user creation") if first_name: body["firstName"] = first_name if last_name: body["lastName"] = last_name if email: body["email"] = email - resp = httpx.post(url, json=body, headers=_bearer(token), timeout=30.0) - resp.raise_for_status() + resp = httpx.post( + url, + json=body, + headers=_basic(project_id, project_secret), + timeout=30.0, + ) + _raise_for_status(resp, "create-user") data = resp.json() or {} if data.get("error"): raise RuntimeError(f"Photon create-user failed: {data['error']}") - return data.get("user") or data + user = data.get("user") or data.get("data") or data + if isinstance(user, dict): + return user + raise RuntimeError("Photon create-user returned an unexpected response") def register_user_if_absent( - token: str, project_id: str, + project_secret: str, *, phone_number: str, first_name: Optional[str] = None, @@ -763,11 +813,12 @@ def register_user_if_absent( same phone number already exists (the official CLI does no dedup, so we add it here to make ``setup`` safely re-runnable). """ - existing = find_user_by_phone(token, project_id, phone_number) + existing = find_user_by_phone(project_id, project_secret, phone_number) if existing is not None: return existing, False user = create_user( - token, project_id, + project_id, + project_secret, phone_number=phone_number, first_name=first_name, last_name=last_name, @@ -812,15 +863,15 @@ def load_user_numbers() -> Tuple[Optional[str], Optional[str]]: def refresh_user_numbers( - token: str, project_id: str, + project_id: str, project_secret: str, ) -> Tuple[Optional[str], Optional[str]]: """Refresh cached user numbers from Photon without provisioning anything.""" phone, cached_assigned = load_user_numbers() user: Optional[Dict[str, Any]] = None if phone: - user = find_user_by_phone(token, project_id, phone) + user = find_user_by_phone(project_id, project_secret, phone) else: - users = list_users(token, project_id) + users = list_users(project_id, project_secret) if len(users) == 1: user = users[0] @@ -833,20 +884,29 @@ def refresh_user_numbers( phone = dashboard_phone assigned = user_assigned_line(user) + dashboard_id = load_dashboard_project_id() if not assigned: - try: - line = get_imessage_line(token, project_id, create_if_missing=False) - except Exception as e: - logger.debug("photon: could not refresh iMessage line for status: %s", e) - else: - if line and line.get("phoneNumber"): - assigned = str(line["phoneNumber"]) + dashboard_token = load_photon_token() + if dashboard_token and dashboard_id: + try: + line = get_imessage_line( + dashboard_token, + dashboard_id, + create_if_missing=False, + ) + except Exception as e: + logger.debug( + "photon: could not refresh iMessage line for status: %s", e + ) + else: + if line and line.get("phoneNumber"): + assigned = str(line["phoneNumber"]) store_user_numbers( phone_number=phone, assigned_phone_number=assigned, user_id=str(user_id) if user_id else None, - dashboard_project_id=project_id, + dashboard_project_id=dashboard_id, ) return phone, assigned diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 846b58404ba..220f9e26e96 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -194,7 +194,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: email = args.email try: user, created = photon_auth.register_user_if_absent( - token, dashboard_id, + spectrum_id, secret, phone_number=phone, first_name=first_name, last_name=args.last_name, @@ -310,12 +310,11 @@ def _refresh_status_numbers() -> None: phone, assigned = photon_auth.load_user_numbers() if phone and assigned: return - token = photon_auth.load_photon_token() - dashboard_id = photon_auth.load_dashboard_project_id() - if not token or not dashboard_id: + spectrum_id, project_secret = photon_auth.load_project_credentials() + if not spectrum_id or not project_secret: return try: - photon_auth.refresh_user_numbers(token, dashboard_id) + 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) diff --git a/plugins/platforms/photon/plugin.yaml b/plugins/platforms/photon/plugin.yaml index 562318520e1..12ade17f8e5 100644 --- a/plugins/platforms/photon/plugin.yaml +++ b/plugins/platforms/photon/plugin.yaml @@ -46,6 +46,10 @@ optional_env: description: "Photon Dashboard API host (default https://app.photon.codes)" prompt: "Dashboard host" password: false + - name: PHOTON_SPECTRUM_HOST + description: "Photon Spectrum API host (default https://spectrum.photon.codes)" + prompt: "Spectrum API host" + password: false - name: PHOTON_ALLOWED_USERS description: "Comma-separated E.164 phone numbers allowed to talk to the bot" prompt: "Allowed users (comma-separated)" diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py index 29b4a666536..9faf7833d4b 100644 --- a/tests/plugins/platforms/photon/test_auth.py +++ b/tests/plugins/platforms/photon/test_auth.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import os +from base64 import b64encode from pathlib import Path from typing import Any, Dict @@ -40,6 +41,7 @@ _PHOTON_ENV = ( "PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET", "PHOTON_DASHBOARD_PROJECT_ID", + "PHOTON_SPECTRUM_HOST", "PHOTON_ALLOWED_USERS", "PHOTON_HOME_CHANNEL", ) @@ -140,17 +142,19 @@ def test_refresh_user_numbers_reads_existing_assignment( photon_auth.store_user_numbers(phone_number="+15551234567") def fake_get(url: str, **kwargs: Any) -> _FakeResponse: - assert kwargs.get("headers", {}).get("Authorization") == "Bearer tok" - assert url.endswith("/projects/dash/spectrum/users") - return _FakeResponse(json_body=[{ + assert kwargs.get("headers", {}).get("Authorization") == ( + "Basic " + b64encode(b"sp:secret").decode("ascii") + ) + assert url.endswith("/projects/sp/users/") + return _FakeResponse(json_body={"succeed": True, "data": {"users": [{ "id": "user-uuid", "phoneNumber": "+1 (555) 123-4567", "assignedPhoneNumber": "+16282679185", - }]) + }]}}) monkeypatch.setattr(photon_auth.httpx, "get", fake_get) - phone, assigned = photon_auth.refresh_user_numbers("tok", "dash") + phone, assigned = photon_auth.refresh_user_numbers("sp", "secret") assert phone == "+15551234567" assert assigned == "+16282679185" assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185") @@ -361,7 +365,7 @@ def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None: def test_create_user_rejects_invalid_phone() -> None: with pytest.raises(ValueError, match="E.164"): - photon_auth.create_user("tok", "proj", phone_number="not-a-number") + photon_auth.create_user("proj", "secret", phone_number="not-a-number") def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None: @@ -371,27 +375,30 @@ def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> N captured["url"] = url captured["body"] = kwargs.get("json") captured["headers"] = kwargs.get("headers") - return _FakeResponse(json_body={"success": True, "user": { + return _FakeResponse(json_body={"succeed": True, "data": { "id": "user-uuid", "phoneNumber": "+15551234567", }}) monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - user = photon_auth.create_user("tok", "proj-id", phone_number="+15551234567") + user = photon_auth.create_user("proj-id", "secret", phone_number="+15551234567") assert user["id"] == "user-uuid" + assert captured["body"]["type"] == "shared" assert captured["body"]["phoneNumber"] == "+15551234567" - assert captured["headers"]["Authorization"] == "Bearer tok" - assert "/projects/proj-id/spectrum/users" in captured["url"] + assert captured["headers"]["Authorization"] == ( + "Basic " + b64encode(b"proj-id:secret").decode("ascii") + ) + assert captured["url"].endswith("/projects/proj-id/users/") def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None: posted = {"n": 0} def fake_get(url: str, **kwargs: Any) -> _FakeResponse: - return _FakeResponse(json_body=[{ + return _FakeResponse(json_body={"succeed": True, "data": {"users": [{ "id": "u1", "phoneNumber": "+1 (555) 123-4567", "assignedPhoneNumber": "+16282679185", - }]) + }]}}) def fake_post(url: str, **kwargs: Any) -> _FakeResponse: posted["n"] += 1 @@ -401,7 +408,7 @@ def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(photon_auth.httpx, "post", fake_post) # Same number, different formatting — should match and NOT create. user, created = photon_auth.register_user_if_absent( - "tok", "proj", phone_number="+15551234567", + "proj", "secret", phone_number="+15551234567", ) assert created is False assert user["id"] == "u1" @@ -424,15 +431,15 @@ def test_user_assigned_line() -> None: def test_register_user_if_absent_creates(monkeypatch: pytest.MonkeyPatch) -> None: def fake_get(url: str, **kwargs: Any) -> _FakeResponse: - return _FakeResponse(json_body=[]) + return _FakeResponse(json_body={"succeed": True, "data": {"users": []}}) def fake_post(url: str, **kwargs: Any) -> _FakeResponse: - return _FakeResponse(json_body={"success": True, "user": {"id": "u-new"}}) + return _FakeResponse(json_body={"succeed": True, "data": {"id": "u-new"}}) monkeypatch.setattr(photon_auth.httpx, "get", fake_get) monkeypatch.setattr(photon_auth.httpx, "post", fake_post) user, created = photon_auth.register_user_if_absent( - "tok", "proj", phone_number="+15551234567", + "proj", "secret", phone_number="+15551234567", ) assert created is True assert user["id"] == "u-new" diff --git a/website/docs/user-guide/messaging/photon.md b/website/docs/user-guide/messaging/photon.md index 90658f97075..a00553bb123 100644 --- a/website/docs/user-guide/messaging/photon.md +++ b/website/docs/user-guide/messaging/photon.md @@ -222,6 +222,7 @@ Common issues: | `PHOTON_REQUIRE_MENTION` | `false` | Require a wake word before responding in groups | | `PHOTON_MENTION_PATTERNS` | Hermes wake words | JSON list / comma / newline regex patterns for group mentions | | `PHOTON_DASHBOARD_HOST` | `app.photon.codes` | Override the dashboard / device-login host | +| `PHOTON_SPECTRUM_HOST` | `spectrum.photon.codes` | Override the Spectrum API host | [photon]: https://photon.codes/ [app]: https://app.photon.codes/