diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 5caca102ac2..37b9e83b77c 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -27,8 +27,9 @@ Credential storage mirrors every other Hermes channel: * runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value`` * management metadata -> ``~/.hermes/auth.json`` under - ``credential_pool.photon`` (device token) and - ``credential_pool.photon_project`` (dashboard id, spectrum id, name) + ``credential_pool.photon`` (device token), + ``credential_pool.photon_project`` (dashboard id, spectrum id, name), and + ``credential_pool.photon_user`` (operator number + assigned text line) Reference: https://github.com/photon-hq/cli and https://photon.codes/docs/api-reference/device-login/request-device-+-user-code @@ -205,6 +206,30 @@ def store_project_credentials( _persist_runtime_env(spectrum_project_id, project_secret) +def store_user_numbers( + *, + phone_number: Optional[str] = None, + assigned_phone_number: Optional[str] = None, + user_id: Optional[str] = None, + dashboard_project_id: Optional[str] = None, +) -> None: + """Persist non-secret Photon user numbers for offline ``status`` output.""" + if not phone_number and not assigned_phone_number: + return + auth = _load_auth() + record: Dict[str, Any] = {"issued_at": int(time.time())} + if phone_number: + record["phone_number"] = phone_number + if assigned_phone_number: + record["assigned_phone_number"] = assigned_phone_number + if user_id: + record["user_id"] = user_id + if dashboard_project_id: + record["dashboard_project_id"] = dashboard_project_id + auth.setdefault("credential_pool", {})["photon_user"] = [record] + _save_auth(auth) + + def _persist_runtime_env(spectrum_project_id: str, project_secret: str) -> None: """Write the SDK creds to ``~/.hermes/.env`` (canonical runtime store). @@ -766,6 +791,95 @@ def user_assigned_line(user: Optional[Dict[str, Any]]) -> Optional[str]: return str(val) if val else None +def load_user_numbers() -> Tuple[Optional[str], Optional[str]]: + """Return ``(operator_phone_number, assigned_phone_number)`` for status.""" + auth = _load_auth() + user_entries = auth.get("credential_pool", {}).get("photon_user") or [] + if isinstance(user_entries, list) and user_entries: + entry = user_entries[0] or {} + if isinstance(entry, dict): + phone = entry.get("phone_number") or entry.get("phoneNumber") + assigned = ( + entry.get("assigned_phone_number") + or entry.get("assignedPhoneNumber") + ) + if phone or assigned: + return ( + str(phone) if phone else _configured_operator_phone(), + str(assigned) if assigned else None, + ) + return _configured_operator_phone(), None + + +def refresh_user_numbers( + token: str, project_id: 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) + else: + users = list_users(token, project_id) + if len(users) == 1: + user = users[0] + + user_id = None + assigned: Optional[str] = cached_assigned + if user: + user_id = user.get("id") + dashboard_phone = _normalize_phone(str(user.get("phoneNumber") or "")) + if E164_RE.match(dashboard_phone): + phone = dashboard_phone + assigned = user_assigned_line(user) + + 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"]) + + 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, + ) + return phone, assigned + + +def _configured_operator_phone() -> Optional[str]: + """Infer the operator's E.164 number from existing Photon env settings.""" + home = _get_config_env_value("PHOTON_HOME_CHANNEL") + if home: + normalized = _normalize_phone(home) + if E164_RE.match(normalized): + return normalized + + allowed = _get_config_env_value("PHOTON_ALLOWED_USERS") + if not allowed: + return None + candidates = [] + for part in re.split(r"[,\s]+", allowed): + normalized = _normalize_phone(part) + if E164_RE.match(normalized): + candidates.append(normalized) + if len(candidates) == 1: + return candidates[0] + return None + + +def _get_config_env_value(key: str) -> Optional[str]: + try: + from hermes_cli.config import get_env_value + except Exception: + return os.getenv(key) + return get_env_value(key) + + # --------------------------------------------------------------------------- # Dashboard API: iMessage lines (the assigned number inventory) @@ -836,6 +950,13 @@ def print_credential_summary(emit: Any = print) -> None: labels["spectrum_project_id"] = sid if sid else "✗ missing" labels["dashboard_project_id"] = load_dashboard_project_id() or "—" labels["project_key"] = "✓ stored" if sec else "✗ missing" + phone, assigned = load_user_numbers() + labels["phone_number"] = ( + phone if phone else "✗ missing (run `hermes photon setup --phone ...`)" + ) + labels["assigned_phone_number"] = ( + assigned if assigned else "✗ missing (run `hermes photon setup`)" + ) rows = [ "Photon iMessage status", @@ -844,6 +965,8 @@ def print_credential_summary(emit: Any = print) -> None: " dashboard project : " + labels["dashboard_project_id"], " spectrum project id : " + labels["spectrum_project_id"], " project secret : " + labels["project_key"], + " my number : " + labels["phone_number"], + " assigned number : " + labels["assigned_phone_number"], ] emit("\n".join(rows)) @@ -864,9 +987,19 @@ def credential_summary() -> Dict[str, str]: _sid, sec = load_project_credentials() return "✓ stored" if sec else "✗ missing" + def _present_phone() -> str: + phone, _assigned = load_user_numbers() + return phone or "✗ missing (run `hermes photon setup --phone ...`)" + + def _present_assigned_phone() -> str: + _phone, assigned = load_user_numbers() + return assigned or "✗ missing (run `hermes photon setup`)" + return { "device_token": _present_token(), "dashboard_project_id": load_dashboard_project_id() or "—", "spectrum_project_id": _present_spectrum_id(), "project_key": _present_secret(), + "phone_number": _present_phone(), + "assigned_phone_number": _present_assigned_phone(), } diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 730b72d38e2..846b58404ba 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -183,6 +183,8 @@ def _cmd_setup(args: argparse.Namespace) -> int: ) ) agent_number = None + registered_phone = None + registered_user_id = None if not phone: print(" Skipped user registration (no phone given). Re-run with --phone later.") else: @@ -205,6 +207,8 @@ def _cmd_setup(args: argparse.Namespace) -> int: print(f" user registration failed: {e}", file=sys.stderr) return 1 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 iMessage line # (the dashboard's "TEXTS ON" column). On shared-number plans there is # no dedicated entry in /lines, so this per-user field is the source of @@ -236,6 +240,16 @@ def _cmd_setup(args: argparse.Namespace) -> int: print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN)) else: print(" No iMessage line assigned yet — check the Photon dashboard.") + if registered_phone: + 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, + ) + except Exception as e: + print(f" (could not save Photon status metadata: {e})", file=sys.stderr) # 6. Sidecar deps (spectrum-ts). if args.skip_sidecar_install: @@ -280,6 +294,7 @@ def _autoconfigure_access(phone: str) -> None: def _cmd_status(_args: argparse.Namespace) -> int: + _refresh_status_numbers() # Defer the credential rows to auth.print_credential_summary — its emit # callback is the only sink that sees credential-derived strings, so # cli.py keeps zero taint flow according to CodeQL. @@ -291,6 +306,20 @@ def _cmd_status(_args: argparse.Namespace) -> int: return 0 +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: + return + try: + photon_auth.refresh_user_numbers(token, dashboard_id) + except Exception as e: + print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr) + + def _cmd_install_sidecar(_args: argparse.Namespace) -> int: return _install_sidecar() diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py index 7fadcfda5f0..29b4a666536 100644 --- a/tests/plugins/platforms/photon/test_auth.py +++ b/tests/plugins/platforms/photon/test_auth.py @@ -40,6 +40,8 @@ _PHOTON_ENV = ( "PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET", "PHOTON_DASHBOARD_PROJECT_ID", + "PHOTON_ALLOWED_USERS", + "PHOTON_HOME_CHANNEL", ) @@ -98,6 +100,62 @@ def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None: assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text +def test_store_user_numbers_round_trip(tmp_hermes_home: Path) -> None: + photon_auth.store_user_numbers( + phone_number="+15551234567", + assigned_phone_number="+16282679185", + user_id="user-uuid", + dashboard_project_id="dash-uuid", + ) + + phone, assigned = photon_auth.load_user_numbers() + assert phone == "+15551234567" + assert assigned == "+16282679185" + + summary = photon_auth.credential_summary() + assert summary["phone_number"] == "+15551234567" + assert summary["assigned_phone_number"] == "+16282679185" + + rendered: list[str] = [] + photon_auth.print_credential_summary(rendered.append) + assert " my number : +15551234567" in rendered[0] + assert " assigned number : +16282679185" in rendered[0] + + +def test_load_user_numbers_falls_back_to_home_channel( + tmp_hermes_home: Path, +) -> None: + from hermes_cli.config import save_env_value + + save_env_value("PHOTON_HOME_CHANNEL", "+15551234567") + + phone, assigned = photon_auth.load_user_numbers() + assert phone == "+15551234567" + assert assigned is None + + +def test_refresh_user_numbers_reads_existing_assignment( + tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + 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=[{ + "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") + assert phone == "+15551234567" + assert assigned == "+16282679185" + assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185") + + def test_load_project_credentials_env_override( tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -435,6 +493,8 @@ def test_credential_summary_no_secret_leak( assert summary["project_key"].startswith("✓") assert summary["spectrum_project_id"] == "sp-uuid" assert summary["dashboard_project_id"] == "dash-uuid" + assert summary["phone_number"].startswith("✗ missing") + assert summary["assigned_phone_number"].startswith("✗ missing") # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/messaging/photon.md b/website/docs/user-guide/messaging/photon.md index d31d46eb7da..90658f97075 100644 --- a/website/docs/user-guide/messaging/photon.md +++ b/website/docs/user-guide/messaging/photon.md @@ -162,7 +162,10 @@ Send an iMessage to your assigned number and Hermes will reply. hermes photon status ``` -Prints: +Prints saved credentials, sidecar health, your registered number, and the +assigned iMessage line Hermes uses. When a Photon token and dashboard project +are available, `status` refreshes missing number rows from the dashboard +without provisioning new lines. ``` Photon iMessage status @@ -171,6 +174,8 @@ Photon iMessage status dashboard project : 3c90c3cc-0d44-4b50-... spectrum project id : sp-... project secret : ✓ stored + my number : +15551234567 + assigned number : +16282679185 node binary : /usr/bin/node sidecar deps : ✓ installed ```