Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
# Photon iMessage platform plugin
|
||||
|
||||
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 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.
|
||||
|
||||
```
|
||||
gRPC (spectrum-ts)
|
||||
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
|
||||
│ Photon Spectrum cloud │ app.messages │ Node sidecar │
|
||||
│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │
|
||||
└─────────────────────────┘ └──────────┬───────────┘
|
||||
GET /inbound (NDJSON) │ ▲ POST /send
|
||||
inbound events ▼ │ /send-richlink
|
||||
│ │ /typing
|
||||
┌──────────────────────┐
|
||||
│ PhotonAdapter │
|
||||
│ (Python, in gateway) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
- **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` / reaction tapbacks are loopback POSTs
|
||||
to the sidecar (`/send`, `/send-richlink`, `/send-attachment`, `/typing`,
|
||||
`/react`, `/unreact`), authenticated with a shared
|
||||
`X-Hermes-Sidecar-Token`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
# One-shot setup: device login (opens browser) + project + user + sidecar deps
|
||||
hermes photon setup --phone +15551234567
|
||||
|
||||
# Start the gateway
|
||||
hermes gateway start
|
||||
```
|
||||
|
||||
`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. **Provision the project secret** — mint a fresh project secret (the
|
||||
dashboard reveals it only once) and persist it to `~/.hermes/.env` so the
|
||||
sidecar can authenticate `spectrum-ts`. Spectrum is always on, so there's no
|
||||
separate enable step.
|
||||
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** (`npm ci` — installs the committed lockfile
|
||||
verbatim, so every setup runs the exact `spectrum-ts` version this plugin
|
||||
was written against).
|
||||
|
||||
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
|
||||
|
||||
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=<projectId> # the SDK's projectId (same as the dashboard project id)
|
||||
PHOTON_PROJECT_SECRET=<projectSecret>
|
||||
```
|
||||
|
||||
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"credential_pool": {
|
||||
"photon": [
|
||||
{ "access_token": "<device-bearer>", "issued_at": ... }
|
||||
],
|
||||
"photon_project": [
|
||||
{
|
||||
"dashboard_project_id": "<project id>",
|
||||
"spectrum_project_id": "<project id>",
|
||||
"project_secret": "<projectSecret>",
|
||||
"name": "Hermes Agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on ids.** A Photon project's dashboard id and its Spectrum project id
|
||||
> are the same value, exposed as `PHOTON_PROJECT_ID`. The `dashboard_project_id`
|
||||
> and `spectrum_project_id` keys in `auth.json` both hold that id.
|
||||
|
||||
## Configuration knobs
|
||||
|
||||
All env vars are documented in `plugin.yaml`. The most important:
|
||||
|
||||
| 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_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
|
||||
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
|
||||
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
|
||||
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
|
||||
| `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines |
|
||||
| `PHOTON_TELEMETRY` | false | Spectrum SDK telemetry — toggle with `hermes photon telemetry on\|off` (restart the gateway to apply) |
|
||||
| `PHOTON_MARKDOWN` | true | Send agent replies as markdown (iMessage renders natively). `false` strips formatting to plain text |
|
||||
| `PHOTON_REACTIONS` | false | Tapback 👀/👍/👎 as processing status; tapbacks on bot messages reach the agent as `reaction:added:<emoji>` |
|
||||
|
||||
## Attachments & limitations
|
||||
|
||||
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
|
||||
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
|
||||
adapter caches them to the shared media cache and populates `media_urls` /
|
||||
`media_types`, so the agent sees the real image/file or can transcribe the
|
||||
voice note — parity with the BlueBubbles iMessage channel. Mixed iMessage
|
||||
bubbles that contain both text and attachments are normalized as a grouped
|
||||
payload so the user's typed text is preserved alongside the cached media.
|
||||
Media larger than `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or
|
||||
any byte read that fails, falls back to a text marker (`[Photon attachment
|
||||
received: …]` or `[Photon voice received: …]`) so the agent still knows
|
||||
something arrived. If Spectrum emits a `richlink` content object, Hermes
|
||||
preserves its URL plus any title/summary metadata Spectrum already exposed;
|
||||
current Spectrum versions may still deliver ordinary inbound links as plain
|
||||
`text`. iMessage may also emit rich-link preview artwork as
|
||||
`.pluginPayloadAttachment` images immediately after the URL; Hermes coalesces
|
||||
those artifacts so the agent receives one link message instead of a follow-up
|
||||
`(attachment)` prompt.
|
||||
- **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.
|
||||
- **Markdown is rendered.** Replies go out via spectrum-ts' `markdown()`
|
||||
builder; iMessage renders bold/italics/lists/code natively and other
|
||||
Spectrum platforms degrade to readable plain text. URL-only replies go out
|
||||
via spectrum-ts' `richlink()` builder so iMessage can render a native link
|
||||
preview card. `PHOTON_MARKDOWN=false` reverts to stripped plain text and
|
||||
disables rich-link routing.
|
||||
- **Reactions (tapbacks) are supported** behind `PHOTON_REACTIONS` (default
|
||||
off): the adapter tapbacks 👀 while processing and swaps it for 👍/👎 on
|
||||
completion, and a user tapback on a bot-sent message is routed to the agent
|
||||
as a synthetic `reaction:added:<emoji>` event. Removal after a sidecar
|
||||
restart is best-effort — the live reaction handle is lost, so a stale
|
||||
tapback heals when the next reaction replaces it. Group spaces stay
|
||||
reachable across restarts via spectrum-ts' `space.get` rehydration.
|
||||
- **Read receipts are supported.** The sidecar marks an inbound iMessage read
|
||||
after forwarding it to Hermes, so the sender sees `Read` without waiting for
|
||||
a model/tool turn. Inbound receipts for Hermes-sent messages are consumed as
|
||||
presence telemetry and never create an agent turn. Set
|
||||
`PHOTON_READ_RECEIPTS=false` to keep messages at `Delivered`.
|
||||
- **Native polls are supported.** Hermes posts poll content through
|
||||
`spectrum-ts`' `poll(...)` builder via the sidecar's `/send-poll` endpoint.
|
||||
- **Message effects are supported.** Text can be sent with native iMessage
|
||||
bubble/screen effects through `spectrum-ts`' iMessage `effect(...)` builder
|
||||
via the sidecar's `/send-effect` endpoint.
|
||||
- **Cron/standalone sends require a running gateway.** Processes outside
|
||||
the gateway (cron subprocesses, `hermes send`) cannot spawn the sidecar;
|
||||
they authenticate to the gateway's live sidecar via the runtime record at
|
||||
`<hermes-home>/runtime/photon-sidecar.json` (written after the sidecar's
|
||||
`/healthz` readiness check, `0600`, removed on stop/failed start). Also
|
||||
note that shared/free-tier Photon lines cannot INITIATE conversations
|
||||
with numbers that never texted the line — that's Photon-side policy, not
|
||||
a Hermes limitation.
|
||||
|
||||
## Upgrading spectrum-ts
|
||||
|
||||
`spectrum-ts` is pinned to an **exact version** in `sidecar/package.json`
|
||||
(no `^` range) and installed with `npm ci`, because the SDK ships breaking
|
||||
majors (v2 removed `defineFusorPlatform`; v3 reworked space construction; v5
|
||||
split it into `@spectrum-ts/*` packages, with `spectrum-ts` as the umbrella
|
||||
that re-exports them; v8 made `richlink` primarily outbound, so many inbound
|
||||
links now arrive as plain `text`). A floating range or `npm install spectrum-ts@latest`
|
||||
would let a breaking release take down fresh setups silently. Upgrades are
|
||||
deliberate:
|
||||
|
||||
1. Read the [SDK release notes](https://github.com/photon-hq/spectrum-ts/releases)
|
||||
for every version between the current pin and the target.
|
||||
2. Bump the exact pin in `sidecar/package.json`, then run `npm install`
|
||||
inside `sidecar/` to regenerate `package-lock.json`. Commit both.
|
||||
3. Migrate `sidecar/index.mjs` against the new typings. `spectrum-ts` re-exports
|
||||
`@spectrum-ts/core` (the framework: `Spectrum`, content builders,
|
||||
`Space`/`Message`) and `@spectrum-ts/imessage` (the provider), so the source
|
||||
of truth is `sidecar/node_modules/@spectrum-ts/{core,imessage}/dist/*.d.ts`
|
||||
(the hosted docs can lag).
|
||||
4. Re-validate `sidecar/patch-spectrum-mixed-attachments.mjs`. It rewrites the
|
||||
compiled iMessage inbound mappers in `@spectrum-ts/imessage/dist/index.js`
|
||||
so a bubble with both text and attachments keeps its typed text; the anchors
|
||||
are tied to that build's output. `npm install` runs it via `postinstall` and
|
||||
fails loudly if the anchors no longer match — update them to the new output
|
||||
(`test_spectrum_patch.py` covers the patch).
|
||||
5. Run `pytest tests/plugins/platforms/photon/`.
|
||||
6. Verify end-to-end: `hermes photon status`, a DM and a group roundtrip,
|
||||
and an agent reply into a group right after a gateway restart (exercises
|
||||
`space.get` rehydration).
|
||||
|
||||
[photon]: https://photon.codes/
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Photon Spectrum (iMessage) platform plugin entry point."""
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
``hermes photon ...`` CLI subcommands — registered by the plugin via
|
||||
``ctx.register_cli_command()``.
|
||||
|
||||
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/
|
||||
telemetry show or toggle Spectrum SDK telemetry (on/off)
|
||||
|
||||
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 os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
from . import auth as photon_auth
|
||||
from .adapter import _NPM_ERROR_LOG_MAX_CHARS, sidecar_deps_installed
|
||||
from .sidecar_paths import resolve_sidecar_dir
|
||||
|
||||
# Writable sidecar runtime dir (mirrors to HERMES_HOME on immutable
|
||||
# installs — NS-606). All npm/setup work happens here. Resolved lazily on
|
||||
# first use — resolve_sidecar_dir() probes the filesystem and may mirror
|
||||
# files, side effects that must not fire at import time (e.g. when argparse
|
||||
# wiring imports this module for `hermes --help`).
|
||||
# Tests monkeypatch these module globals directly; the accessors honor a
|
||||
# non-None value and only resolve/derive when unset.
|
||||
_SIDECAR_DIR: Path | None = None
|
||||
# Written on npm failure so check_requirements() can surface the root cause
|
||||
# when called later (gateway start, hermes status). Cleared on success.
|
||||
_NPM_ERROR_LOG: Path | None = None
|
||||
|
||||
|
||||
def _sidecar_dir() -> Path:
|
||||
"""Sidecar runtime dir, resolved once on first use (never at import)."""
|
||||
global _SIDECAR_DIR
|
||||
if _SIDECAR_DIR is None:
|
||||
_SIDECAR_DIR = resolve_sidecar_dir()
|
||||
return _SIDECAR_DIR
|
||||
|
||||
|
||||
def _npm_error_log() -> Path:
|
||||
"""Path of the persisted npm-failure log (derived from the sidecar dir)."""
|
||||
if _NPM_ERROR_LOG is not None:
|
||||
return _NPM_ERROR_LOG
|
||||
return _sidecar_dir() / ".photon-npm-error.log"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring
|
||||
|
||||
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.add_argument("--first-name", default=None)
|
||||
p_setup.add_argument("--last-name", default=None)
|
||||
p_setup.add_argument("--email", default=None)
|
||||
p_setup.add_argument("--no-browser", action="store_true",
|
||||
help="Don't try to open a browser for device login; print the URL only")
|
||||
p_setup.add_argument("--skip-sidecar-install", action="store_true",
|
||||
help="Skip `npm install` inside the sidecar directory")
|
||||
|
||||
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_telemetry = subs.add_parser(
|
||||
"telemetry",
|
||||
help="Show or toggle Spectrum SDK telemetry (on/off)",
|
||||
)
|
||||
p_telemetry.add_argument(
|
||||
"state", nargs="?", choices=("on", "off"),
|
||||
help="Turn telemetry on or off (omit to show the current state)",
|
||||
)
|
||||
|
||||
parser.set_defaults(func=dispatch)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "photon_command", None)
|
||||
if sub is None:
|
||||
# No subcommand given — show status by default.
|
||||
return _cmd_status(args)
|
||||
if sub == "setup":
|
||||
return _cmd_setup(args)
|
||||
if sub == "status":
|
||||
return _cmd_status(args)
|
||||
if sub == "install-sidecar":
|
||||
return _cmd_install_sidecar(args)
|
||||
if sub == "telemetry":
|
||||
return _cmd_telemetry(args)
|
||||
print(f"unknown subcommand: {sub}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand handlers
|
||||
|
||||
def _run_device_login(args: argparse.Namespace) -> int:
|
||||
"""Run the RFC 8628 device-code login flow and persist the token.
|
||||
|
||||
Internal helper — invoked as the first step of ``setup``. There is
|
||||
no standalone ``hermes photon login`` command; Photon onboards
|
||||
through the single ``setup`` surface like every other channel.
|
||||
"""
|
||||
def _print_code(code):
|
||||
target = code.verification_uri_complete or code.verification_uri
|
||||
print()
|
||||
print("┌─ Photon device login ────────────────────────────────────────")
|
||||
print(f"│ Open this URL: {target}")
|
||||
print(f"│ Enter the code: {code.user_code}")
|
||||
print("│ (waiting for approval — Ctrl-C to cancel)")
|
||||
print("└──────────────────────────────────────────────────────────────")
|
||||
print()
|
||||
|
||||
try:
|
||||
token = photon_auth.login_device_flow(
|
||||
open_browser=not args.no_browser,
|
||||
on_user_code=_print_code,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"login failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
# Don't print any portion of the token — even a prefix can help a
|
||||
# shoulder-surfer or accidentally leak into a screen recording.
|
||||
_ = token
|
||||
print(f"✓ logged in — token saved to {photon_auth._auth_json_path()}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
# 1. Login (skip if we already have a valid token).
|
||||
token = photon_auth.load_photon_token()
|
||||
if token:
|
||||
# Validate the existing token — the dashboard token has a short TTL
|
||||
# and can go stale between runs (observed: ~3-4 days). Reusing a
|
||||
# stale token causes every management call to fail with 401 and
|
||||
# leaves the operator confused about why setup "succeeds" but nothing
|
||||
# works. Check upfront so we fail fast and fall back to fresh login.
|
||||
print("[1/5] Checking existing Photon token...")
|
||||
if photon_auth.check_photon_token_valid(token):
|
||||
print(" ✓ token is valid")
|
||||
else:
|
||||
print(" ✗ token is stale (dashboard rejected it) — re-authenticating")
|
||||
photon_auth.clear_photon_token()
|
||||
token = None
|
||||
if not token:
|
||||
print("[1/5] No valid Photon token found — running device login...")
|
||||
rc = _run_device_login(args)
|
||||
if rc != 0:
|
||||
return rc
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("login completed but token was not stored", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
print("[1/5] Reusing existing Photon token")
|
||||
|
||||
# 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. Provision Spectrum credentials (runtime -> ~/.hermes/.env,
|
||||
# ids -> auth.json). Spectrum is always enabled and provisioned at
|
||||
# create-time, and the dashboard project id *is* the Spectrum project id
|
||||
# (ids unified), so there's nothing to enable — the id we already have is
|
||||
# the Spectrum id.
|
||||
#
|
||||
# On re-run we reuse an existing valid secret instead of regenerating.
|
||||
# Regenerating invalidates the credential that a running sidecar holds
|
||||
# in its process env, causing all outbound sends to fail with
|
||||
# AuthenticationError until the gateway is restarted (GH #50755).
|
||||
try:
|
||||
print("[3/5] Provisioning Spectrum credentials...")
|
||||
spectrum_id = dashboard_id
|
||||
existing_id, existing_secret = photon_auth.load_project_credentials()
|
||||
secret: str = ""
|
||||
reused = False
|
||||
if existing_id and existing_secret:
|
||||
# Validate the existing credential with a lightweight API call.
|
||||
try:
|
||||
photon_auth.list_users(existing_id, existing_secret)
|
||||
secret = existing_secret
|
||||
reused = True
|
||||
except Exception:
|
||||
secret = "" # fall through to regeneration
|
||||
if not secret:
|
||||
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.
|
||||
if reused:
|
||||
print(f" ✓ Spectrum ready (project id {spectrum_id}) — existing credentials valid")
|
||||
else:
|
||||
print(f" ✓ Spectrum ready (project id {spectrum_id}) — new secret saved")
|
||||
print(
|
||||
" ⚠ Project secret was regenerated. If the gateway is running, "
|
||||
"restart it so the sidecar picks up the new secret:\n"
|
||||
" hermes gateway restart"
|
||||
)
|
||||
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(
|
||||
color(
|
||||
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
|
||||
Colors.CYAN,
|
||||
)
|
||||
)
|
||||
agent_number = None
|
||||
registered_phone = None
|
||||
registered_user_id = None
|
||||
if not phone:
|
||||
print(" Skipped user registration (no phone given). Re-run with --phone later.")
|
||||
else:
|
||||
# Name/email are optional and never prompted for — pass --first-name /
|
||||
# --email if you want them sent to the dashboard.
|
||||
first_name = args.first_name
|
||||
email = args.email
|
||||
try:
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
spectrum_id, secret,
|
||||
phone_number=phone,
|
||||
first_name=first_name,
|
||||
last_name=args.last_name,
|
||||
email=email,
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f" invalid phone number: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f" user registration failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(" ✓ phone registered" if created else " ✓ phone already registered")
|
||||
registered_phone = phone
|
||||
registered_user_id = user.get("id")
|
||||
# The number to text the agent is the user's assigned iMessage line
|
||||
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
|
||||
# no dedicated entry in /lines, so this per-user field is the source of
|
||||
# truth — and we already have it from the (reused) user object.
|
||||
agent_number = photon_auth.user_assigned_line(user)
|
||||
# Allowlist the operator and make their DM the cron home channel —
|
||||
# otherwise the gateway denies their own inbound messages
|
||||
# ("Unauthorized user") and has no default space for cron delivery.
|
||||
_autoconfigure_access(phone)
|
||||
|
||||
# 5. Surface the agent's iMessage number (the number to text the agent).
|
||||
if not agent_number:
|
||||
# No per-user assignment — fall back to a dedicated line if the project
|
||||
# has one provisioned in its line inventory.
|
||||
try:
|
||||
line = photon_auth.get_imessage_line(token, dashboard_id)
|
||||
if line:
|
||||
agent_number = line.get("phoneNumber")
|
||||
except Exception as e:
|
||||
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
|
||||
if agent_number:
|
||||
print()
|
||||
print(color("┌─ Your agent's iMessage number ───────────────────────────────", Colors.GREEN))
|
||||
print(
|
||||
color("│ 📱 ", Colors.GREEN)
|
||||
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
|
||||
)
|
||||
print(color("│ Text this number from your phone to talk to your agent.", Colors.GREEN))
|
||||
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
|
||||
else:
|
||||
print(" No iMessage line assigned yet — check the Photon dashboard.")
|
||||
if registered_phone:
|
||||
try:
|
||||
photon_auth.store_user_numbers(
|
||||
phone_number=registered_phone,
|
||||
assigned_phone_number=agent_number,
|
||||
user_id=str(registered_user_id) if registered_user_id else None,
|
||||
dashboard_project_id=dashboard_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
|
||||
|
||||
# 6. Sidecar deps (spectrum-ts).
|
||||
if args.skip_sidecar_install:
|
||||
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
|
||||
|
||||
# 7. Ensure the photon platform is enabled in config.yaml so the
|
||||
# gateway loads it on next start. Without this the channel stays
|
||||
# disabled even after a successful provisioning run, silently
|
||||
# keeping iMessage offline.
|
||||
try:
|
||||
from hermes_cli.config import write_platform_config_field
|
||||
write_platform_config_field("photon", "enabled", True, raw=True)
|
||||
print(" ✓ photon platform enabled in config.yaml")
|
||||
except Exception as e:
|
||||
print(f" (could not enable Photon in config: {e})", file=sys.stderr)
|
||||
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Start the gateway: hermes gateway start")
|
||||
return 0
|
||||
|
||||
|
||||
def _autoconfigure_access(phone: str) -> None:
|
||||
"""Allowlist the operator and set their DM as the cron home channel.
|
||||
|
||||
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
|
||||
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
|
||||
(the default space for cron delivery) to the operator's E.164 number. Each
|
||||
is only filled when unset, so a hand-tuned allowlist / home channel is
|
||||
never clobbered on a re-run.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
except ImportError:
|
||||
return
|
||||
for key, label in (
|
||||
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
|
||||
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
|
||||
):
|
||||
try:
|
||||
if get_env_value(key):
|
||||
print(f" {key} already set — leaving it as-is.")
|
||||
continue
|
||||
save_env_value(key, phone)
|
||||
print(f" ✓ {label} ({key})")
|
||||
except Exception as e:
|
||||
print(f" could not set {key}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_status(_args: argparse.Namespace) -> int:
|
||||
_refresh_status_numbers()
|
||||
# Defer the credential rows to auth.print_credential_summary — its emit
|
||||
# callback is the only sink that sees credential-derived strings, so
|
||||
# cli.py keeps zero taint flow according to CodeQL.
|
||||
photon_auth.print_credential_summary(print)
|
||||
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
|
||||
sidecar_installed = sidecar_deps_installed()
|
||||
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
|
||||
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
|
||||
print(f" telemetry : {'on' if _telemetry_enabled() else 'off'} (`hermes photon telemetry on|off`)")
|
||||
return 0
|
||||
|
||||
|
||||
def _refresh_status_numbers() -> None:
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
if phone and assigned:
|
||||
return
|
||||
spectrum_id, project_secret = photon_auth.load_project_credentials()
|
||||
if not spectrum_id or not project_secret:
|
||||
return
|
||||
try:
|
||||
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
|
||||
except Exception as e:
|
||||
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
|
||||
return _install_sidecar()
|
||||
|
||||
|
||||
def _telemetry_enabled() -> bool:
|
||||
"""Read PHOTON_TELEMETRY from the env / ~/.hermes/.env.
|
||||
|
||||
Mirrors the sidecar's truthy set (index.mjs) so the state shown here
|
||||
always matches what the sidecar will actually do.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import get_env_value
|
||||
raw = get_env_value("PHOTON_TELEMETRY")
|
||||
except ImportError:
|
||||
raw = os.getenv("PHOTON_TELEMETRY")
|
||||
return (raw or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _cmd_telemetry(args: argparse.Namespace) -> int:
|
||||
state = getattr(args, "state", None)
|
||||
if state is None:
|
||||
print(f"Photon telemetry: {'on' if _telemetry_enabled() else 'off'}")
|
||||
print(" Toggle with `hermes photon telemetry on` / `hermes photon telemetry off`.")
|
||||
return 0
|
||||
try:
|
||||
from hermes_cli.config import save_env_value
|
||||
save_env_value("PHOTON_TELEMETRY", "true" if state == "on" else "false")
|
||||
except Exception as e:
|
||||
print(f"could not save PHOTON_TELEMETRY: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"✓ Spectrum telemetry turned {state} (PHOTON_TELEMETRY in ~/.hermes/.env)")
|
||||
print(" Restart the gateway for the sidecar to pick it up: hermes gateway restart")
|
||||
return 0
|
||||
|
||||
|
||||
def _install_sidecar() -> int:
|
||||
npm = shutil.which("npm") or "npm"
|
||||
if not shutil.which(npm):
|
||||
print(
|
||||
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
|
||||
"and re-run.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
# spectrum-ts is pinned exactly in package.json/package-lock.json because
|
||||
# the SDK ships breaking majors (v2 removed defineFusorPlatform; v3
|
||||
# reworked space construction; v5 split it into @spectrum-ts/* packages).
|
||||
# Upgrades are deliberate: bump the pin, migrate sidecar/index.mjs, re-run
|
||||
# the photon tests — never `@latest` (see README "Upgrading spectrum-ts").
|
||||
# `npm ci` installs the committed lockfile verbatim; fall back to
|
||||
# `npm install` when the lockfile is missing or drifted (e.g. a dev
|
||||
# checkout mid-upgrade).
|
||||
print(f" $ cd {_sidecar_dir()} && {npm} ci")
|
||||
# stdout is not captured so npm progress prints to the terminal in real
|
||||
# time. stderr is captured so we can persist the failure reason for
|
||||
# check_requirements() to surface after the process exits.
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "ci"],
|
||||
cwd=str(_sidecar_dir()),
|
||||
check=False,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if proc.stderr:
|
||||
print(proc.stderr, end="", file=sys.stderr)
|
||||
if proc.returncode != 0:
|
||||
print(f" npm ci failed — falling back to: {npm} install")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "install"],
|
||||
cwd=str(_sidecar_dir()),
|
||||
check=False,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if proc.stderr:
|
||||
print(proc.stderr, end="", file=sys.stderr)
|
||||
if proc.returncode != 0:
|
||||
print("npm install failed", file=sys.stderr)
|
||||
# Bound to the same length check_requirements() truncates to on
|
||||
# read, so the log file never holds more than what's ever surfaced.
|
||||
error = (proc.stderr or "").strip()[:_NPM_ERROR_LOG_MAX_CHARS]
|
||||
if error:
|
||||
try:
|
||||
_npm_error_log().write_text(error, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
_npm_error_log().unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return proc.returncode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway-setup entry point
|
||||
#
|
||||
# `hermes gateway setup` discovers platforms via the registry and calls each
|
||||
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
|
||||
# in the unified setup wizard alongside every other channel — same onboarding
|
||||
# surface, no Photon-specific detour. It runs the identical device-login +
|
||||
# project + user + sidecar flow as ``hermes photon setup`` with interactive
|
||||
# defaults (phone is prompted when stdin is a TTY).
|
||||
|
||||
def gateway_setup() -> None:
|
||||
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
|
||||
args = argparse.Namespace(
|
||||
photon_command="setup",
|
||||
project_name=None,
|
||||
phone=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=False,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
_cmd_setup(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small interactive helpers
|
||||
|
||||
def _prompt(prompt: str, *, secret: bool = False) -> str:
|
||||
if not sys.stdin.isatty():
|
||||
return ""
|
||||
try:
|
||||
if secret:
|
||||
return getpass.getpass(prompt).strip()
|
||||
return input(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return ""
|
||||
@@ -0,0 +1,92 @@
|
||||
name: photon-platform
|
||||
label: iMessage via Photon
|
||||
kind: platform
|
||||
version: 0.3.0
|
||||
description: >
|
||||
Photon Spectrum gateway adapter for Hermes Agent.
|
||||
Connects to iMessage (and other Spectrum interfaces) through Photon's
|
||||
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 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 (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: "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_SIDECAR_PORT
|
||||
description: "Loopback port for the Node sidecar control + inbound channel (default 8789)"
|
||||
prompt: "Sidecar control port"
|
||||
password: false
|
||||
- name: PHOTON_SIDECAR_AUTOSTART
|
||||
description: "Spawn the Node sidecar on connect (true/false, default true)"
|
||||
prompt: "Auto-start the sidecar?"
|
||||
password: false
|
||||
- name: PHOTON_NODE_BIN
|
||||
description: "Path to the node binary (default: shutil.which('node'))"
|
||||
prompt: "Node executable path"
|
||||
password: false
|
||||
- name: PHOTON_DASHBOARD_HOST
|
||||
description: "Photon Dashboard API host (default https://app.photon.codes)"
|
||||
prompt: "Dashboard host"
|
||||
password: false
|
||||
- name: PHOTON_SPECTRUM_HOST
|
||||
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
|
||||
prompt: "Spectrum API host"
|
||||
password: false
|
||||
- name: PHOTON_ALLOWED_USERS
|
||||
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: PHOTON_ALLOW_ALL_USERS
|
||||
description: "Allow any sender to trigger the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_READ_RECEIPTS
|
||||
description: "Mark inbound iMessages read after forwarding to Hermes (true/false, default true)"
|
||||
prompt: "Send read receipts? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_REQUIRE_MENTION
|
||||
description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)"
|
||||
prompt: "Require a mention in group chats?"
|
||||
password: false
|
||||
- name: PHOTON_MENTION_PATTERNS
|
||||
description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)"
|
||||
prompt: "Group mention patterns"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL
|
||||
description: "Default Photon target for cron / notification delivery: Spectrum space id, DM GUID, or bare E.164 phone number"
|
||||
prompt: "Home Photon target"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL_NAME
|
||||
description: "Human label for the home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
- name: PHOTON_TELEMETRY
|
||||
description: "Enable Spectrum SDK telemetry in the sidecar (true/false, default false; toggle with `hermes photon telemetry on|off`)"
|
||||
prompt: "Enable Spectrum telemetry? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_MARKDOWN
|
||||
description: "Send agent replies as markdown — iMessage renders it natively, other Spectrum platforms degrade to plain text (true/false, default true)"
|
||||
prompt: "Render replies as markdown? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_REACTIONS
|
||||
description: "Tapback 👀/👍/👎 on messages as processing status and route tapbacks on bot messages to the agent (true/false, default false)"
|
||||
prompt: "Enable reaction tapbacks? (true/false)"
|
||||
password: false
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.photon-npm-error.log
|
||||
@@ -0,0 +1,50 @@
|
||||
# Photon sidecar
|
||||
|
||||
Small Node helper that bridges Hermes Agent to Photon's Spectrum SDK
|
||||
(`spectrum-ts`). Hermes is Python; Photon has no public HTTP
|
||||
send-message endpoint today; replies therefore go through this sidecar.
|
||||
|
||||
The sidecar:
|
||||
|
||||
- runs `Spectrum({ projectId, projectSecret, providers: [imessage.config()] })`
|
||||
- exposes a loopback-only HTTP control channel for the Python adapter
|
||||
to push send/typing requests (auth via `X-Hermes-Sidecar-Token`)
|
||||
- drains the inbound message stream so `spectrum-ts` keeps its
|
||||
reconnect/heartbeat machinery alive and Hermes can receive inbound messages
|
||||
over the adapter's loopback `GET /inbound` stream
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd plugins/platforms/photon/sidecar
|
||||
npm install
|
||||
```
|
||||
|
||||
The Hermes plugin's `hermes photon setup` command runs `npm install`
|
||||
here automatically.
|
||||
|
||||
## Run standalone
|
||||
|
||||
For debugging:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=... PHOTON_PROJECT_SECRET=... \
|
||||
PHOTON_SIDECAR_PORT=8789 PHOTON_SIDECAR_TOKEN=$(openssl rand -hex 16) \
|
||||
node index.mjs
|
||||
```
|
||||
|
||||
In normal use, the Python adapter supervises this process — start,
|
||||
restart on crash, kill on shutdown — and never asks the user to run
|
||||
it by hand.
|
||||
|
||||
## Why a sidecar at all?
|
||||
|
||||
Photon's Spectrum send path is exposed through the TypeScript SDK's
|
||||
`Space.send(...)` API. Hermes is Python, so replies go through this sidecar
|
||||
until Photon ships a public HTTP send endpoint.
|
||||
|
||||
When Photon ships an HTTP send endpoint, the plan is to retire this
|
||||
sidecar entirely and call it directly from Python. The plugin's
|
||||
outbound code path is already isolated behind small helpers
|
||||
(`_sidecar_send`, `_sidecar_send_richlink`, and `_sidecar_send_attachment` in
|
||||
`adapter.py`) to make that swap localized.
|
||||
File diff suppressed because it is too large
Load Diff
+1459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"scripts": {
|
||||
"start": "node index.mjs",
|
||||
"postinstall": "node patch-spectrum-mixed-attachments.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "12.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "8.7.1",
|
||||
"@opentelemetry/otlp-transformer": "0.218.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.218.0",
|
||||
"@opentelemetry/core": "2.10.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
// Patch spectrum-ts' iMessage inbound mapper until upstream preserves mixed
|
||||
// text + attachment Apple events. The mapper returns only
|
||||
// buildAttachmentMessage(...) whenever attachments are present, which drops
|
||||
// `message.content.text` before Hermes can see it. We rewrite the two inbound
|
||||
// mappers — `rebuildFromAppleMessage` (used by `space.getMessage`) and
|
||||
// `toInboundMessages` (used by the live stream) — so a bubble carrying both
|
||||
// text and attachment(s) surfaces as a group whose first child is the typed
|
||||
// text. Paths with no text are rewritten to byte-identical behavior, so only
|
||||
// mixed text+attachment messages change shape.
|
||||
//
|
||||
// Since spectrum-ts 5.x split the SDK into scoped packages, the iMessage mapper
|
||||
// lives in `@spectrum-ts/imessage/dist/index.js` (it used to be a chunk under
|
||||
// `spectrum-ts/dist`). The published output is tab-indented and uses
|
||||
// `const ... = async` declarations; the anchors below match that exactly and
|
||||
// fail loudly if a future spectrum-ts reshapes the mapper.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const MARKER = "Hermes patch: Preserve mixed text + attachment iMessage payloads";
|
||||
|
||||
function scriptDir() {
|
||||
return path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function replaceOnce(source, from, to, label) {
|
||||
const count = source.split(from).length - 1;
|
||||
if (count !== 1) {
|
||||
throw new Error(`expected exactly one ${label} match, found ${count}`);
|
||||
}
|
||||
return source.replace(from, to);
|
||||
}
|
||||
|
||||
function replaceExactly(source, from, to, expected, label) {
|
||||
const count = source.split(from).length - 1;
|
||||
if (count !== expected) {
|
||||
throw new Error(
|
||||
`expected exactly ${expected} ${label} matches, found ${count}`
|
||||
);
|
||||
}
|
||||
return source.split(from).join(to);
|
||||
}
|
||||
|
||||
// The text-first child of a mixed text+attachment group, indented `tabs` deep
|
||||
// (the object's closing brace sits at `tabs`; its properties one level in).
|
||||
function textChild(tabs) {
|
||||
const t = "\t".repeat(tabs);
|
||||
return (
|
||||
`{\n${t}\t...base,\n${t}\tid: formatChildId(0, messageGuidStr),` +
|
||||
`\n${t}\tcontent: asText(text2),\n${t}\tpartIndex: 0,` +
|
||||
`\n${t}\tparentId: messageGuidStr\n${t}}`
|
||||
);
|
||||
}
|
||||
|
||||
function patchRebuild(source) {
|
||||
// Capture the bubble text before the attachment branches consume it. The
|
||||
// existing no-attachment branch keeps its own `const text` declaration, so a
|
||||
// distinct name avoids a redeclaration.
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\tconst attachments = messageAttachments(message);\n\tif (attachments.length === 1) {`,
|
||||
`\tconst attachments = messageAttachments(message);\n\tconst text2 = message.content.text;\n\tif (attachments.length === 1) {`,
|
||||
"rebuild text capture"
|
||||
);
|
||||
// Single attachment: when text is present, push it to slot 0 and the
|
||||
// attachment to slot 1, then wrap both in a group.
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\treturn buildAttachmentMessage(client, base, info, messageGuidStr, 0);`,
|
||||
`\t\tconst msg2 = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg2])\n\t\t\t};\n\t\t}\n\t\treturn msg2;`,
|
||||
"rebuild single attachment"
|
||||
);
|
||||
// Multi attachment: prepend the text child to the group's items.
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
`\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
"rebuild multi attachment text child"
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
function patchInbound(source) {
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\tconst attachments = messageAttachments(event.message);\n\tif (attachments.length === 1) {`,
|
||||
`\tconst attachments = messageAttachments(event.message);\n\tconst text2 = event.message.content.text;\n\tif (attachments.length === 1) {`,
|
||||
"inbound text capture"
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\tconst msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`,
|
||||
`\t\tconst msg = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\tconst parent = {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg])\n\t\t\t};\n\t\t\tcacheMessage(cache, parent);\n\t\t\treturn [parent];\n\t\t}\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`,
|
||||
"inbound single attachment"
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
`\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
"inbound multi attachment text child"
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
// Shift attachment part indices by one when a text child occupies slot 0. The
|
||||
// push line is byte-identical in both mappers, so patch both occurrences.
|
||||
function patchChildIndices(source) {
|
||||
return replaceExactly(
|
||||
source,
|
||||
`items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));`,
|
||||
`items.push(await buildAttachmentMessage(client, base, info, formatChildId(text2 ? i + 1 : i, messageGuidStr), text2 ? i + 1 : i, messageGuidStr));`,
|
||||
2,
|
||||
"multi attachment child index"
|
||||
);
|
||||
}
|
||||
|
||||
export function patchSpectrumTs(root = scriptDir()) {
|
||||
const dist = path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
"@spectrum-ts",
|
||||
"imessage",
|
||||
"dist"
|
||||
);
|
||||
if (!fs.existsSync(dist)) {
|
||||
throw new Error(`@spectrum-ts/imessage dist not found: ${dist}`);
|
||||
}
|
||||
const files = fs.readdirSync(dist)
|
||||
.filter((name) => name.endsWith(".js"))
|
||||
.map((name) => path.join(dist, name));
|
||||
|
||||
for (const file of files) {
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
if (raw.includes(MARKER)) {
|
||||
return { patched: false, file, reason: "already patched" };
|
||||
}
|
||||
// Normalize to LF for matching so the patch works regardless of the
|
||||
// checkout's line-ending style (Windows git autocrlf produces CRLF,
|
||||
// which would otherwise defeat the \n-based search strings). The
|
||||
// original EOL style is restored on write. Indentation in the published
|
||||
// tarball is tabs; the anchors match that directly.
|
||||
const CR = String.fromCharCode(13);
|
||||
const CRLF = CR + "\n";
|
||||
const usedCRLF = raw.includes(CRLF);
|
||||
const original = usedCRLF ? raw.split(CRLF).join("\n") : raw;
|
||||
if (!original.includes("const toInboundMessages = async") ||
|
||||
!original.includes("const rebuildFromAppleMessage = async")) {
|
||||
continue;
|
||||
}
|
||||
// spectrum-ts 12.x replaced the attachment-only branches with
|
||||
// `buildUnwrappedContentMessage` + `toOrderedParts`, which already emits a
|
||||
// group containing both text and attachments. There is nothing left for
|
||||
// Hermes to patch; keep the legacy v8 path below for older pinned installs.
|
||||
if (
|
||||
original.includes("const buildUnwrappedContentMessage = async") &&
|
||||
original.includes("const parts = toOrderedParts(message.content.text, attachments);")
|
||||
) {
|
||||
return { patched: false, file, reason: "upstream preserves mixed payloads" };
|
||||
}
|
||||
let patched = original;
|
||||
patched = patchRebuild(patched);
|
||||
patched = patchInbound(patched);
|
||||
patched = patchChildIndices(patched);
|
||||
patched = `// ${MARKER}\n${patched}`;
|
||||
if (usedCRLF) {
|
||||
patched = patched.split("\n").join(CRLF);
|
||||
}
|
||||
fs.writeFileSync(file, patched, "utf8");
|
||||
return { patched: true, file };
|
||||
}
|
||||
throw new Error("could not find @spectrum-ts/imessage iMessage inbound chunk to patch");
|
||||
}
|
||||
|
||||
const _invokedDirectly =
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (_invokedDirectly) {
|
||||
try {
|
||||
const root = process.argv[2] ? path.resolve(process.argv[2]) : scriptDir();
|
||||
const result = patchSpectrumTs(root);
|
||||
const action = result.patched ? "patched" : "ok";
|
||||
console.error(`photon-sidecar: spectrum mixed attachment patch ${action}: ${result.file}`);
|
||||
} catch (err) {
|
||||
console.error(`photon-sidecar: spectrum mixed attachment patch failed: ${err?.stack || err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Outbound /send builder selection for the Photon sidecar.
|
||||
//
|
||||
// spectrumMarkdown() enables data detection (enableDataDetection) in the
|
||||
// underlying iMessage API, which can 500 on messages containing raw URLs.
|
||||
// Plain-text URLs are auto-linked by iMessage anyway, so markdown messages
|
||||
// that contain a URL are routed through the text builder, while URL-free
|
||||
// markdown keeps native markdown rendering.
|
||||
//
|
||||
// This lives in its own module (rather than inline in index.mjs) so tests can
|
||||
// execute the real decision logic under node instead of grepping source —
|
||||
// see tests/plugins/platforms/photon/test_url_send_path.py.
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s)'"<>]+/i;
|
||||
|
||||
/**
|
||||
* Decide which spectrum-ts builder the /send handler should use.
|
||||
*
|
||||
* @param {string} format "markdown" | "text" (already validated by /send)
|
||||
* @param {string} text the outbound message body
|
||||
* @returns {"markdown"|"text"}
|
||||
*/
|
||||
export function chooseSendFormat(format, text) {
|
||||
if (format === "markdown" && !URL_RE.test(String(text))) {
|
||||
return "markdown";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Pure decision helpers for the zombie-stream (half-open gRPC) watchdog.
|
||||
//
|
||||
// spectrum-ts only reconnects when its inbound async iterator throws or ends.
|
||||
// A half-open ("zombie") socket makes the iterator hang forever — no error,
|
||||
// no end — so inbound silently dies while /healthz still looks fine. The
|
||||
// watchdog in index.mjs tracks the last time the inbound iterator yielded and,
|
||||
// once the stream has been silent past a conservative threshold, drives a
|
||||
// cheap authenticated unary read over the same channel. STRICT semantics:
|
||||
//
|
||||
// - probe resolves, or rejects with a not-found-shaped error for our
|
||||
// synthetic id -> ALIVE (the wire round-tripped)
|
||||
// - probe rejects any other way (UNAVAILABLE, DEADLINE_EXCEEDED, network
|
||||
// down, ...) -> INCONCLUSIVE — never treated as alive, and
|
||||
// never treated as zombie-proof either
|
||||
//
|
||||
// A zombie is only declared when the stream is silent past the threshold AND
|
||||
// a probe proves connectivity (the wire works but the stream is deaf). Silence
|
||||
// alone NEVER degrades the stream: shared lines can be legitimately quiet for
|
||||
// hours. Inconclusive probes NEVER degrade it either: the network may simply
|
||||
// be down, and in that case the iterator will eventually throw and the
|
||||
// existing re-subscribe loop recovers on its own.
|
||||
//
|
||||
// These helpers are pure (no SDK, no timers) so tests can execute them under
|
||||
// node — see tests/plugins/platforms/photon/test_zombie_stream_watchdog.py.
|
||||
|
||||
// gRPC NOT_FOUND is code 5; SDKs also surface it as "not found" / "NotFound"
|
||||
// message text. Anything not clearly not-found is inconclusive.
|
||||
const NOT_FOUND_RE = /not[\s_-]?found/i;
|
||||
|
||||
/**
|
||||
* Classify the rejection of the synthetic-id probe read.
|
||||
*
|
||||
* @param {unknown} err error thrown by `space.getMessage(<synthetic id>)`
|
||||
* @returns {{alive: boolean, inconclusive: boolean, reason: string}}
|
||||
*/
|
||||
export function classifyProbeRejection(err) {
|
||||
const code = err && typeof err === "object" ? err.code : undefined;
|
||||
const message =
|
||||
err && typeof err === "object" && err.message
|
||||
? String(err.message)
|
||||
: String(err);
|
||||
if (code === 5 || code === "notFound" || NOT_FOUND_RE.test(message)) {
|
||||
// Expected: the synthetic id doesn't exist. The unary call completed a
|
||||
// round-trip, so the channel is provably alive.
|
||||
return { alive: true, inconclusive: false, reason: "not-found round-trip" };
|
||||
}
|
||||
// Anything else (UNAVAILABLE, DEADLINE_EXCEEDED, TLS, auth, ...) does NOT
|
||||
// prove liveness — and doesn't prove a zombie either.
|
||||
return { alive: false, inconclusive: true, reason: message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the watchdog probe at all this tick?
|
||||
*
|
||||
* @param {number} silentForMs ms since the inbound iterator last yielded
|
||||
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
|
||||
* @param {number} sinceLastProbeMs ms since the previous probe attempt
|
||||
* @param {number} probeCooldownMs min spacing between probe attempts
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldProbe(silentForMs, thresholdMs, sinceLastProbeMs, probeCooldownMs) {
|
||||
if (!(thresholdMs > 0)) return false;
|
||||
if (silentForMs < thresholdMs) return false;
|
||||
return sinceLastProbeMs >= probeCooldownMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final classification: zombie only on silence past threshold + probe-proven
|
||||
* connectivity. Never on silence alone, never on an inconclusive probe.
|
||||
*
|
||||
* @param {number} silentForMs ms since the inbound iterator last yielded
|
||||
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
|
||||
* @param {{alive: boolean}} probeOutcome
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isZombieSuspect(silentForMs, thresholdMs, probeOutcome) {
|
||||
if (!(thresholdMs > 0)) return false;
|
||||
if (silentForMs < thresholdMs) return false;
|
||||
return probeOutcome != null && probeOutcome.alive === true;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Resolve where the Photon sidecar runs from and where its Node deps live.
|
||||
|
||||
The sidecar source ships inside the installed plugin tree
|
||||
(``plugins/platforms/photon/sidecar/``). On dev/source installs that tree is
|
||||
writable and everything — ``npm ci``, the spectrum patch, the sidecar itself —
|
||||
happens in place. Hosted/managed images instead keep the whole install tree
|
||||
under an immutable ``/opt/hermes`` (read-only for the hermes user), which
|
||||
broke every install/self-heal path with EROFS (NS-606).
|
||||
|
||||
Resolution order (mirrors ``resolve_whatsapp_bridge_dir`` for the Baileys
|
||||
bridge, which hit the same wall):
|
||||
|
||||
1. ``PHOTON_SIDECAR_DIR`` env override — operator escape hatch, used as-is.
|
||||
2. Source dir writable → run in place (dev installs, unchanged behavior).
|
||||
3. Source dir read-only but ``node_modules`` is baked and current → run in
|
||||
place. This is the managed-image happy path: the Dockerfile bakes the
|
||||
sidecar deps with ``npm ci`` at build time (deterministic installs,
|
||||
NS-559), so no runtime install is ever needed.
|
||||
4. Source dir read-only and deps missing or stale → mirror the sidecar
|
||||
source files to ``$HERMES_HOME/photon/sidecar`` (the durable data volume,
|
||||
e.g. ``/opt/data`` on hosted) and return that. The caller's normal
|
||||
install/self-heal machinery then works there because it is writable.
|
||||
|
||||
The mirror is refreshed on every resolve: when an image update changes a
|
||||
sidecar source file, the changed file is re-copied (content compare, not
|
||||
mtime) while ``node_modules`` is left in place — the adapter's existing
|
||||
lockfile-vs-install-marker staleness check then triggers the ``npm ci``
|
||||
self-heal inside the mirror.
|
||||
|
||||
This module is import-light on purpose: both ``adapter.py`` (gateway) and
|
||||
``cli.py`` (``hermes photon ...``) use it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import filecmp
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
||||
|
||||
# The files that define the sidecar. Mirrored into the writable runtime dir
|
||||
# when the install tree is read-only. node_modules is deliberately absent —
|
||||
# it is either baked (managed image) or installed by npm in the mirror.
|
||||
_MIRROR_FILES = (
|
||||
"index.mjs",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"patch-spectrum-mixed-attachments.mjs",
|
||||
)
|
||||
|
||||
|
||||
def dir_writable(path: Path) -> bool:
|
||||
"""True when we can create files in ``path`` (probe-based, not stat).
|
||||
|
||||
A stat-mode check lies on containers (root-squash, read-only bind
|
||||
mounts), so probe with a real create+unlink like the WhatsApp bridge
|
||||
resolver does.
|
||||
"""
|
||||
probe = path / ".hermes-write-probe"
|
||||
try:
|
||||
probe.touch()
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# Backwards-friendly private alias for module-internal use.
|
||||
_dir_writable = dir_writable
|
||||
|
||||
|
||||
def _lock_newer_than_install(sidecar_dir: Path) -> bool:
|
||||
"""True when the committed lockfile postdates npm's install marker.
|
||||
|
||||
Same signal as ``adapter._sidecar_deps_stale`` — duplicated here (three
|
||||
lines) rather than imported so this module stays import-light for the
|
||||
CLI. Returns False on any stat failure so an odd filesystem never forces
|
||||
the mirror path.
|
||||
"""
|
||||
lockfile = sidecar_dir / "package-lock.json"
|
||||
marker = sidecar_dir / "node_modules" / ".package-lock.json"
|
||||
try:
|
||||
return lockfile.stat().st_mtime > marker.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path:
|
||||
"""Return the directory the sidecar should run from (see module doc).
|
||||
|
||||
``source_dir`` defaults to the installed plugin tree; tests and callers
|
||||
that monkeypatch the adapter's ``_SIDECAR_DIR`` pass it through so the
|
||||
override keeps working.
|
||||
"""
|
||||
source = Path(source_dir) if source_dir is not None else SOURCE_SIDECAR_DIR
|
||||
|
||||
override = os.getenv("PHOTON_SIDECAR_DIR")
|
||||
if override:
|
||||
return Path(override)
|
||||
|
||||
if _dir_writable(source):
|
||||
return source
|
||||
|
||||
# Read-only install tree (hosted/managed image). If the image baked the
|
||||
# deps at build time and they match the lockfile, run in place — the
|
||||
# sidecar itself never writes inside its own directory.
|
||||
if (source / "node_modules").exists() and not _lock_newer_than_install(source):
|
||||
return source
|
||||
|
||||
# Deps missing or stale inside a read-only tree: mirror to the durable
|
||||
# data volume so the normal install/self-heal machinery has somewhere
|
||||
# writable to work.
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
mirror = get_hermes_home() / "photon" / "sidecar"
|
||||
try:
|
||||
mirror.mkdir(parents=True, exist_ok=True)
|
||||
for name in _MIRROR_FILES:
|
||||
src = source / name
|
||||
if not src.exists():
|
||||
continue
|
||||
dst = mirror / name
|
||||
if not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False):
|
||||
shutil.copy2(str(src), str(dst))
|
||||
return mirror
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"[photon] install tree is read-only and mirroring the sidecar "
|
||||
"to %s failed (%s) — falling back to the read-only source dir; "
|
||||
"dependency installs will not be possible",
|
||||
mirror,
|
||||
exc,
|
||||
)
|
||||
return source
|
||||
Reference in New Issue
Block a user