mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
b58ff93459
commit
0337658904
6 changed files with 122 additions and 50 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue