mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(photon): persist and display user phone numbers in status
Store operator and assigned iMessage numbers in `auth.json` after setup, and surface them in `hermes photon status`. When numbers are missing, status auto-refreshes from the dashboard without provisioning new lines.
This commit is contained in:
parent
2130ef68b3
commit
b58ff93459
4 changed files with 230 additions and 3 deletions
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue