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

View file

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

View file

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