mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(photon): gRPC-native iMessage channel (no webhook)
Make Photon iMessage a first-class persistent-connection channel like Discord/Slack, using the spectrum-ts gRPC stream for both directions. - Inbound: the sidecar forwards the SDK's app.messages gRPC stream to the adapter over a loopback GET /inbound (NDJSON) instead of webhooks. Drops the aiohttp webhook server, HMAC signature verification, public URL, and PHOTON_WEBHOOK_* config; adapter reconnects with backoff. - Management plane: device login uses client_id=photon-cli against the single dashboard host (Bearer), matching the official photon-hq/cli; find-or-create "Hermes Agent" project, enable Spectrum, rotate secret, register user (with phone dedup), surface the assigned iMessage line. - SDK projectId is the project's spectrumProjectId, not the dashboard id; runtime creds persist to ~/.hermes/.env like every other channel. - CLI: 6-step setup, webhook subcommands removed. - Tests/docs updated for the gRPC flow; sidecar pins spectrum-ts ^1.17.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c3420d91ad
commit
4e4d27875f
12 changed files with 1323 additions and 1176 deletions
|
|
@ -1,123 +1,132 @@
|
|||
# Photon iMessage platform plugin
|
||||
|
||||
This plugin connects Hermes Agent to iMessage (and WhatsApp Business +
|
||||
future Spectrum interfaces) through [Photon][photon] — a managed
|
||||
service that handles the iMessage line allocation, delivery, and
|
||||
abuse-prevention layer so users don't have to run their own Mac
|
||||
relay.
|
||||
This plugin connects Hermes Agent to iMessage (and other Spectrum
|
||||
interfaces) through [Photon][photon] — a managed service that handles
|
||||
iMessage line allocation, delivery, and abuse-prevention so users don't
|
||||
have to run their own Mac relay.
|
||||
|
||||
The free tier uses Photon's shared iMessage line pool (`type: shared`)
|
||||
and is the path we recommend for everyone who doesn't already pay for a
|
||||
dedicated number.
|
||||
The free tier uses Photon's shared iMessage line pool and is the path we
|
||||
recommend for everyone who doesn't already pay for a dedicated number.
|
||||
|
||||
## Architecture
|
||||
|
||||
Like Discord and Slack, Photon is a **persistent-connection** channel — no
|
||||
public URL, no webhook, no signing secret. The `spectrum-ts` SDK holds a
|
||||
long-lived **gRPC stream** to Photon for both directions. Because the SDK is
|
||||
TypeScript-only, Hermes runs it inside a small supervised Node sidecar and
|
||||
talks to it over loopback.
|
||||
|
||||
```
|
||||
┌─────────────────────────┐ HMAC-signed POSTs ┌──────────────────┐
|
||||
│ Photon Spectrum cloud │ ──────────────────────► │ Hermes Agent │
|
||||
│ (iMessage line owner) │ │ (Python) │
|
||||
└─────────────────────────┘ JSON over loopback │ │
|
||||
▲ ◄────────────────────── │ PhotonAdapter │
|
||||
│ │ + aiohttp recv │
|
||||
│ spectrum-ts │ │
|
||||
│ SDK (Node) │ spawns + super- │
|
||||
▼ │ vises ▼ │
|
||||
┌─────────────────────────┐ ├──────────────────┤
|
||||
│ Node sidecar │ ◄──── X-Hermes- ─ │ Node sidecar │
|
||||
│ (plugins/.../sidecar) │ Sidecar-Token │ child process │
|
||||
└─────────────────────────┘ └──────────────────┘
|
||||
gRPC (spectrum-ts)
|
||||
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
|
||||
│ Photon Spectrum cloud │ app.messages │ Node sidecar │
|
||||
│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │
|
||||
└─────────────────────────┘ └──────────┬───────────┘
|
||||
GET /inbound (NDJSON) │ ▲ POST /send
|
||||
inbound events ▼ │ /typing
|
||||
┌──────────────────────┐
|
||||
│ PhotonAdapter │
|
||||
│ (Python, in gateway) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
Inbound traffic is webhook-only — Hermes runs an aiohttp listener
|
||||
that verifies `X-Spectrum-Signature` and dedupes on `message.id`.
|
||||
|
||||
Outbound traffic goes through a tiny Node sidecar that runs the
|
||||
`spectrum-ts` SDK. Photon does not currently expose an HTTP
|
||||
send-message endpoint; their own docs say:
|
||||
|
||||
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
|
||||
> SDK instance to reply. **No public HTTP send endpoint exists today.**
|
||||
> — https://photon.codes/docs/webhooks/events
|
||||
|
||||
When Photon ships an HTTP send endpoint, `_sidecar_send` is the one
|
||||
function that swaps and the sidecar disappears. The rest of the
|
||||
plugin stays the same.
|
||||
- **Inbound**: the sidecar consumes the SDK's `app.messages` gRPC stream,
|
||||
normalizes each message, and streams it to the adapter over a loopback
|
||||
`GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches
|
||||
a `MessageEvent` to the gateway. It reconnects automatically if the stream
|
||||
drops; the sidecar owns the gRPC reconnect to Photon.
|
||||
- **Outbound**: `send` / `send_typing` are loopback POSTs to the sidecar,
|
||||
authenticated with a shared `X-Hermes-Sidecar-Token`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
# 1. One-shot setup: device login (opens browser) + project + user + sidecar deps
|
||||
# One-shot setup: device login (opens browser) + project + user + sidecar deps
|
||||
hermes photon setup --phone +15551234567
|
||||
|
||||
# 2. Expose your webhook URL to the public internet
|
||||
# (cloudflared, ngrok, your gateway's public hostname, etc.)
|
||||
# Then register it with Photon:
|
||||
hermes photon webhook register https://your-host.example.com/photon/webhook
|
||||
|
||||
# 3. Save the signing secret it prints to ~/.hermes/.env
|
||||
# as PHOTON_WEBHOOK_SECRET=...
|
||||
# Photon only returns it ONCE.
|
||||
|
||||
# 4. Start the gateway
|
||||
# Start the gateway
|
||||
hermes gateway start --platform photon
|
||||
```
|
||||
|
||||
`hermes photon setup` runs the RFC 8628 device-code login as its first
|
||||
step — it opens `https://app.photon.codes/` for approval, then
|
||||
provisions the Spectrum project + iMessage line. There is no separate
|
||||
`login` command; like every other Hermes channel, onboarding goes
|
||||
through one setup surface. Re-running `setup` reuses an existing token
|
||||
and project, so it's safe to run again to finish a partial setup.
|
||||
`hermes photon setup` does, in order:
|
||||
|
||||
1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens
|
||||
`https://app.photon.codes/` for approval and stores the bearer token.
|
||||
2. **Find or create** the `Hermes Agent` project on the Photon dashboard.
|
||||
3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the
|
||||
project secret, and persist both.
|
||||
4. **Register your phone number** as a Spectrum user (idempotent — skipped if
|
||||
a user with that number already exists).
|
||||
5. **Print the assigned iMessage line** — the number you text to reach your
|
||||
agent.
|
||||
6. **Install the sidecar deps** (`spectrum-ts`).
|
||||
|
||||
There is no separate `login` command; like every other Hermes channel,
|
||||
onboarding goes through one setup surface. Re-running `setup` reuses an
|
||||
existing token/project, so it's safe to run again to finish a partial setup.
|
||||
Run `hermes photon status` to see what's configured.
|
||||
|
||||
## Credentials
|
||||
|
||||
Stored in `~/.hermes/auth.json` under `credential_pool`:
|
||||
Runtime SDK credentials live in `~/.hermes/.env` (the same place every other
|
||||
channel keeps its token), and the adapter reads them from the environment:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=<spectrumProjectId> # the SDK's projectId
|
||||
PHOTON_PROJECT_SECRET=<projectSecret>
|
||||
```
|
||||
|
||||
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"credential_pool": {
|
||||
"photon": [
|
||||
{ "access_token": "<dashboard-bearer>", "issued_at": ... }
|
||||
{ "access_token": "<device-bearer>", "issued_at": ... }
|
||||
],
|
||||
"photon_project": [
|
||||
{ "project_id": "...", "project_secret": "...", "name": "Hermes Agent" }
|
||||
{
|
||||
"dashboard_project_id": "<dashboard id>",
|
||||
"spectrum_project_id": "<spectrumProjectId>",
|
||||
"project_secret": "<projectSecret>",
|
||||
"name": "Hermes Agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The per-URL webhook signing secret is treated like an API key and
|
||||
lives in `~/.hermes/.env` as `PHOTON_WEBHOOK_SECRET`.
|
||||
> **Note on ids.** A Photon project has two identifiers: the dashboard `id`
|
||||
> (used for management API calls) and the `spectrumProjectId` (what the SDK
|
||||
> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id.
|
||||
|
||||
## Configuration knobs
|
||||
|
||||
All env vars are documented in `plugin.yaml`. The most important are:
|
||||
All env vars are documented in `plugin.yaml`. The most important:
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|--------------------------|--------------------|-----------------------------------------|
|
||||
| `PHOTON_PROJECT_ID` | from auth.json | Spectrum project ID |
|
||||
| `PHOTON_PROJECT_SECRET` | from auth.json | Spectrum project secret (HTTP Basic) |
|
||||
| `PHOTON_WEBHOOK_SECRET` | (unset) | Signing secret returned at register |
|
||||
| `PHOTON_WEBHOOK_PORT` | 8788 | Local port for the aiohttp listener |
|
||||
| `PHOTON_WEBHOOK_PATH` | /photon/webhook | Path under which the listener mounts |
|
||||
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for sidecar control |
|
||||
| `PHOTON_HOME_CHANNEL` | (unset) | Default space ID for cron delivery |
|
||||
| `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist |
|
||||
| Env var | Default | Meaning |
|
||||
|---------------------------|----------------------------|--------------------------------------|
|
||||
| `PHOTON_PROJECT_ID` | from .env / auth.json | Spectrum project id (SDK `projectId`)|
|
||||
| `PHOTON_PROJECT_SECRET` | from .env / auth.json | Project secret |
|
||||
| `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_REQUIRE_MENTION` | false | Gate group chats on a wake word |
|
||||
|
||||
## Limitations (current Photon API)
|
||||
|
||||
- **Inbound attachments are metadata only.** Inbound webhooks include the
|
||||
filename + MIME type but no download URL. The plugin surfaces a
|
||||
text marker (`[Photon attachment received: …]`) so the agent knows
|
||||
something arrived, but cannot read the bytes. Photon's docs note
|
||||
an attachment retrieval endpoint is on the roadmap.
|
||||
- **Outbound attachments are supported.** Images, voice notes, video,
|
||||
and documents are sent via `space.send(attachment(...))` /
|
||||
- **Inbound attachments are metadata only.** Inbound events carry the
|
||||
filename + MIME type; the plugin surfaces a text marker
|
||||
(`[Photon attachment received: …]`) so the agent knows something arrived.
|
||||
The SDK exposes attachment bytes via `content.read()`/`stream()`, so
|
||||
downloading them is a sidecar follow-up.
|
||||
- **Outbound attachments are supported.** Images, voice notes, video, and
|
||||
documents are sent via `space.send(attachment(...))` /
|
||||
`space.send(voice(...))` through the sidecar's `/send-attachment`
|
||||
endpoint. A caption is delivered as a separate text bubble after the
|
||||
media.
|
||||
- **Reactions, message effects, polls** — not exposed yet; the
|
||||
`spectrum-ts` SDK supports them, and the sidecar is the natural
|
||||
place to add them when the agent has reason to use them.
|
||||
endpoint; a caption is delivered as a separate text bubble after the media.
|
||||
- **Reactions, message effects, polls** — supported by `spectrum-ts` but not
|
||||
yet exposed; the sidecar is the natural place to add them.
|
||||
|
||||
[photon]: https://photon.codes/
|
||||
|
|
|
|||
|
|
@ -1,32 +1,29 @@
|
|||
"""
|
||||
Photon Spectrum (iMessage) platform adapter for Hermes Agent.
|
||||
|
||||
Both directions of traffic flow through a small supervised Node sidecar
|
||||
(see ``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK — the SDK is
|
||||
TypeScript-only and there is no public HTTP message API, so a sidecar is
|
||||
unavoidable.
|
||||
|
||||
Inbound:
|
||||
Photon delivers signed JSON ``POST``s to a URL we register. The
|
||||
adapter spins up an aiohttp server on ``PHOTON_WEBHOOK_PORT``,
|
||||
verifies ``X-Spectrum-Signature`` (HMAC-SHA256 of
|
||||
``v0:{timestamp}:{body}`` keyed by the per-URL signing secret),
|
||||
rejects deliveries with a timestamp drift > 5 minutes, dedupes on
|
||||
``message.id``, and dispatches a normalized ``MessageEvent`` to the
|
||||
gateway runner via ``BasePlatformAdapter.handle_message``.
|
||||
The SDK's ``app.messages`` is a long-lived **gRPC** stream. The sidecar
|
||||
serializes each message to a normalized JSON event and streams it to this
|
||||
adapter over a loopback ``GET /inbound`` (NDJSON). A background task here
|
||||
consumes that stream, dedupes on ``messageId``, and dispatches a
|
||||
``MessageEvent`` to the gateway via ``BasePlatformAdapter.handle_message``.
|
||||
No webhook, no public URL, no signing secret.
|
||||
|
||||
Outbound:
|
||||
Photon does not currently expose a public HTTP send-message
|
||||
endpoint, so the adapter spawns a small Node sidecar (see
|
||||
``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK. Each
|
||||
``send`` / ``send_typing`` / attachment call from Hermes is a
|
||||
loopback POST to the sidecar with a shared bearer token. Outbound
|
||||
media (images, voice notes, video, documents) goes through
|
||||
spectrum-ts' ``attachment()`` / ``voice()`` content builders.
|
||||
|
||||
When Photon ships an HTTP send endpoint we can collapse the sidecar
|
||||
into ``_send_via_http`` and drop the Node dependency entirely.
|
||||
``send`` / ``send_typing`` are loopback POSTs to the sidecar's control
|
||||
endpoints, authenticated with a shared bearer token. Outbound media
|
||||
(images, voice notes, video, documents) goes through spectrum-ts'
|
||||
``attachment()`` / ``voice()`` content builders via the sidecar's
|
||||
``/send-attachment`` endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -48,13 +45,6 @@ except ImportError: # pragma: no cover - httpx is already a Hermes dep
|
|||
HTTPX_AVAILABLE = False
|
||||
httpx = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
AIOHTTP_AVAILABLE = False
|
||||
web = None # type: ignore[assignment]
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
|
|
@ -63,21 +53,13 @@ from gateway.platforms.base import (
|
|||
SendResult,
|
||||
)
|
||||
|
||||
from .auth import (
|
||||
DEFAULT_SPECTRUM_HOST,
|
||||
load_project_credentials,
|
||||
_spectrum_host,
|
||||
)
|
||||
from .auth import load_project_credentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
_DEFAULT_WEBHOOK_PORT = 8788
|
||||
_DEFAULT_WEBHOOK_PATH = "/photon/webhook"
|
||||
_DEFAULT_WEBHOOK_BIND = "0.0.0.0"
|
||||
|
||||
_DEFAULT_SIDECAR_PORT = 8789
|
||||
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
|
||||
|
||||
|
|
@ -86,11 +68,8 @@ _DEFAULT_SIDECAR_BIND = "127.0.0.1"
|
|||
# size to ~16 KB. Keep a conservative cap that matches BlueBubbles.
|
||||
_MAX_MESSAGE_LENGTH = 8000
|
||||
|
||||
# Spec says reject deliveries older than ~5 minutes for replay protection.
|
||||
_TIMESTAMP_DRIFT_SECONDS = 300
|
||||
|
||||
# Dedup parameters — keep at least 1k IDs for ~48h per Photon's
|
||||
# at-least-once guidance.
|
||||
# Dedup parameters — the gRPC stream is at-least-once, and a sidecar
|
||||
# reconnect can replay, so keep at least 1k ids for ~48h.
|
||||
_DEDUP_MAX_SIZE = 4000
|
||||
_DEDUP_WINDOW_SECONDS = 48 * 3600
|
||||
|
||||
|
|
@ -118,7 +97,7 @@ def _coerce_port(value: Any, default: int) -> int:
|
|||
|
||||
def check_requirements() -> bool:
|
||||
"""Return True when both Python deps and the Node sidecar are available."""
|
||||
if not HTTPX_AVAILABLE or not AIOHTTP_AVAILABLE:
|
||||
if not HTTPX_AVAILABLE:
|
||||
return False
|
||||
if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
|
||||
return False
|
||||
|
|
@ -150,57 +129,18 @@ def _env_enablement() -> Optional[dict]:
|
|||
project_id, project_secret = load_project_credentials()
|
||||
if not (project_id and project_secret):
|
||||
return None
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"project_secret": project_secret,
|
||||
"webhook_port": _coerce_port(os.getenv("PHOTON_WEBHOOK_PORT"), _DEFAULT_WEBHOOK_PORT),
|
||||
"webhook_path": os.getenv("PHOTON_WEBHOOK_PATH") or _DEFAULT_WEBHOOK_PATH,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signature verification
|
||||
|
||||
def verify_signature(
|
||||
*,
|
||||
body: bytes,
|
||||
timestamp_header: str,
|
||||
signature_header: str,
|
||||
signing_secret: str,
|
||||
now: Optional[float] = None,
|
||||
drift: int = _TIMESTAMP_DRIFT_SECONDS,
|
||||
) -> bool:
|
||||
"""Constant-time verify a Photon webhook signature.
|
||||
|
||||
Returns True iff the timestamp is within ``drift`` of *now* AND
|
||||
``signature_header == "v0=" + hmac_sha256(secret, "v0:{ts}:{body}")``.
|
||||
|
||||
Exposed at module scope so tests can exercise it without an adapter
|
||||
instance.
|
||||
"""
|
||||
if not timestamp_header or not signature_header or not signing_secret:
|
||||
return False
|
||||
try:
|
||||
ts = int(timestamp_header)
|
||||
except ValueError:
|
||||
return False
|
||||
if abs((now or time.time()) - ts) > drift:
|
||||
return False
|
||||
if not signature_header.startswith("v0="):
|
||||
return False
|
||||
expected = hmac.new(
|
||||
signing_secret.encode("utf-8"),
|
||||
f"v0:{ts}:".encode("utf-8") + body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature_header[3:])
|
||||
return {"project_id": project_id, "project_secret": project_secret}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter
|
||||
|
||||
class PhotonAdapter(BasePlatformAdapter):
|
||||
"""Inbound: signed webhook on aiohttp. Outbound: Node sidecar via loopback HTTP."""
|
||||
"""Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar.
|
||||
|
||||
Inbound: consume the sidecar's ``/inbound`` gRPC stream.
|
||||
Outbound: loopback POSTs to the sidecar's control channel.
|
||||
"""
|
||||
|
||||
MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
|
||||
|
||||
|
|
@ -209,6 +149,8 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
extra = config.extra or {}
|
||||
|
||||
# Project credentials (env wins, then config.extra, then auth.json).
|
||||
# ``project_id`` here is the project's spectrumProjectId — the value
|
||||
# the spectrum-ts SDK authenticates with.
|
||||
stored_id, stored_sec = load_project_credentials()
|
||||
self._project_id: str = (
|
||||
os.getenv("PHOTON_PROJECT_ID")
|
||||
|
|
@ -223,27 +165,6 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
or ""
|
||||
)
|
||||
|
||||
# Webhook receiver
|
||||
self._webhook_port = _coerce_port(
|
||||
extra.get("webhook_port") or os.getenv("PHOTON_WEBHOOK_PORT"),
|
||||
_DEFAULT_WEBHOOK_PORT,
|
||||
)
|
||||
self._webhook_path = (
|
||||
extra.get("webhook_path")
|
||||
or os.getenv("PHOTON_WEBHOOK_PATH")
|
||||
or _DEFAULT_WEBHOOK_PATH
|
||||
)
|
||||
self._webhook_bind = (
|
||||
extra.get("webhook_bind")
|
||||
or os.getenv("PHOTON_WEBHOOK_BIND")
|
||||
or _DEFAULT_WEBHOOK_BIND
|
||||
)
|
||||
self._webhook_secret: str = (
|
||||
os.getenv("PHOTON_WEBHOOK_SECRET")
|
||||
or extra.get("webhook_secret")
|
||||
or ""
|
||||
)
|
||||
|
||||
# Sidecar
|
||||
self._sidecar_port = _coerce_port(
|
||||
extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
|
||||
|
|
@ -259,12 +180,13 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
|
||||
|
||||
# Runtime state
|
||||
self._runner: Optional["web.AppRunner"] = None
|
||||
self._sidecar_proc: Optional[subprocess.Popen] = None
|
||||
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
|
||||
self._inbound_task: Optional[asyncio.Task] = None
|
||||
self._inbound_running = False
|
||||
self._http_client: Optional["httpx.AsyncClient"] = None
|
||||
# Lightweight in-memory dedup. Photon's at-least-once guarantee
|
||||
# means we WILL see the same message.id more than once.
|
||||
# Lightweight in-memory dedup. The gRPC stream is at-least-once, so we
|
||||
# may see the same messageId more than once (e.g. after a reconnect).
|
||||
self._seen_messages: Dict[str, float] = {}
|
||||
|
||||
# Group-chat mention gating (parity with BlueBubbles). When enabled,
|
||||
|
|
@ -345,13 +267,6 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
# -- Connection lifecycle ---------------------------------------------
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
self._set_fatal_error(
|
||||
"MISSING_DEP",
|
||||
"aiohttp not installed. Run: pip install aiohttp",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
if not HTTPX_AVAILABLE:
|
||||
self._set_fatal_error(
|
||||
"MISSING_DEP", "httpx not installed", retryable=False
|
||||
|
|
@ -366,19 +281,10 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return False
|
||||
|
||||
# Start the aiohttp receiver first; without it the sidecar would
|
||||
# be able to forward inbound traffic to a closed port.
|
||||
try:
|
||||
await self._start_webhook_server()
|
||||
except OSError as e:
|
||||
self._set_fatal_error(
|
||||
"PORT_IN_USE",
|
||||
f"webhook port {self._webhook_port} unavailable: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
return False
|
||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
# Spin up the Node sidecar (required for outbound).
|
||||
# The sidecar holds the gRPC stream for BOTH directions, so it is
|
||||
# required now (not just for outbound).
|
||||
if self._autostart_sidecar:
|
||||
try:
|
||||
await self._start_sidecar()
|
||||
|
|
@ -388,23 +294,39 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
f"failed to start Photon sidecar: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
await self._stop_webhook_server()
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
return False
|
||||
else:
|
||||
logger.info("[photon] sidecar autostart disabled — outbound will fail")
|
||||
logger.warning(
|
||||
"[photon] sidecar autostart disabled — inbound + outbound will fail"
|
||||
)
|
||||
|
||||
# Start consuming the inbound gRPC stream from the sidecar.
|
||||
self._inbound_running = True
|
||||
self._inbound_task = asyncio.get_event_loop().create_task(
|
||||
self._inbound_loop()
|
||||
)
|
||||
|
||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||
self._mark_connected()
|
||||
logger.info(
|
||||
"[photon] connected — webhook at %s:%d%s, sidecar on %s:%d",
|
||||
self._webhook_bind, self._webhook_port, self._webhook_path,
|
||||
"[photon] connected — sidecar on %s:%d, streaming inbound over gRPC",
|
||||
self._sidecar_bind, self._sidecar_port,
|
||||
)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._inbound_running = False
|
||||
if self._inbound_task is not None:
|
||||
self._inbound_task.cancel()
|
||||
try:
|
||||
await self._inbound_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
self._inbound_task = None
|
||||
await self._stop_sidecar()
|
||||
await self._stop_webhook_server()
|
||||
if self._http_client is not None:
|
||||
try:
|
||||
await self._http_client.aclose()
|
||||
|
|
@ -413,68 +335,61 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
self._http_client = None
|
||||
self._mark_disconnected()
|
||||
|
||||
# -- Webhook server ----------------------------------------------------
|
||||
# -- Inbound stream consumer ------------------------------------------
|
||||
|
||||
async def _start_webhook_server(self) -> None:
|
||||
app = web.Application()
|
||||
app.router.add_post(self._webhook_path, self._handle_webhook)
|
||||
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self._webhook_bind, self._webhook_port)
|
||||
await site.start()
|
||||
async def _inbound_loop(self) -> None:
|
||||
"""Consume the sidecar's ``/inbound`` NDJSON stream, with reconnect.
|
||||
|
||||
async def _stop_webhook_server(self) -> None:
|
||||
if self._runner is not None:
|
||||
The sidecar owns the gRPC reconnect/heartbeat to Photon; this loop
|
||||
only has to re-open the loopback HTTP stream if it drops (e.g. the
|
||||
sidecar restarts).
|
||||
"""
|
||||
client = self._http_client
|
||||
if client is None:
|
||||
return
|
||||
url = f"http://{self._sidecar_bind}:{self._sidecar_port}/inbound"
|
||||
headers = {"X-Hermes-Sidecar-Token": self._sidecar_token}
|
||||
backoff = 1.0
|
||||
while self._inbound_running:
|
||||
try:
|
||||
await self._runner.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
self._runner = None
|
||||
|
||||
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
||||
body = await request.read()
|
||||
if self._webhook_secret:
|
||||
ts = request.headers.get("X-Spectrum-Timestamp", "")
|
||||
sig = request.headers.get("X-Spectrum-Signature", "")
|
||||
if not verify_signature(
|
||||
body=body,
|
||||
timestamp_header=ts,
|
||||
signature_header=sig,
|
||||
signing_secret=self._webhook_secret,
|
||||
):
|
||||
logger.warning("[photon] rejected webhook with bad signature")
|
||||
return web.Response(status=401, text="invalid signature")
|
||||
else:
|
||||
logger.warning(
|
||||
"[photon] PHOTON_WEBHOOK_SECRET unset — accepting unsigned "
|
||||
"deliveries. Set the per-URL signing secret returned by "
|
||||
"register-webhook to enable verification."
|
||||
)
|
||||
async with client.stream(
|
||||
"GET", url, headers=headers, timeout=None,
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"/inbound returned {resp.status_code}")
|
||||
backoff = 1.0 # reset on a successful connect
|
||||
async for line in resp.aiter_lines():
|
||||
if not self._inbound_running:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue # heartbeat
|
||||
await self._on_inbound_line(line)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if not self._inbound_running:
|
||||
break
|
||||
logger.warning(
|
||||
"[photon] inbound stream dropped (%s); reconnecting in %.1fs",
|
||||
e, backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
|
||||
async def _on_inbound_line(self, line: str) -> None:
|
||||
try:
|
||||
payload = json.loads(body or b"{}")
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return web.Response(status=400, text="invalid json")
|
||||
if payload.get("event") != "messages":
|
||||
# Photon currently emits only `messages`; any future event
|
||||
# types are ack'd 200 so they don't retry.
|
||||
return web.Response(text="ok")
|
||||
|
||||
msg = payload.get("message") or {}
|
||||
msg_id = msg.get("id")
|
||||
if not msg_id:
|
||||
return web.Response(status=400, text="missing message.id")
|
||||
if self._is_duplicate(msg_id):
|
||||
return web.Response(text="ok (dup)")
|
||||
|
||||
logger.debug("[photon] skipping non-JSON inbound line")
|
||||
return
|
||||
msg_id = event.get("messageId")
|
||||
if msg_id and self._is_duplicate(msg_id):
|
||||
return
|
||||
try:
|
||||
await self._dispatch_inbound(payload)
|
||||
await self._dispatch_inbound(event)
|
||||
except Exception:
|
||||
logger.exception("[photon] inbound dispatch failed")
|
||||
# 200 anyway — we own the dedup; failing here would cause
|
||||
# Photon to retry the same id.
|
||||
return web.Response(text="ok")
|
||||
|
||||
def _is_duplicate(self, msg_id: str) -> bool:
|
||||
now = time.time()
|
||||
|
|
@ -488,44 +403,55 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
self._seen_messages[msg_id] = now
|
||||
return False
|
||||
|
||||
async def _dispatch_inbound(self, payload: Dict[str, Any]) -> None:
|
||||
msg = payload.get("message") or {}
|
||||
space = msg.get("space") or payload.get("space") or {}
|
||||
sender = msg.get("sender") or {}
|
||||
content = msg.get("content") or {}
|
||||
async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
|
||||
"""Normalize a sidecar inbound event and dispatch it to the gateway.
|
||||
|
||||
Event shape (from ``sidecar/index.mjs``)::
|
||||
|
||||
{
|
||||
"messageId": "...",
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "...", "type": "dm"|"group", "phone": "+E164"},
|
||||
"sender": {"id": "+E164"},
|
||||
"content": {"type": "text", "text": "..."}
|
||||
| {"type": "attachment", "name", "mimeType", "size"},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z"
|
||||
}
|
||||
"""
|
||||
space = event.get("space") or {}
|
||||
sender = event.get("sender") or {}
|
||||
content = event.get("content") or {}
|
||||
|
||||
space_id = space.get("id") or ""
|
||||
sender_id = sender.get("id") or ""
|
||||
if not space_id:
|
||||
logger.warning("[photon] inbound missing space.id")
|
||||
return
|
||||
|
||||
# Space type — Photon documents iMessage DM ids as `any;-;+E164`
|
||||
# and group ids as `any;+;<chat-guid>`. Use that as the
|
||||
# heuristic; everything else is treated as DM.
|
||||
chat_type = "group" if ";+;" in space_id else "dm"
|
||||
# iMessage spaces carry their type directly — no id string-sniffing.
|
||||
chat_type = "group" if space.get("type") == "group" else "dm"
|
||||
sender_id = sender.get("id") or space.get("phone") or space_id
|
||||
|
||||
# Timestamp — ISO 8601 from the platform.
|
||||
ts_str = msg.get("timestamp") or ""
|
||||
ts_str = event.get("timestamp") or ""
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
timestamp = (
|
||||
datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
if ts_str
|
||||
else datetime.now(tz=timezone.utc)
|
||||
)
|
||||
except ValueError:
|
||||
timestamp = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Content normalization. Spectrum is a discriminated union;
|
||||
# text vs attachment metadata. Attachments are metadata-only
|
||||
# today (no download URL) — log + carry the name so the agent
|
||||
# at least knows something was sent.
|
||||
if content.get("type") == "text":
|
||||
ctype = content.get("type")
|
||||
if ctype == "text":
|
||||
text = content.get("text") or ""
|
||||
mtype = MessageType.TEXT
|
||||
elif content.get("type") == "attachment":
|
||||
elif ctype == "attachment":
|
||||
name = content.get("name") or "(unnamed)"
|
||||
mime = content.get("mimeType") or ""
|
||||
text = f"[Photon attachment received: {name} ({mime}) — no download URL yet]"
|
||||
text = f"[Photon attachment received: {name} ({mime})]"
|
||||
mtype = _attachment_message_type(mime)
|
||||
else:
|
||||
text = f"[Photon content type not handled: {content.get('type')}]"
|
||||
text = f"[Photon content type not handled: {ctype}]"
|
||||
mtype = MessageType.TEXT
|
||||
|
||||
# Group-mention gating (parity with BlueBubbles). In group chats with
|
||||
|
|
@ -545,18 +471,18 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
chat_id=space_id,
|
||||
chat_name=space_id,
|
||||
chat_type=chat_type,
|
||||
user_id=sender_id or space_id,
|
||||
user_id=sender_id,
|
||||
user_name=sender_id or None,
|
||||
)
|
||||
event = MessageEvent(
|
||||
message_event = MessageEvent(
|
||||
text=text,
|
||||
message_type=mtype,
|
||||
source=source,
|
||||
message_id=msg.get("id"),
|
||||
raw_message=payload,
|
||||
message_id=event.get("messageId"),
|
||||
raw_message=event,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
await self.handle_message(message_event)
|
||||
|
||||
# -- Sidecar lifecycle -------------------------------------------------
|
||||
|
||||
|
|
@ -774,12 +700,10 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return whatever we know about a Spectrum space id.
|
||||
|
||||
Photon's `space.id` is opaque (`any;-;+E164` for DMs,
|
||||
`any;+;<guid>` for groups). We surface that shape directly so
|
||||
the gateway has something to show in session pickers / logs.
|
||||
Photon's ``space.id`` is opaque; the inbound event also carries the
|
||||
DM/group type, but here we only have the id, so infer conservatively.
|
||||
"""
|
||||
chat_type = "group" if ";+;" in chat_id else "dm"
|
||||
return {"name": chat_id, "type": chat_type, "id": chat_id}
|
||||
return {"name": chat_id, "type": "dm", "id": chat_id}
|
||||
|
||||
async def _sidecar_send(
|
||||
self, space_id: str, text: str, *, reply_to: Optional[str] = None,
|
||||
|
|
@ -889,9 +813,8 @@ def _attachment_message_type(mime: str) -> MessageType:
|
|||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone (out-of-process) send for cron deliveries when the gateway
|
||||
# is not co-resident. Spins up an ephemeral sidecar call by spawning
|
||||
# the existing sidecar binary one-shot; if a live sidecar is already
|
||||
# listening on the configured port we reuse it.
|
||||
# is not co-resident. Reuses a live sidecar already listening on the
|
||||
# configured port (cron processes cannot spawn the sidecar themselves).
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig: PlatformConfig,
|
||||
|
|
@ -1011,7 +934,7 @@ def register(ctx) -> None:
|
|||
"Treat replies like regular text messages — short, friendly, no "
|
||||
"markdown rendering. Recipient identifiers are E.164 phone "
|
||||
"numbers; never expose them in responses unless the user asked. "
|
||||
"Attachments arrive as metadata only (no download URL yet)."
|
||||
"Attachments arrive as metadata only."
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,37 @@
|
|||
"""
|
||||
Photon Dashboard + Spectrum API client and device-code login flow.
|
||||
Photon Dashboard API client + device-code login flow.
|
||||
|
||||
This module is pure Python — it intentionally does not depend on
|
||||
``spectrum-ts``. All management-plane operations (login, create
|
||||
project, create user, register webhook) talk to Photon's HTTP API
|
||||
directly:
|
||||
``spectrum-ts``. Every management-plane operation (login, find/create
|
||||
project, enable Spectrum, rotate the project secret, register a user,
|
||||
list the assigned iMessage line) talks to Photon's **Dashboard API** on a
|
||||
single host, exactly like the official Photon CLI (``photon-hq/cli``):
|
||||
|
||||
Dashboard API https://app.photon.codes/api/...
|
||||
OAuth bearer token from device flow
|
||||
OAuth 2.0 device flow, Bearer access token
|
||||
|
||||
Spectrum API https://spectrum.photon.codes/projects/{id}/...
|
||||
HTTP Basic with (projectId, projectSecret)
|
||||
A Photon project carries two distinct identifiers:
|
||||
|
||||
The webhook receiver + Node sidecar in ``adapter.py`` consume the
|
||||
credentials this module persists to ``~/.hermes/auth.json``.
|
||||
* ``id`` — the Dashboard project id (used in API paths)
|
||||
* ``spectrumProjectId`` — the Spectrum Cloud project id, populated when
|
||||
Spectrum is enabled on the project
|
||||
|
||||
Reference docs (read at integration time):
|
||||
https://photon.codes/docs/api-reference/introduction
|
||||
https://photon.codes/docs/api-reference/device-login/request-device-+-user-code
|
||||
https://photon.codes/docs/api-reference/device-login/exchange-device-code-for-token
|
||||
https://photon.codes/docs/api-reference/projects/create-project
|
||||
https://photon.codes/docs/api-reference/users/create-user
|
||||
https://photon.codes/docs/webhooks/overview
|
||||
The ``spectrum-ts`` SDK (run by the Node sidecar) authenticates to Spectrum
|
||||
Cloud with ``(spectrumProjectId, projectSecret)`` — so the value we persist
|
||||
as ``PHOTON_PROJECT_ID`` for the runtime is the **spectrumProjectId**, not
|
||||
the Dashboard ``id``. The Dashboard ``id`` is kept only for management
|
||||
calls.
|
||||
|
||||
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)
|
||||
|
||||
Reference: https://github.com/photon-hq/cli and
|
||||
https://photon.codes/docs/api-reference/device-login/request-device-+-user-code
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -32,7 +42,7 @@ import re
|
|||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
|
@ -51,17 +61,20 @@ class PhotonDashboardAuthError(RuntimeError):
|
|||
# Hosted Photon allowlists registered device clients on the device-code
|
||||
# endpoint — an unregistered client_id is rejected with
|
||||
# `400 {"error":"invalid_client"}`. Use Photon's published CLI device
|
||||
# client until the dashboard API registers Hermes as its own client_id.
|
||||
# client (matches `CLI_CLIENT_ID` in photon-hq/cli) until the dashboard API
|
||||
# registers Hermes as its own client_id.
|
||||
DEFAULT_CLIENT_ID = "photon-cli"
|
||||
DEFAULT_SCOPE = "openid profile email"
|
||||
|
||||
DEFAULT_DASHBOARD_HOST = "https://app.photon.codes"
|
||||
DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes"
|
||||
|
||||
# Polling defaults per RFC 8628. Photon may override via `interval` /
|
||||
# `expires_in` fields in the device-code response — those win.
|
||||
# Default name of the project Hermes provisions for the operator.
|
||||
DEFAULT_PROJECT_NAME = "Hermes Agent"
|
||||
|
||||
# Polling defaults per RFC 8628. Photon overrides via `interval` /
|
||||
# `expires_in` in the device-code response — those win.
|
||||
DEFAULT_POLL_INTERVAL = 5
|
||||
DEFAULT_POLL_TIMEOUT = 900 # 15 minutes is conservative; Photon returns expires_in
|
||||
DEFAULT_POLL_TIMEOUT = 1800 # 30 min, matching the CLI's fallback
|
||||
|
||||
E164_RE = re.compile(r"^\+[1-9]\d{6,14}$")
|
||||
|
||||
|
|
@ -104,7 +117,7 @@ def _save_auth(data: Dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def load_photon_token() -> Optional[str]:
|
||||
"""Return the bearer token stored by ``login()`` or ``None``."""
|
||||
"""Return the device-flow bearer token stored by ``login()`` or ``None``."""
|
||||
auth = _load_auth()
|
||||
pool = auth.get("credential_pool", {}).get("photon") or []
|
||||
if isinstance(pool, list) and pool:
|
||||
|
|
@ -128,7 +141,13 @@ def store_photon_token(token: str) -> None:
|
|||
|
||||
|
||||
def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Return ``(project_id, project_secret)`` from auth.json + env override."""
|
||||
"""Return the runtime SDK creds ``(spectrum_project_id, project_secret)``.
|
||||
|
||||
Precedence: process env (``~/.hermes/.env`` is loaded into the gateway's
|
||||
environment at startup) wins, then ``auth.json`` for offline / status
|
||||
use. This is the pair the Node sidecar feeds to ``spectrum-ts`` — the id
|
||||
is the **spectrumProjectId**, not the Dashboard id.
|
||||
"""
|
||||
env_id = os.getenv("PHOTON_PROJECT_ID")
|
||||
env_sec = os.getenv("PHOTON_PROJECT_SECRET")
|
||||
if env_id and env_sec:
|
||||
|
|
@ -137,24 +156,72 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
|
|||
proj = auth.get("credential_pool", {}).get("photon_project") or []
|
||||
if isinstance(proj, list) and proj:
|
||||
entry = proj[0]
|
||||
return (
|
||||
env_id or entry.get("project_id"),
|
||||
env_sec or entry.get("project_secret"),
|
||||
)
|
||||
# back-compat: old records used "project_id" for the spectrum id
|
||||
sid = entry.get("spectrum_project_id") or entry.get("project_id")
|
||||
return (env_id or sid, env_sec or entry.get("project_secret"))
|
||||
return env_id, env_sec
|
||||
|
||||
|
||||
def store_project_credentials(project_id: str, project_secret: str, **extra: Any) -> None:
|
||||
"""Persist the Spectrum project's id+secret under ``credential_pool.photon_project``."""
|
||||
def load_dashboard_project_id() -> Optional[str]:
|
||||
"""Return the Dashboard project id (for management API calls)."""
|
||||
env_id = os.getenv("PHOTON_DASHBOARD_PROJECT_ID")
|
||||
if env_id:
|
||||
return env_id
|
||||
auth = _load_auth()
|
||||
record = {
|
||||
"project_id": project_id,
|
||||
proj = auth.get("credential_pool", {}).get("photon_project") or []
|
||||
if isinstance(proj, list) and proj:
|
||||
return proj[0].get("dashboard_project_id") or proj[0].get("project_id")
|
||||
return None
|
||||
|
||||
|
||||
def store_project_credentials(
|
||||
*,
|
||||
spectrum_project_id: str,
|
||||
project_secret: str,
|
||||
dashboard_project_id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Persist project credentials to both .env (runtime) and auth.json (mgmt).
|
||||
|
||||
The runtime SDK creds land in ``~/.hermes/.env`` via the same
|
||||
``save_env_value`` helper every other channel uses, so the gateway picks
|
||||
them up from the environment with zero adapter changes. A copy of the
|
||||
non-secret ids (plus the secret, for offline ``status``) is written to
|
||||
``auth.json`` so management commands work even when ``.env`` hasn't been
|
||||
loaded into the current process.
|
||||
"""
|
||||
auth = _load_auth()
|
||||
record: Dict[str, Any] = {
|
||||
"spectrum_project_id": spectrum_project_id,
|
||||
"project_secret": project_secret,
|
||||
"issued_at": int(time.time()),
|
||||
}
|
||||
record.update(extra)
|
||||
if dashboard_project_id:
|
||||
record["dashboard_project_id"] = dashboard_project_id
|
||||
if name:
|
||||
record["name"] = name
|
||||
auth.setdefault("credential_pool", {})["photon_project"] = [record]
|
||||
_save_auth(auth)
|
||||
_persist_runtime_env(spectrum_project_id, project_secret)
|
||||
|
||||
|
||||
def _persist_runtime_env(spectrum_project_id: str, project_secret: str) -> None:
|
||||
"""Write the SDK creds to ``~/.hermes/.env`` (canonical runtime store).
|
||||
|
||||
Isolated in its own helper so the secret value flows straight into
|
||||
``save_env_value`` without ever being bound to a printable local in a
|
||||
caller — same CodeQL-clean-flow rationale as the rest of this module.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import save_env_value
|
||||
except ImportError:
|
||||
logger.warning("photon: hermes_cli.config unavailable — skipping .env write")
|
||||
return
|
||||
try:
|
||||
save_env_value("PHOTON_PROJECT_ID", spectrum_project_id)
|
||||
save_env_value("PHOTON_PROJECT_SECRET", project_secret)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning("photon: could not write project creds to .env: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -181,8 +248,8 @@ def _dashboard_host() -> str:
|
|||
return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/")
|
||||
|
||||
|
||||
def _spectrum_host() -> str:
|
||||
return (os.getenv("PHOTON_API_HOST") or DEFAULT_SPECTRUM_HOST).rstrip("/")
|
||||
def _bearer(token: str) -> Dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def request_device_code(
|
||||
|
|
@ -218,16 +285,22 @@ def poll_for_token(
|
|||
) -> str:
|
||||
"""Poll ``/api/auth/device/token`` until the user approves.
|
||||
|
||||
Returns the bearer token from the ``set-auth-token`` response header
|
||||
(Photon's documented mechanism). Falls back to ``session.access_token``
|
||||
in the JSON body if the header is absent — see the API spec.
|
||||
Mirrors the official CLI's polling loop: sleep first, then poll;
|
||||
``authorization_pending`` keeps the interval, ``slow_down`` adds 5s,
|
||||
HTTP 429 adds 10s, and ``access_denied`` / ``expired_token`` abort.
|
||||
|
||||
The bearer token comes from the response body's top-level
|
||||
``access_token`` (better-auth device-grant shape), with
|
||||
``session.access_token`` and the ``set-auth-token`` header kept as
|
||||
fallbacks for API drift.
|
||||
"""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon device login")
|
||||
url = f"{_dashboard_host()}/api/auth/device/token"
|
||||
deadline = time.time() + (timeout or code.expires_in or DEFAULT_POLL_TIMEOUT)
|
||||
sleep = interval or code.interval or DEFAULT_POLL_INTERVAL
|
||||
sleep = interval if interval is not None else (code.interval or DEFAULT_POLL_INTERVAL)
|
||||
while time.time() < deadline:
|
||||
time.sleep(sleep)
|
||||
try:
|
||||
resp = httpx.post(
|
||||
url,
|
||||
|
|
@ -240,7 +313,6 @@ def poll_for_token(
|
|||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning("photon: device-token poll failed: %s", e)
|
||||
time.sleep(sleep)
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
body: Dict[str, Any] = {}
|
||||
|
|
@ -259,34 +331,35 @@ def poll_for_token(
|
|||
"data.access_token, accessToken, or set-auth-token)."
|
||||
)
|
||||
return candidates[0].token
|
||||
if resp.status_code == 429:
|
||||
# RFC 8628 §3.5 — treat 429 as slow_down.
|
||||
sleep += 10
|
||||
if on_pending:
|
||||
_safe(on_pending)
|
||||
continue
|
||||
if resp.status_code == 400:
|
||||
# RFC 8628 §3.5 — error codes are returned with 400.
|
||||
body: Dict[str, Any] = {}
|
||||
body = {}
|
||||
try:
|
||||
body = resp.json() or {}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
err = body.get("error") or body.get("message") or ""
|
||||
if err in ("authorization_pending", "slow_down"):
|
||||
if err == "authorization_pending":
|
||||
if on_pending:
|
||||
try:
|
||||
on_pending()
|
||||
except Exception:
|
||||
pass
|
||||
if err == "slow_down":
|
||||
sleep += 5
|
||||
time.sleep(sleep)
|
||||
_safe(on_pending)
|
||||
continue
|
||||
if err == "slow_down":
|
||||
sleep += 5
|
||||
if on_pending:
|
||||
_safe(on_pending)
|
||||
continue
|
||||
if err in ("expired_token", "access_denied"):
|
||||
raise RuntimeError(f"Photon login failed: {err}")
|
||||
# Unknown error — surface it
|
||||
raise RuntimeError(f"Photon device token error: {err or resp.text}")
|
||||
# Unexpected status; log and retry
|
||||
logger.warning(
|
||||
"photon: device-token unexpected status %s: %s",
|
||||
resp.status_code, resp.text[:200],
|
||||
)
|
||||
time.sleep(sleep)
|
||||
raise TimeoutError("Photon device login timed out")
|
||||
|
||||
|
||||
|
|
@ -426,6 +499,13 @@ def _validated_dashboard_token(candidates: list) -> str:
|
|||
raise RuntimeError("Photon did not return a usable dashboard token")
|
||||
|
||||
|
||||
def _safe(fn: Callable[[], None]) -> None:
|
||||
try:
|
||||
fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def login_device_flow(
|
||||
*,
|
||||
client_id: str = DEFAULT_CLIENT_ID,
|
||||
|
|
@ -434,15 +514,12 @@ def login_device_flow(
|
|||
) -> str:
|
||||
"""Run the full device-code login flow and persist the token.
|
||||
|
||||
Returns the bearer token. ``on_user_code`` is a callback receiving the
|
||||
:class:`DeviceCode` so callers can print + optionally open the browser.
|
||||
Returns the bearer token. ``on_user_code`` receives the
|
||||
:class:`DeviceCode` so callers can print it + optionally open a browser.
|
||||
"""
|
||||
code = request_device_code(client_id=client_id)
|
||||
if on_user_code:
|
||||
try:
|
||||
on_user_code(code)
|
||||
except Exception:
|
||||
pass
|
||||
_safe(lambda: on_user_code(code))
|
||||
if open_browser:
|
||||
try:
|
||||
import webbrowser
|
||||
|
|
@ -461,280 +538,320 @@ def login_device_flow(
|
|||
return token
|
||||
|
||||
|
||||
def get_session(token: str) -> Dict[str, Any]:
|
||||
"""GET ``/api/auth/get-session`` — confirm the token + fetch the user."""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
url = f"{_dashboard_host()}/api/auth/get-session"
|
||||
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json() or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard API: create project
|
||||
# Dashboard API: projects
|
||||
|
||||
def _unwrap_list(data: Any) -> List[Dict[str, Any]]:
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for key in ("data", "projects", "users", "lines", "items"):
|
||||
inner = data.get(key)
|
||||
if isinstance(inner, list):
|
||||
return inner
|
||||
return []
|
||||
|
||||
|
||||
def list_projects(token: str) -> List[Dict[str, Any]]:
|
||||
"""GET ``/api/projects`` — return the caller's projects."""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
url = f"{_dashboard_host()}/api/projects"
|
||||
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return _unwrap_list(resp.json())
|
||||
|
||||
|
||||
def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the first project whose name matches (case-insensitive)."""
|
||||
target = (name or "").strip().lower()
|
||||
for proj in list_projects(token):
|
||||
if (proj.get("name") or "").strip().lower() == target:
|
||||
return proj
|
||||
return None
|
||||
|
||||
|
||||
def get_project(token: str, project_id: str) -> Dict[str, Any]:
|
||||
"""GET ``/api/projects/{id}`` — includes ``spectrum`` + ``spectrumProjectId``."""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
url = f"{_dashboard_host()}/api/projects/{project_id}"
|
||||
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json() or {}
|
||||
|
||||
|
||||
def create_project(
|
||||
token: str,
|
||||
*,
|
||||
name: str,
|
||||
name: str = DEFAULT_PROJECT_NAME,
|
||||
location: str = "United States",
|
||||
platforms: Optional[list] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""POST ``/api/projects/`` with ``spectrum: true`` and return the response.
|
||||
|
||||
The response includes ``spectrumProjectId`` and ``projectSecret`` — those
|
||||
are the HTTP Basic credentials for the Spectrum API. Photon only
|
||||
returns ``projectSecret`` to project owners at creation time.
|
||||
"""
|
||||
"""POST ``/api/projects`` with ``spectrum: true`` and return ``{success, id}``."""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon project creation")
|
||||
url = f"{_dashboard_host()}/api/projects/"
|
||||
url = f"{_dashboard_host()}/api/projects"
|
||||
body: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"location": location,
|
||||
"spectrum": True,
|
||||
"platforms": platforms or ["imessage"],
|
||||
"template": False,
|
||||
"observability": False,
|
||||
}
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp = httpx.post(url, json=body, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
data = resp.json() or {}
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"Photon create-project failed: {data['error']}")
|
||||
if not data.get("id"):
|
||||
raise RuntimeError("Photon create-project did not return a project id")
|
||||
return data
|
||||
|
||||
|
||||
def ensure_spectrum_enabled(token: str, project_id: str) -> Dict[str, Any]:
|
||||
"""Enable Spectrum on the project if needed; return the project dict.
|
||||
|
||||
The dashboard exposes Spectrum as a toggle, so we only flip it when
|
||||
``spectrum`` is currently false, then re-fetch to pick up the freshly
|
||||
populated ``spectrumProjectId``.
|
||||
"""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
proj = get_project(token, project_id)
|
||||
if not proj.get("spectrum"):
|
||||
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/toggle"
|
||||
resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
proj = get_project(token, project_id)
|
||||
if not proj.get("spectrumProjectId"):
|
||||
raise RuntimeError(
|
||||
"Spectrum is enabled but the project has no spectrumProjectId yet — "
|
||||
"retry in a moment, or enable Spectrum from the dashboard."
|
||||
)
|
||||
return proj
|
||||
|
||||
|
||||
def regenerate_project_secret(token: str, project_id: str) -> str:
|
||||
"""POST ``/api/projects/{id}/regenerate-secret`` → the new project secret.
|
||||
|
||||
This is the only way to read a project secret (the dashboard shows it
|
||||
exactly once), so callers should persist the returned value immediately.
|
||||
"""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
url = f"{_dashboard_host()}/api/projects/{project_id}/regenerate-secret"
|
||||
resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json() or {}
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"Photon regenerate-secret failed: {data['error']}")
|
||||
secret = data.get("projectSecret")
|
||||
if not secret:
|
||||
raise RuntimeError("Photon regenerate-secret returned no projectSecret")
|
||||
return str(secret)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spectrum API: create user
|
||||
# Dashboard API: spectrum 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[]``."""
|
||||
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()
|
||||
return _unwrap_list(resp.json())
|
||||
|
||||
|
||||
def find_user_by_phone(
|
||||
token: str, project_id: 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):
|
||||
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,
|
||||
user_type: str = "shared",
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
assigned_phone_number: Optional[str] = None,
|
||||
send_invite: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""POST ``/projects/{id}/users/`` on the Spectrum API.
|
||||
|
||||
For free users we always pass ``type=shared``; Photon's Cosmos pool
|
||||
assigns the iMessage line. ``assigned_phone_number`` is only valid
|
||||
for the paid ``dedicated`` mode.
|
||||
"""
|
||||
"""POST ``/api/projects/{id}/spectrum/users`` and return the created 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"{_spectrum_host()}/projects/{project_id}/users/"
|
||||
body: Dict[str, Any] = {"type": user_type, "phoneNumber": phone_number}
|
||||
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users"
|
||||
body: Dict[str, Any] = {"phoneNumber": phone_number, "sendInvite": send_invite}
|
||||
if first_name:
|
||||
body["firstName"] = first_name
|
||||
if last_name:
|
||||
body["lastName"] = last_name
|
||||
if email:
|
||||
body["email"] = email
|
||||
if assigned_phone_number:
|
||||
body["assignedPhoneNumber"] = assigned_phone_number
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json=body,
|
||||
auth=(project_id, project_secret),
|
||||
timeout=30.0,
|
||||
)
|
||||
resp = httpx.post(url, json=body, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json() or {}
|
||||
if not data.get("succeed"):
|
||||
raise RuntimeError(
|
||||
f"Photon create-user failed: {data.get('message') or data}"
|
||||
)
|
||||
return data.get("data") or {}
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"Photon create-user failed: {data['error']}")
|
||||
return data.get("user") or data
|
||||
|
||||
|
||||
def register_user_if_absent(
|
||||
token: str,
|
||||
project_id: str,
|
||||
*,
|
||||
phone_number: str,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> Tuple[Dict[str, Any], bool]:
|
||||
"""Idempotently register a Spectrum user.
|
||||
|
||||
Returns ``(user, created)`` — ``created`` is False when a user with the
|
||||
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)
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
user = create_user(
|
||||
token, project_id,
|
||||
phone_number=phone_number,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email,
|
||||
)
|
||||
return user, True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spectrum API: webhook registration
|
||||
#
|
||||
# Endpoints from https://photon.codes/docs/webhooks/overview:
|
||||
# POST /projects/{id}/webhooks/ register, returns signing secret ONCE
|
||||
# GET /projects/{id}/webhooks/ list
|
||||
# DELETE /projects/{id}/webhooks/{wid} remove
|
||||
# Dashboard API: iMessage lines (the assigned number inventory)
|
||||
|
||||
def register_webhook(
|
||||
project_id: str, project_secret: str, *, webhook_url: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Register a webhook URL with Photon and return the API response.
|
||||
|
||||
Photon returns the per-URL signing secret exactly once in this
|
||||
response, so callers who need to persist it should hand the
|
||||
response to :func:`persist_webhook_signing_secret` immediately —
|
||||
that helper writes the value into ``~/.hermes/.env`` (mode 0o600,
|
||||
existing entries preserved) without the secret value ever needing
|
||||
to leave this module.
|
||||
"""
|
||||
def list_lines(token: str, project_id: str) -> List[Dict[str, Any]]:
|
||||
"""GET ``/api/projects/{id}/lines`` → ``[{id, platform, phoneNumber, status}]``."""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon webhook registration")
|
||||
url = f"{_spectrum_host()}/projects/{project_id}/webhooks/"
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
url = f"{_dashboard_host()}/api/projects/{project_id}/lines"
|
||||
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return _unwrap_list(resp.json())
|
||||
|
||||
|
||||
def add_line(
|
||||
token: str, project_id: str, *, platform: str = "imessage",
|
||||
) -> Dict[str, Any]:
|
||||
"""POST ``/api/projects/{id}/lines`` to provision a new line."""
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon")
|
||||
url = f"{_dashboard_host()}/api/projects/{project_id}/lines"
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={"webhookUrl": webhook_url},
|
||||
auth=(project_id, project_secret),
|
||||
timeout=30.0,
|
||||
url, json={"platform": platform}, headers=_bearer(token), timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json() or {}
|
||||
if not data.get("succeed"):
|
||||
raise RuntimeError(
|
||||
f"Photon register-webhook failed: {data.get('message') or data}"
|
||||
)
|
||||
return data.get("data") or {}
|
||||
if data.get("error"):
|
||||
raise RuntimeError(f"Photon add-line failed: {data['error']}")
|
||||
return data.get("line") or data
|
||||
|
||||
|
||||
def get_imessage_line(
|
||||
token: str, project_id: str, *, create_if_missing: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return the project's iMessage line (the number to text the agent).
|
||||
|
||||
If none exists and ``create_if_missing`` is set, provision one. Returns
|
||||
``None`` if there is no line and provisioning failed.
|
||||
"""
|
||||
for line in list_lines(token, project_id):
|
||||
if (line.get("platform") or "").lower() == "imessage":
|
||||
return line
|
||||
if create_if_missing:
|
||||
try:
|
||||
return add_line(token, project_id, platform="imessage")
|
||||
except Exception as e:
|
||||
logger.warning("photon: could not auto-provision iMessage line: %s", e)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential status (display-only — never emits raw secret material)
|
||||
|
||||
def print_credential_summary(emit: Any = print) -> None:
|
||||
"""Pretty-print the credential status table via the *emit* callback.
|
||||
|
||||
Same isolation rationale as :func:`persist_webhook_signing_secret`:
|
||||
all secret-bearing reads happen inside this function; the *emit*
|
||||
callback only ever receives display literals like ``"✓ stored"``
|
||||
or a project UUID. No tainted variable ever escapes into the
|
||||
caller's scope. Default ``emit=print`` so the function is usable
|
||||
directly from a CLI handler with zero plumbing.
|
||||
Every secret-bearing read is reduced to a display literal inside this
|
||||
function (``"✓ stored"`` / ``"✗ missing"`` / a non-secret id); the
|
||||
callback only ever receives the assembled banner string, so no tainted
|
||||
value escapes into the caller's scope.
|
||||
"""
|
||||
# Resolve every credential read into a plain display string FIRST,
|
||||
# in a tight block. The intermediate `labels` dict only ever stores
|
||||
# literals from a finite set ("✓ stored" / "✗ missing" / "✓ set" /
|
||||
# "⚠ unset — verification disabled" / a project UUID) — never a
|
||||
# credential's raw bytes. We then assemble the whole banner into
|
||||
# one string and call emit() exactly once with that string, so the
|
||||
# static taint analyzer sees a single sink that consumes only a
|
||||
# joined literal blob.
|
||||
labels: Dict[str, str] = {}
|
||||
if load_photon_token():
|
||||
labels["device_token"] = "✓ stored"
|
||||
else:
|
||||
labels["device_token"] = "✗ missing (run `hermes photon setup`)"
|
||||
pid, sec = load_project_credentials()
|
||||
labels["project_id"] = pid if pid else "✗ missing"
|
||||
labels["device_token"] = (
|
||||
"✓ stored" if load_photon_token()
|
||||
else "✗ missing (run `hermes photon setup`)"
|
||||
)
|
||||
sid, sec = load_project_credentials()
|
||||
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"
|
||||
if os.getenv("PHOTON_WEBHOOK_SECRET"):
|
||||
labels["webhook_key"] = "✓ set"
|
||||
else:
|
||||
labels["webhook_key"] = "⚠ unset — verification disabled"
|
||||
|
||||
rows = [
|
||||
"Photon iMessage status",
|
||||
"──────────────────────",
|
||||
" device token : " + labels["device_token"],
|
||||
" project id : " + labels["project_id"],
|
||||
" project key : " + labels["project_key"],
|
||||
" webhook key : " + labels["webhook_key"],
|
||||
" dashboard project : " + labels["dashboard_project_id"],
|
||||
" spectrum project id : " + labels["spectrum_project_id"],
|
||||
" project secret : " + labels["project_key"],
|
||||
]
|
||||
emit("\n".join(rows))
|
||||
|
||||
|
||||
def credential_summary() -> Dict[str, str]:
|
||||
"""Return a fully pre-formatted credential status dict.
|
||||
|
||||
Caller-safe: every value is one of ``"✓ stored"`` / ``"✗ missing"``
|
||||
/ ``"⚠ unset — verification disabled"`` / ``"✓ set"`` literals, or a
|
||||
UUID for the project id. No secret-bearing string ever leaves this
|
||||
function — read-and-bool-cast happens entirely inside the closure.
|
||||
"""
|
||||
"""Return a fully pre-formatted credential status dict (no raw secrets)."""
|
||||
def _present_token() -> str:
|
||||
return "✓ stored" if load_photon_token() else "✗ missing (run `hermes photon setup`)"
|
||||
return (
|
||||
"✓ stored" if load_photon_token()
|
||||
else "✗ missing (run `hermes photon setup`)"
|
||||
)
|
||||
|
||||
def _present_project_id() -> str:
|
||||
pid, _sec = load_project_credentials()
|
||||
return pid or "✗ missing"
|
||||
def _present_spectrum_id() -> str:
|
||||
sid, _sec = load_project_credentials()
|
||||
return sid or "✗ missing"
|
||||
|
||||
def _present_project_secret() -> str:
|
||||
_pid, sec = load_project_credentials()
|
||||
def _present_secret() -> str:
|
||||
_sid, sec = load_project_credentials()
|
||||
return "✓ stored" if sec else "✗ missing"
|
||||
|
||||
def _present_webhook_secret() -> str:
|
||||
return "✓ set" if os.getenv("PHOTON_WEBHOOK_SECRET") else "⚠ unset — verification disabled"
|
||||
|
||||
return {
|
||||
"device_token": _present_token(),
|
||||
"project_id": _present_project_id(),
|
||||
"project_key": _present_project_secret(),
|
||||
"webhook_key": _present_webhook_secret(),
|
||||
"dashboard_project_id": load_dashboard_project_id() or "—",
|
||||
"spectrum_project_id": _present_spectrum_id(),
|
||||
"project_key": _present_secret(),
|
||||
}
|
||||
|
||||
|
||||
def persist_webhook_signing_secret(
|
||||
webhook_data: Dict[str, Any],
|
||||
*,
|
||||
on_summary: Optional[Any] = None,
|
||||
) -> bool:
|
||||
"""Persist a webhook signing secret via Hermes' canonical .env writer.
|
||||
|
||||
Delegates to :func:`hermes_cli.config.save_env_value` — the same
|
||||
helper that backs every other API-key persistence path in Hermes
|
||||
Agent (OpenAI key, Anthropic key, Telegram token, ...). The secret
|
||||
value is read directly from ``webhook_data['signingSecret']`` (or
|
||||
``['secret']`` fallback) and handed to that helper without ever
|
||||
being bound to a local in any module that prints or logs.
|
||||
|
||||
Returns ``True`` on success, ``False`` if the response had no
|
||||
secret OR the write failed. The optional ``on_summary`` callable
|
||||
receives a plain string with no credential material, suitable for
|
||||
printing — e.g. ``"Wrote to /home/u/.hermes/.env"`` or
|
||||
``"register response: {redacted dict json}"``. We do the
|
||||
formatting here so callers stay clear of the taint flow CodeQL
|
||||
tracks through functions that touch secrets.
|
||||
"""
|
||||
if not isinstance(webhook_data, dict):
|
||||
return False
|
||||
has_secret = bool(webhook_data.get("signingSecret") or webhook_data.get("secret"))
|
||||
redacted = {
|
||||
k: ("<redacted>" if k in ("signingSecret", "secret") else v)
|
||||
for k, v in webhook_data.items()
|
||||
}
|
||||
if on_summary is not None:
|
||||
try:
|
||||
on_summary("webhook registration response (redacted):")
|
||||
on_summary(json.dumps(redacted, indent=2))
|
||||
except Exception:
|
||||
pass
|
||||
if not has_secret:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import save_env_value
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
save_env_value(
|
||||
"PHOTON_WEBHOOK_SECRET",
|
||||
webhook_data.get("signingSecret") or webhook_data.get("secret") or "",
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
if on_summary is not None:
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
env_path = Path(get_hermes_home()) / ".env"
|
||||
except Exception:
|
||||
env_path = Path(os.path.expanduser("~/.hermes")) / ".env"
|
||||
try:
|
||||
on_summary(f"signing key saved to {env_path}")
|
||||
on_summary("(Photon only returns this once — keep the file safe)")
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def list_webhooks(project_id: str, project_secret: str) -> list:
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon webhook listing")
|
||||
url = f"{_spectrum_host()}/projects/{project_id}/webhooks/"
|
||||
resp = httpx.get(url, auth=(project_id, project_secret), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json() or {}
|
||||
return data.get("data") or []
|
||||
|
||||
|
||||
def delete_webhook(
|
||||
project_id: str, project_secret: str, *, webhook_id: str,
|
||||
) -> None:
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon webhook deletion")
|
||||
url = f"{_spectrum_host()}/projects/{project_id}/webhooks/{webhook_id}"
|
||||
resp = httpx.delete(url, auth=(project_id, project_secret), timeout=30.0)
|
||||
if resp.status_code not in (200, 204, 404):
|
||||
resp.raise_for_status()
|
||||
|
|
|
|||
|
|
@ -7,19 +7,18 @@ Subcommands:
|
|||
setup full first-time setup (device login + project + user + sidecar)
|
||||
status show login + project + sidecar dep state
|
||||
install-sidecar npm install inside plugins/platforms/photon/sidecar/
|
||||
webhook register register the local webhook URL with Photon
|
||||
webhook list list registered webhooks
|
||||
webhook delete delete a webhook by id
|
||||
|
||||
The device-code login runs automatically as the first step of ``setup``;
|
||||
there is no standalone ``login`` verb (matching how every other Hermes
|
||||
gateway channel onboards through a single setup surface).
|
||||
|
||||
Photon uses the spectrum-ts gRPC stream for inbound — there is no webhook
|
||||
to register, so there are no webhook subcommands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
|
@ -38,9 +37,14 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
|
|||
"""Wire up `hermes photon ...` subcommands."""
|
||||
subs = parser.add_subparsers(dest="photon_command", required=False)
|
||||
|
||||
p_setup = subs.add_parser("setup", help="First-time setup (device login + project + user + sidecar)")
|
||||
p_setup.add_argument("--project-name", default=None, help="Project name (default: 'Hermes Agent')")
|
||||
p_setup.add_argument("--phone", default=None, help="Your E.164 phone number (e.g. +15551234567)")
|
||||
p_setup = subs.add_parser(
|
||||
"setup",
|
||||
help="First-time setup (device login + project + user + sidecar)",
|
||||
)
|
||||
p_setup.add_argument("--project-name", default=None,
|
||||
help="Project name (default: 'Hermes Agent')")
|
||||
p_setup.add_argument("--phone", default=None,
|
||||
help="Your E.164 phone number (e.g. +15551234567)")
|
||||
p_setup.add_argument("--first-name", default=None)
|
||||
p_setup.add_argument("--last-name", default=None)
|
||||
p_setup.add_argument("--email", default=None)
|
||||
|
|
@ -52,14 +56,6 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
|
|||
subs.add_parser("status", help="Show login + project + sidecar dep state")
|
||||
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
|
||||
|
||||
p_hook = subs.add_parser("webhook", help="Manage Photon webhook registrations")
|
||||
hook_subs = p_hook.add_subparsers(dest="photon_webhook_command", required=True)
|
||||
p_hook_reg = hook_subs.add_parser("register", help="Register a webhook URL")
|
||||
p_hook_reg.add_argument("url", help="Publicly reachable URL Photon should POST to")
|
||||
hook_subs.add_parser("list", help="List registered webhooks for the current project")
|
||||
p_hook_del = hook_subs.add_parser("delete", help="Delete a webhook by id")
|
||||
p_hook_del.add_argument("webhook_id")
|
||||
|
||||
parser.set_defaults(func=dispatch)
|
||||
|
||||
|
||||
|
|
@ -77,8 +73,6 @@ def dispatch(args: argparse.Namespace) -> int:
|
|||
return _cmd_status(args)
|
||||
if sub == "install-sidecar":
|
||||
return _cmd_install_sidecar(args)
|
||||
if sub == "webhook":
|
||||
return _cmd_webhook(args)
|
||||
print(f"unknown subcommand: {sub}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
|
@ -122,7 +116,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
|||
# 1. Login (skip if we already have a token).
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("[1/4] No Photon token found — running device login...")
|
||||
print("[1/5] No Photon token found — running device login...")
|
||||
rc = _run_device_login(args)
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
|
@ -131,85 +125,121 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
|||
print("login completed but token was not stored", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
print("[1/4] Reusing existing Photon token")
|
||||
print("[1/5] Reusing existing Photon token")
|
||||
|
||||
# 2. Create (or surface existing) project.
|
||||
existing_id, existing_secret = photon_auth.load_project_credentials()
|
||||
project_id: str
|
||||
project_secret: str
|
||||
if existing_id and existing_secret:
|
||||
project_id, project_secret = existing_id, existing_secret
|
||||
# `project_id` is a Photon-assigned UUID, not a secret — but we
|
||||
# keep the print terse to avoid CodeQL flow noise.
|
||||
print("[2/4] Reusing existing Photon project")
|
||||
else:
|
||||
name = args.project_name or "Hermes Agent"
|
||||
print(f"[2/4] Creating Photon project '{name}' (spectrum=true, imessage)...")
|
||||
try:
|
||||
data = photon_auth.create_project(token, name=name)
|
||||
except Exception as e:
|
||||
print(f"create-project failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
project_id = data.get("spectrumProjectId") or data.get("id") or ""
|
||||
project_secret = data.get("projectSecret") or ""
|
||||
if not project_id or not project_secret:
|
||||
print(
|
||||
"create-project did not return spectrumProjectId + "
|
||||
"projectSecret. Re-run after enabling Spectrum on the "
|
||||
"project, or open https://app.photon.codes/ to fetch the "
|
||||
"secret manually.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
photon_auth.store_project_credentials(project_id, project_secret, name=name)
|
||||
print(" ✓ project provisioned (run `hermes photon status` to see the id)")
|
||||
# 2. Find or create the "Hermes Agent" project.
|
||||
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
|
||||
dashboard_id = photon_auth.load_dashboard_project_id()
|
||||
try:
|
||||
if dashboard_id:
|
||||
print("[2/5] Reusing configured Photon project")
|
||||
else:
|
||||
existing = photon_auth.find_project_by_name(token, name)
|
||||
if existing and existing.get("id"):
|
||||
dashboard_id = existing["id"]
|
||||
print(f"[2/5] Found existing project '{name}'")
|
||||
else:
|
||||
print(f"[2/5] Creating Photon project '{name}'...")
|
||||
created = photon_auth.create_project(token, name=name)
|
||||
dashboard_id = created.get("id")
|
||||
print(" ✓ project created")
|
||||
except Exception as e:
|
||||
print(f"project setup failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not dashboard_id:
|
||||
print("could not resolve a Photon project id", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. Create a Spectrum user for the operator.
|
||||
# 3. Enable Spectrum, fetch the spectrum project id, rotate the secret,
|
||||
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
|
||||
try:
|
||||
print("[3/5] Enabling Spectrum and provisioning credentials...")
|
||||
proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id)
|
||||
spectrum_id = proj.get("spectrumProjectId")
|
||||
if not spectrum_id:
|
||||
print("spectrum provisioning failed: no spectrum project id", file=sys.stderr)
|
||||
return 1
|
||||
spectrum_id = str(spectrum_id)
|
||||
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id=spectrum_id,
|
||||
project_secret=secret,
|
||||
dashboard_project_id=dashboard_id,
|
||||
name=name,
|
||||
)
|
||||
# spectrum_id is an opaque non-secret id; safe to show.
|
||||
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
|
||||
except Exception as e:
|
||||
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 4. Register the operator's phone number as a Spectrum user (idempotent).
|
||||
phone = args.phone or _prompt(
|
||||
"Your iMessage phone number (E.164, e.g. +15551234567): "
|
||||
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): "
|
||||
)
|
||||
if not phone:
|
||||
print("[3/4] Skipped user creation (no phone given). Re-run with --phone later.")
|
||||
print(" Skipped user registration (no phone given). Re-run with --phone later.")
|
||||
else:
|
||||
print("[3/4] Creating shared Spectrum user...")
|
||||
first_name = args.first_name
|
||||
email = args.email
|
||||
# The dashboard may require a name/email; prompt interactively when
|
||||
# we have a TTY and they weren't supplied, but allow skipping.
|
||||
if first_name is None:
|
||||
first_name = _prompt(" First name (optional, Enter to skip): ") or None
|
||||
if email is None:
|
||||
email = _prompt(" Email (optional, Enter to skip): ") or None
|
||||
try:
|
||||
photon_auth.create_user(
|
||||
project_id, project_secret,
|
||||
_user, created = photon_auth.register_user_if_absent(
|
||||
token, dashboard_id,
|
||||
phone_number=phone,
|
||||
first_name=args.first_name,
|
||||
first_name=first_name,
|
||||
last_name=args.last_name,
|
||||
email=args.email,
|
||||
email=email,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"create-user failed: {e}", file=sys.stderr)
|
||||
except ValueError as e:
|
||||
print(f" invalid phone number: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(" ✓ user created — check `hermes photon status` or the dashboard for the assigned iMessage line")
|
||||
except Exception as e:
|
||||
print(f" user registration failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(" ✓ phone registered" if created else " ✓ phone already registered")
|
||||
|
||||
# 4. Sidecar deps.
|
||||
if args.skip_sidecar_install:
|
||||
print("[4/4] Skipping sidecar npm install (--skip-sidecar-install)")
|
||||
# 5. Surface the assigned iMessage line (the number to text the agent).
|
||||
try:
|
||||
line = photon_auth.get_imessage_line(token, dashboard_id)
|
||||
except Exception as e:
|
||||
line = None
|
||||
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
|
||||
if line and line.get("phoneNumber"):
|
||||
status = line.get("status") or "active"
|
||||
print()
|
||||
print("┌─ Your agent's iMessage number ───────────────────────────────")
|
||||
print(f"│ 📱 {line['phoneNumber']} ({status})")
|
||||
print("│ Text this number from your phone to talk to your agent.")
|
||||
print("└──────────────────────────────────────────────────────────────")
|
||||
else:
|
||||
print("[4/4] Installing Node sidecar deps (spectrum-ts)...")
|
||||
print(" No iMessage line assigned yet — check the Photon dashboard.")
|
||||
|
||||
# 6. Sidecar deps (spectrum-ts).
|
||||
if args.skip_sidecar_install:
|
||||
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
|
||||
else:
|
||||
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
|
||||
rc = _install_sidecar()
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Next: register a webhook URL Photon can reach:")
|
||||
print(" hermes photon webhook register https://YOUR-PUBLIC-URL/photon/webhook")
|
||||
print(" Then start the gateway:")
|
||||
print(" hermes gateway start --platform photon")
|
||||
print(" Start the gateway: hermes gateway start --platform photon")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_status(_args: argparse.Namespace) -> int:
|
||||
# Defer the whole table to auth.print_credential_summary — its emit
|
||||
# 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.
|
||||
photon_auth.print_credential_summary(print)
|
||||
# The two non-credential rows live here so the helper stays purely
|
||||
# about credentials.
|
||||
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
|
||||
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
|
||||
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
|
||||
|
|
@ -218,8 +248,7 @@ def _cmd_status(_args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
|
||||
rc = _install_sidecar()
|
||||
return rc
|
||||
return _install_sidecar()
|
||||
|
||||
|
||||
def _install_sidecar() -> int:
|
||||
|
|
@ -242,64 +271,6 @@ def _install_sidecar() -> int:
|
|||
return proc.returncode
|
||||
|
||||
|
||||
def _cmd_webhook(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "photon_webhook_command", None)
|
||||
project_id, project_secret = photon_auth.load_project_credentials()
|
||||
if not (project_id and project_secret):
|
||||
print(
|
||||
"no Photon project configured — run `hermes photon setup` first",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if sub == "register":
|
||||
try:
|
||||
data = photon_auth.register_webhook(
|
||||
project_id, project_secret, webhook_url=args.url
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"register failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
# The helper does all the formatting + writing; cli.py never
|
||||
# touches the signing-secret value, the path it was written
|
||||
# to, or even the redacted-response dict. on_summary is a
|
||||
# plain printer callback.
|
||||
ok = photon_auth.persist_webhook_signing_secret(data, on_summary=print)
|
||||
if not ok:
|
||||
print(
|
||||
"‼ Photon returned no signing secret in the response, "
|
||||
"or the file write failed. Inspect your home directory "
|
||||
"permissions and re-run; do not retry without first "
|
||||
"deleting the orphaned webhook from the Photon dashboard.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if sub == "list":
|
||||
try:
|
||||
data = photon_auth.list_webhooks(project_id, project_secret)
|
||||
except Exception as e:
|
||||
print(f"list failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(data, indent=2))
|
||||
return 0
|
||||
|
||||
if sub == "delete":
|
||||
try:
|
||||
photon_auth.delete_webhook(
|
||||
project_id, project_secret, webhook_id=args.webhook_id
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"delete failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"deleted webhook {args.webhook_id}")
|
||||
return 0
|
||||
|
||||
print(f"unknown webhook subcommand: {sub}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway-setup entry point
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,52 +1,37 @@
|
|||
name: photon-platform
|
||||
label: Photon iMessage
|
||||
kind: platform
|
||||
version: 0.1.0
|
||||
version: 0.2.0
|
||||
description: >
|
||||
Photon Spectrum gateway adapter for Hermes Agent.
|
||||
Connects to iMessage (and other Spectrum interfaces) through Photon's
|
||||
managed Spectrum platform. Inbound messages arrive as signed webhooks
|
||||
on a local aiohttp server; outbound messages are sent via a small
|
||||
supervised Node sidecar that runs the `spectrum-ts` SDK (Photon does
|
||||
not currently expose a public HTTP send endpoint).
|
||||
managed Spectrum platform. Both directions run over the `spectrum-ts`
|
||||
SDK's long-lived gRPC stream via a small supervised Node sidecar —
|
||||
inbound messages arrive on the SDK's `app.messages` stream (no webhook,
|
||||
no public URL, no signing secret), and outbound messages are sent over
|
||||
the same sidecar.
|
||||
|
||||
The plugin ships with a `hermes photon` CLI for the one-time login
|
||||
+ project + user setup, persists Spectrum credentials to
|
||||
``~/.hermes/auth.json`` under ``credential_pool.photon`` (token) and
|
||||
``credential_pool.photon_project`` (project id + secret), and exposes
|
||||
Photon's free shared-line model so users can get started without a
|
||||
paid plan.
|
||||
The plugin ships with a `hermes photon` CLI for the one-time device
|
||||
login + project + user setup. Runtime credentials are written to
|
||||
``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = the Spectrum project id,
|
||||
``PHOTON_PROJECT_SECRET``) like every other channel, with management
|
||||
metadata (device token, dashboard project id) in ``~/.hermes/auth.json``.
|
||||
Photon's free shared-line model lets users get started without a paid plan.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: PHOTON_PROJECT_ID
|
||||
description: "Spectrum project ID (set by `hermes photon setup`)"
|
||||
prompt: "Photon Spectrum project ID"
|
||||
description: "Spectrum project id (the project's spectrumProjectId; set by `hermes photon setup`)"
|
||||
prompt: "Photon Spectrum project id"
|
||||
url: "https://app.photon.codes/"
|
||||
password: false
|
||||
- name: PHOTON_PROJECT_SECRET
|
||||
description: "Spectrum project secret (set by `hermes photon setup`)"
|
||||
prompt: "Photon Spectrum project secret"
|
||||
description: "Project secret paired with the Spectrum project id (set by `hermes photon setup`)"
|
||||
prompt: "Photon project secret"
|
||||
url: "https://app.photon.codes/"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: PHOTON_WEBHOOK_SECRET
|
||||
description: "Per-URL HMAC-SHA256 signing secret returned at webhook registration"
|
||||
prompt: "Photon webhook signing secret"
|
||||
password: true
|
||||
- name: PHOTON_WEBHOOK_PORT
|
||||
description: "Local port the webhook receiver listens on (default 8788)"
|
||||
prompt: "Webhook receiver port"
|
||||
password: false
|
||||
- name: PHOTON_WEBHOOK_PATH
|
||||
description: "Path the webhook receiver listens on (default /photon/webhook)"
|
||||
prompt: "Webhook receiver path"
|
||||
password: false
|
||||
- name: PHOTON_WEBHOOK_BIND
|
||||
description: "Bind address for the webhook receiver (default 0.0.0.0)"
|
||||
prompt: "Webhook bind address"
|
||||
password: false
|
||||
- name: PHOTON_SIDECAR_PORT
|
||||
description: "Loopback port for the Node sidecar control channel (default 8789)"
|
||||
description: "Loopback port for the Node sidecar control + inbound channel (default 8789)"
|
||||
prompt: "Sidecar control port"
|
||||
password: false
|
||||
- name: PHOTON_SIDECAR_AUTOSTART
|
||||
|
|
@ -57,12 +42,8 @@ optional_env:
|
|||
description: "Path to the node binary (default: shutil.which('node'))"
|
||||
prompt: "Node executable path"
|
||||
password: false
|
||||
- name: PHOTON_API_HOST
|
||||
description: "Spectrum management API host (default https://spectrum.photon.codes)"
|
||||
prompt: "Spectrum API host"
|
||||
password: false
|
||||
- name: PHOTON_DASHBOARD_HOST
|
||||
description: "Dashboard API host (default https://app.photon.codes)"
|
||||
description: "Photon Dashboard API host (default https://app.photon.codes)"
|
||||
prompt: "Dashboard host"
|
||||
password: false
|
||||
- name: PHOTON_ALLOWED_USERS
|
||||
|
|
@ -82,8 +63,8 @@ optional_env:
|
|||
prompt: "Group mention patterns"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL
|
||||
description: "Default Spectrum space ID for cron / notification delivery"
|
||||
prompt: "Home space ID"
|
||||
description: "Default Spectrum space id for cron / notification delivery"
|
||||
prompt: "Home space id"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL_NAME
|
||||
description: "Human label for the home channel"
|
||||
|
|
|
|||
|
|
@ -1,40 +1,46 @@
|
|||
// Hermes Agent — Photon Spectrum sidecar
|
||||
//
|
||||
// Spawned by `plugins/platforms/photon/adapter.py` to bridge outbound
|
||||
// messaging to Photon's Spectrum platform. Inbound messages go directly
|
||||
// from Photon's webhook to Hermes' Python aiohttp receiver — this
|
||||
// sidecar handles ONLY outbound calls (which require the spectrum-ts
|
||||
// SDK because Photon has no public HTTP send endpoint today).
|
||||
// Spawned by `plugins/platforms/photon/adapter.py` to bridge BOTH directions
|
||||
// of messaging to Photon's Spectrum platform via the `spectrum-ts` SDK (the
|
||||
// SDK is TypeScript-only, so a Node sidecar is unavoidable — there is no
|
||||
// Python SDK and no public HTTP message API).
|
||||
//
|
||||
// Protocol:
|
||||
// - The sidecar listens on http://127.0.0.1:${PORT} (loopback only)
|
||||
// - Each request must include `X-Hermes-Sidecar-Token: ${TOKEN}`
|
||||
// - POST /healthz -> {"ok": true}
|
||||
// - POST /send -> {"ok": true, "messageId": "..."}
|
||||
// Inbound (gRPC -> Hermes): the SDK's `app.messages` async iterator is a
|
||||
// long-lived gRPC stream. We serialize each `[space, message]` to a
|
||||
// normalized JSON event and stream it to the Python adapter over a
|
||||
// loopback `GET /inbound` (NDJSON). We pause pulling from the stream while
|
||||
// no consumer is attached so a backlog isn't pulled-and-lost before the
|
||||
// gateway connects.
|
||||
// Outbound (Hermes -> gRPC): `/send` and `/typing` drive `space.send(...)` /
|
||||
// `space.startTyping()` on the SDK.
|
||||
//
|
||||
// Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`):
|
||||
// - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank
|
||||
// lines are heartbeats. One consumer at a time.
|
||||
// - POST /healthz -> {"ok": true}
|
||||
// - POST /send -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "text": "...", "replyTo": "..." | null}
|
||||
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
|
||||
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
|
||||
// "mimeType": "..." | null, "caption": "..." | null,
|
||||
// "kind": "attachment" | "voice", "replyTo": "..." | null}
|
||||
// - POST /typing -> {"ok": true}
|
||||
// - POST /typing -> {"ok": true}
|
||||
// body: {"spaceId": "..."}
|
||||
// - POST /shutdown -> {"ok": true}; then process exits
|
||||
// - POST /shutdown -> {"ok": true}; then process exits
|
||||
//
|
||||
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
|
||||
// exiting. Errors are logged to stderr; Python supervises restart.
|
||||
// exiting. Logs go to stderr; Python supervises restart.
|
||||
//
|
||||
// Env vars (all required):
|
||||
// PHOTON_PROJECT_ID
|
||||
// Env vars (required):
|
||||
// PHOTON_PROJECT_ID (== the project's spectrumProjectId)
|
||||
// PHOTON_PROJECT_SECRET
|
||||
// PHOTON_SIDECAR_PORT
|
||||
// PHOTON_SIDECAR_TOKEN
|
||||
//
|
||||
// Optional:
|
||||
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
|
||||
// PHOTON_API_HOST (passed through to spectrum-ts if its config
|
||||
// honours it)
|
||||
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
|
||||
|
||||
import http from "node:http";
|
||||
import { once } from "node:events";
|
||||
|
||||
const projectId = process.env.PHOTON_PROJECT_ID;
|
||||
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
|
||||
|
|
@ -71,17 +77,104 @@ const app = await Spectrum({
|
|||
providers: [imessage.config()],
|
||||
});
|
||||
|
||||
// Drain the inbound stream — Photon's webhook is the canonical inbound
|
||||
// path, but we still consume `app.messages` so spectrum-ts' internal
|
||||
// reconnect/heartbeat logic keeps running. Each event is logged at
|
||||
// debug level; everything else is a no-op here.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound: forward `app.messages` (gRPC stream) to the Python consumer.
|
||||
|
||||
// At most one Python consumer is attached at a time (the gateway adapter).
|
||||
let consumerRes = null;
|
||||
let consumerWaiters = [];
|
||||
|
||||
function waitForConsumer() {
|
||||
if (consumerRes) return Promise.resolve();
|
||||
return new Promise((resolve) => consumerWaiters.push(resolve));
|
||||
}
|
||||
|
||||
function setConsumer(res) {
|
||||
consumerRes = res;
|
||||
const waiters = consumerWaiters;
|
||||
consumerWaiters = [];
|
||||
for (const resolve of waiters) resolve();
|
||||
}
|
||||
|
||||
function clearConsumer(res) {
|
||||
if (consumerRes === res) consumerRes = null;
|
||||
}
|
||||
|
||||
// Write one NDJSON line to the active consumer. Blocks until a consumer is
|
||||
// connected; if the write fails (consumer vanished mid-flight) we wait for a
|
||||
// new consumer and retry, so a message is never silently dropped here.
|
||||
async function deliver(line) {
|
||||
for (;;) {
|
||||
await waitForConsumer();
|
||||
const res = consumerRes;
|
||||
if (!res) continue;
|
||||
try {
|
||||
const flushed = res.write(line + "\n");
|
||||
if (!flushed) await once(res, "drain");
|
||||
return;
|
||||
} catch {
|
||||
clearConsumer(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContent(content) {
|
||||
if (!content || typeof content !== "object") {
|
||||
return { type: "unknown" };
|
||||
}
|
||||
if (content.type === "text") {
|
||||
return { type: "text", text: content.text || "" };
|
||||
}
|
||||
if (content.type === "attachment") {
|
||||
// Bytes are reachable via content.read()/stream(); we surface metadata
|
||||
// here and leave byte download to a follow-up (keeps the event small).
|
||||
return {
|
||||
type: "attachment",
|
||||
id: content.id ?? null,
|
||||
name: content.name ?? null,
|
||||
mimeType: content.mimeType ?? null,
|
||||
size: typeof content.size === "number" ? content.size : null,
|
||||
};
|
||||
}
|
||||
return { type: content.type || "unknown" };
|
||||
}
|
||||
|
||||
function normalizeEvent(space, message) {
|
||||
try {
|
||||
const msgSpace = message.space || {};
|
||||
const ts = message.timestamp;
|
||||
return {
|
||||
messageId: message.id ?? null,
|
||||
platform: message.platform || space.__platform || "iMessage",
|
||||
space: {
|
||||
id: space.id ?? msgSpace.id ?? null,
|
||||
// iMessage spaces carry `type` ("dm"|"group") and `phone` directly.
|
||||
type: space.type ?? msgSpace.type ?? "dm",
|
||||
phone: space.phone ?? msgSpace.phone ?? null,
|
||||
},
|
||||
sender: { id: message.sender ? message.sender.id : null },
|
||||
content: normalizeContent(message.content),
|
||||
timestamp:
|
||||
ts instanceof Date ? ts.toISOString() : ts ? String(ts) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: failed to normalize inbound message: " + String(e)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for await (const [, message] of app.messages) {
|
||||
console.error(
|
||||
`photon-sidecar: drained inbound from ${message.platform} ` +
|
||||
`space=${message.space?.id}`
|
||||
);
|
||||
for await (const [space, message] of app.messages) {
|
||||
// Only forward inbound messages (ignore our own outbound echoes).
|
||||
if (message && message.direction && message.direction !== "inbound") {
|
||||
continue;
|
||||
}
|
||||
const event = normalizeEvent(space, message);
|
||||
if (!event) continue;
|
||||
await deliver(JSON.stringify(event));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
|
|
@ -91,6 +184,9 @@ const app = await Spectrum({
|
|||
}
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP control + inbound server (loopback only).
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
|
|
@ -130,6 +226,39 @@ function ok(res, data) {
|
|||
res.end(JSON.stringify({ ok: true, ...data }));
|
||||
}
|
||||
|
||||
function handleInbound(req, res) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/x-ndjson");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
// One consumer at a time — a fresh connection (e.g. after a reconnect)
|
||||
// supersedes the previous one.
|
||||
if (consumerRes && consumerRes !== res) {
|
||||
try {
|
||||
consumerRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setConsumer(res);
|
||||
// Heartbeat keeps the socket warm through idle periods and lets the Python
|
||||
// side detect a dead pipe promptly.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write("\n");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 25000);
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
clearConsumer(res);
|
||||
};
|
||||
req.on("close", cleanup);
|
||||
req.on("aborted", cleanup);
|
||||
res.on("error", cleanup);
|
||||
}
|
||||
|
||||
async function resolveSpace(spaceId) {
|
||||
// spectrum-ts exposes the same Space methods via `app.space(spaceId)` /
|
||||
// narrowed helpers; we fall back through a few accessor shapes to
|
||||
|
|
@ -140,7 +269,6 @@ async function resolveSpace(spaceId) {
|
|||
if (app.spaces && typeof app.spaces.get === "function") {
|
||||
return await app.spaces.get(spaceId);
|
||||
}
|
||||
// Last resort — the platform-narrowed helper.
|
||||
if (imessage) {
|
||||
const im = imessage(app);
|
||||
if (typeof im.space === "function") {
|
||||
|
|
@ -158,6 +286,10 @@ const server = http.createServer(async (req, res) => {
|
|||
if (req.headers["x-hermes-sidecar-token"] !== sharedToken) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
// Long-lived inbound NDJSON stream.
|
||||
if (req.method === "GET" && req.url === "/inbound") {
|
||||
return handleInbound(req, res);
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
return res.end();
|
||||
|
|
@ -225,7 +357,9 @@ const server = http.createServer(async (req, res) => {
|
|||
const { spaceId } = body || {};
|
||||
if (!spaceId) return badRequest(res, "spaceId is required");
|
||||
const space = await resolveSpace(spaceId);
|
||||
if (typeof space.typing === "function") {
|
||||
if (typeof space.startTyping === "function") {
|
||||
await space.startTyping();
|
||||
} else if (typeof space.typing === "function") {
|
||||
await space.typing();
|
||||
} else if (typeof space.setTyping === "function") {
|
||||
await space.setTyping(true);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
|
|
@ -12,6 +12,6 @@
|
|||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "^0.1.0"
|
||||
"spectrum-ts": "^1.17.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue