diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index 7865649cdb6..f07acf4f25c 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -111,8 +111,8 @@ 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_HOME_CHANNEL` | (unset) | Default space id for cron delivery | -| `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist | +| `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 | | `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines | diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 1ddf6014d30..730b72d38e2 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -210,6 +210,10 @@ def _cmd_setup(args: argparse.Namespace) -> int: # no dedicated entry in /lines, so this per-user field is the source of # truth — and we already have it from the (reused) user object. agent_number = photon_auth.user_assigned_line(user) + # Allowlist the operator and make their DM the cron home channel — + # otherwise the gateway denies their own inbound messages + # ("Unauthorized user") and has no default space for cron delivery. + _autoconfigure_access(phone) # 5. Surface the agent's iMessage number (the number to text the agent). if not agent_number: @@ -248,6 +252,33 @@ def _cmd_setup(args: argparse.Namespace) -> int: return 0 +def _autoconfigure_access(phone: str) -> None: + """Allowlist the operator and set their DM as the cron home channel. + + Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's + own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL`` + (the default space for cron delivery) to the operator's E.164 number. Each + is only filled when unset, so a hand-tuned allowlist / home channel is + never clobbered on a re-run. + """ + try: + from hermes_cli.config import get_env_value, save_env_value + except ImportError: + return + for key, label in ( + ("PHOTON_ALLOWED_USERS", "allowlisted your number"), + ("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"), + ): + try: + if get_env_value(key): + print(f" {key} already set — leaving it as-is.") + continue + save_env_value(key, phone) + print(f" ✓ {label} ({key})") + except Exception as e: + print(f" could not set {key}: {e}", file=sys.stderr) + + def _cmd_status(_args: argparse.Namespace) -> int: # Defer the credential rows to auth.print_credential_summary — its emit # callback is the only sink that sees credential-derived strings, so diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index 60f5ba2a8c8..5371a683d9a 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -303,6 +303,26 @@ function handleInbound(req, res) { } async function resolveSpace(spaceId) { + // A bare E.164 phone number addresses a DM. Resolve the user, then the (DM) + // space — `imessage(app).user(phone)` -> `im.space(user)` — so callers can + // pass just "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of + // an opaque inbound space id. Real inbound space ids never match this shape, + // so this only kicks in for phone-addressed sends. + if (typeof spaceId === "string" && /^\+\d{6,}$/.test(spaceId) && imessage) { + try { + const im = imessage(app); + if (typeof im.user === "function" && typeof im.space === "function") { + const user = await im.user(spaceId); + return await im.space(user); + } + } catch (e) { + console.error( + "photon-sidecar: phone->DM resolution failed; falling back to " + + "id-based lookup: " + + (e && e.stack ? e.stack : String(e)) + ); + } + } // spectrum-ts exposes the same Space methods via `app.space(spaceId)` / // narrowed helpers; we fall back through a few accessor shapes to // tolerate small SDK API drift. diff --git a/tests/plugins/platforms/photon/test_setup_access.py b/tests/plugins/platforms/photon/test_setup_access.py new file mode 100644 index 00000000000..8a9d7cb864a --- /dev/null +++ b/tests/plugins/platforms/photon/test_setup_access.py @@ -0,0 +1,38 @@ +"""Tests for `hermes photon setup`'s access auto-configuration. + +`_autoconfigure_access` allowlists the operator and points the cron home +channel at their DM, writing to the per-test ~/.hermes/.env (the hermetic +HERMES_HOME fixture isolates this). It must fill only unset keys so a re-run +never clobbers a hand-tuned allowlist. +""" +from __future__ import annotations + +import pytest + +from hermes_cli.config import get_env_value, save_env_value +from plugins.platforms.photon import cli + + +def test_autoconfigure_access_fills_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False) + monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False) + + cli._autoconfigure_access("+15551234567") + + assert get_env_value("PHOTON_ALLOWED_USERS") == "+15551234567" + assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567" + + +def test_autoconfigure_access_preserves_existing_allowlist( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False) + monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False) + # A hand-tuned allowlist already in place must survive a setup re-run. + save_env_value("PHOTON_ALLOWED_USERS", "+19998887777,+15551112222") + + cli._autoconfigure_access("+15551234567") + + assert get_env_value("PHOTON_ALLOWED_USERS") == "+19998887777,+15551112222" + # The still-unset home channel is filled. + assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"