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.
This commit is contained in:
underthestars-zhy 2026-06-08 21:06:39 -07:00 committed by Teknium
parent b58ff93459
commit 0337658904
6 changed files with 122 additions and 50 deletions

View file

@ -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 |

View file

@ -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

View file

@ -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)

View file

@ -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)"