From 08742f9af0615ff30daae15beed88cd0b3e59ccc Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Wed, 10 Jun 2026 23:55:48 -0700 Subject: [PATCH 1/9] feat(photon): add telemetry toggle via `hermes photon telemetry` --- plugins/platforms/photon/README.md | 1 + plugins/platforms/photon/cli.py | 44 ++++++++++++++++++++++ plugins/platforms/photon/plugin.yaml | 4 ++ plugins/platforms/photon/sidecar/index.mjs | 6 +++ 4 files changed, 55 insertions(+) diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index a2cd92ec4a7f..f78be5d41d93 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -116,6 +116,7 @@ All env vars are documented in `plugin.yaml`. The most important: | `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) | ## Attachments & limitations diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 789746860f85..f93b33f2c956 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -7,6 +7,7 @@ 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 @@ -58,6 +59,15 @@ 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_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) @@ -75,6 +85,8 @@ def dispatch(args: argparse.Namespace) -> int: 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 @@ -303,6 +315,7 @@ def _cmd_status(_args: argparse.Namespace) -> int: sidecar_installed = (_SIDECAR_DIR / "node_modules").exists() 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 @@ -323,6 +336,37 @@ 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): diff --git a/plugins/platforms/photon/plugin.yaml b/plugins/platforms/photon/plugin.yaml index 12ade17f8e58..c9e149cbca4f 100644 --- a/plugins/platforms/photon/plugin.yaml +++ b/plugins/platforms/photon/plugin.yaml @@ -74,3 +74,7 @@ optional_env: 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 diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index d1c44d7c51bc..065e90d84cb6 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -38,6 +38,8 @@ // PHOTON_SIDECAR_TOKEN // Optional: // PHOTON_SIDECAR_BIND (default 127.0.0.1) +// PHOTON_TELEMETRY enable Spectrum SDK telemetry ("true"/"1"/"on"/"yes"; +// default off — toggle with `hermes photon telemetry`) import http from "node:http"; import crypto from "node:crypto"; @@ -48,6 +50,9 @@ const projectSecret = process.env.PHOTON_PROJECT_SECRET; const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10); const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1"; const sharedToken = process.env.PHOTON_SIDECAR_TOKEN; +const telemetry = /^(1|true|yes|on)$/i.test( + (process.env.PHOTON_TELEMETRY || "").trim() +); // Inbound binary content is read into memory and base64-inlined on the NDJSON // event so the Python adapter can cache the real bytes (and the agent can see @@ -94,6 +99,7 @@ const app = await Spectrum({ projectSecret, providers: [imessage.config()], options: { flattenGroups: true }, + telemetry, }); // --------------------------------------------------------------------------- From 08f70d0ef54aa1c2f649ee67fe34269818224f87 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Thu, 11 Jun 2026 02:48:11 -0700 Subject: [PATCH 2/9] feat(photon): upgrade to spectrum-ts 3.0.0 (pinned) with markdown + reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin spectrum-ts to exactly 3.0.0 (was ^1.18.0 plus an `npm install spectrum-ts@latest` on every setup) so breaking SDK majors can't take down fresh installs silently; `hermes photon setup` now runs `npm ci`. Upgrade procedure documented in the README. Migrate resolveSpace to the v3 namespace API: `im.space.create(phone)` for DMs and `im.space.get(id)` for everything else — group spaces are now rehydratable from their persisted id after a sidecar restart, which v1 could not do. Markdown: replies go out via the v3 `markdown()` builder (iMessage renders natively; other Spectrum platforms degrade to plain text). `PHOTON_MARKDOWN=false` reverts to the stripped plain-text path. Reactions, behind PHOTON_REACTIONS (default off): lifecycle tapbacks (👀 while processing, 👍/👎 on completion) via new sidecar /react and /unreact endpoints with per-target reaction-handle tracking, and user tapbacks on bot-sent messages routed to the agent as synthetic `reaction:added:` events. Co-Authored-By: Claude Fable 5 --- plugins/platforms/photon/README.md | 46 ++- plugins/platforms/photon/adapter.py | 182 +++++++++++- plugins/platforms/photon/cli.py | 22 +- plugins/platforms/photon/plugin.yaml | 10 +- plugins/platforms/photon/sidecar/index.mjs | 188 +++++++++--- .../photon/sidecar/package-lock.json | 38 ++- plugins/platforms/photon/sidecar/package.json | 4 +- .../plugins/platforms/photon/test_markdown.py | 129 ++++++++ .../platforms/photon/test_reactions.py | 275 ++++++++++++++++++ 9 files changed, 832 insertions(+), 62 deletions(-) create mode 100644 tests/plugins/platforms/photon/test_markdown.py create mode 100644 tests/plugins/platforms/photon/test_reactions.py diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index f78be5d41d93..af885cc61047 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -35,8 +35,9 @@ talks to it over 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`. +- **Outbound**: `send` / `send_typing` / reaction tapbacks are loopback POSTs + to the sidecar (`/send`, `/send-attachment`, `/typing`, `/react`, + `/unreact`), authenticated with a shared `X-Hermes-Sidecar-Token`. ## First-time setup @@ -59,7 +60,9 @@ hermes gateway start --platform photon 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`). +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 @@ -117,6 +120,8 @@ All env vars are documented in `plugin.yaml`. The most important: | `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:` | ## Attachments & limitations @@ -132,7 +137,38 @@ All env vars are documented in `plugin.yaml`. The most important: 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** — supported by `spectrum-ts` but not - yet exposed; the sidecar is the natural place to add them. +- **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. `PHOTON_MARKDOWN=false` + reverts to stripped plain text. +- **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:` 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 v3's `space.get(id)`. +- **Message effects, polls** — supported by `spectrum-ts` but not yet + exposed; the sidecar is the natural place to add them. + +## 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). +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 + (`sidecar/node_modules/spectrum-ts/dist/*.d.ts` is the source of truth — + the hosted docs can lag). +4. Run `pytest tests/plugins/platforms/photon/`. +5. 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/ diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 78902234b1b5..9673d58c9eb2 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -58,6 +58,7 @@ BasePlatformAdapter, MessageEvent, MessageType, + ProcessingOutcome, SendResult, ) from gateway.platforms.helpers import strip_markdown @@ -152,6 +153,19 @@ def _env_enablement() -> Optional[dict]: return seed +def _markdown_enabled() -> bool: + """Send agent replies as markdown (spectrum-ts ``markdown()`` builder). + + iMessage renders it natively; other Spectrum platforms degrade to + readable plain text. On-device rendering can't be unit-tested, so + ``PHOTON_MARKDOWN=false`` is the kill-switch back to stripped plain + text without a release. + """ + return os.getenv("PHOTON_MARKDOWN", "true").strip().lower() not in { + "false", "0", "no", + } + + # --------------------------------------------------------------------------- # Adapter @@ -199,6 +213,10 @@ def __init__(self, config: PlatformConfig): ).lower() not in ("0", "false", "no") self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node" + # With markdown on, format_message preserves fences and the sidecar's + # markdown() builder renders them (or degrades them readably). + self.supports_code_blocks = _markdown_enabled() + # Runtime state self._sidecar_proc: Optional[subprocess.Popen] = None self._sidecar_supervisor_task: Optional[asyncio.Task] = None @@ -208,6 +226,10 @@ def __init__(self, config: PlatformConfig): # 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] = {} + # Ids of messages WE sent (bounded, insertion-order eviction). Inbound + # reaction events are only routed to the agent when they target one of + # these — a tapback on a human↔human message is not addressed to us. + self._sent_message_ids: Dict[str, float] = {} # Group-chat mention gating (parity with BlueBubbles). When enabled, # group messages are ignored unless they match a wake word; DMs are @@ -442,7 +464,10 @@ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None: "content": {"type": "text", "text": "..."} | {"type": "attachment"|"voice", "id", "name", "mimeType", "size", "duration"?, "data"?, - "encoding"?}, + "encoding"?} + | {"type": "reaction", "emoji": "❤️", + "targetMessageId": "..." | null, + "targetDirection": "inbound"|"outbound" | null}, "timestamp": "2026-05-14T19:06:32.000Z" Attachment and voice content carry the bytes inline as base64 ``data`` @@ -480,6 +505,39 @@ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None: media_types: List[str] = [] ctype = content.get("type") + if ctype == "reaction": + # Route only tapbacks on messages WE sent — those are implicitly + # addressed to the bot (feishu precedent: synthetic text event). + # Reactions on human↔human messages are not for us. Checked before + # the mention gate: a tapback never carries a wake word. + target_id = content.get("targetMessageId") + is_ours = content.get("targetDirection") == "outbound" or ( + target_id and target_id in self._sent_message_ids + ) + if not is_ours: + logger.debug( + "[photon] ignoring reaction on a message we didn't send" + ) + return + emoji = content.get("emoji") or "" + source = self.build_source( + chat_id=space_id, + chat_name=space_id, + chat_type=chat_type, + user_id=sender_id, + user_name=sender_id or None, + ) + await self.handle_message( + MessageEvent( + text=f"reaction:added:{emoji}", + message_type=MessageType.TEXT, + source=source, + message_id=event.get("messageId"), + raw_message=event, + timestamp=timestamp, + ) + ) + return if ctype == "text": text = content.get("text") or "" mtype = MessageType.TEXT @@ -774,6 +832,91 @@ async def stop_typing(self, chat_id: str) -> None: except Exception as e: logger.debug("[photon] stop_typing failed: %s", e) + # -- Reactions (tapbacks) ----------------------------------------------- + # + # Same lifecycle-hook pattern as Telegram/Discord: 👀 while processing, + # swapped for 👍/👎 on completion. Opt-in via PHOTON_REACTIONS — iMessage + # is a personal-texting channel, and a tapback on every text is noisy. + + _SENT_IDS_MAX = 1000 + + def _record_sent_message(self, message_id: Optional[str]) -> None: + if not message_id: + return + sent = self._sent_message_ids + if message_id in sent: + del sent[message_id] # refresh insertion order + sent[message_id] = time.time() + if len(sent) > self._SENT_IDS_MAX: + for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]: + del sent[old] + + def _reactions_enabled(self) -> bool: + return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in { + "true", "1", "yes", "on", + } + + async def _add_reaction( + self, chat_id: str, message_id: str, emoji: str + ) -> bool: + """Tapback ``emoji`` onto a message. Soft-fails (False), never raises.""" + try: + await self._sidecar_call( + "/react", + {"spaceId": chat_id, "messageId": message_id, "emoji": emoji}, + ) + return True + except Exception as e: + logger.debug("[photon] add_reaction failed: %s", e) + return False + + async def _remove_reaction(self, chat_id: str, message_id: str) -> bool: + """Retract our tapback from a message. Soft-fails (False), never raises. + + The sidecar tracks one reaction handle per target message; after a + sidecar restart the handle is gone and removal is best-effort (the + stale tapback self-heals when the next reaction replaces it). + """ + try: + await self._sidecar_call( + "/unreact", {"spaceId": chat_id, "messageId": message_id}, + ) + return True + except Exception as e: + logger.debug("[photon] remove_reaction failed: %s", e) + return False + + async def on_processing_start(self, event: MessageEvent) -> None: + """Tapback 👀 on the triggering message while the agent works.""" + if not self._reactions_enabled(): + return + chat_id = getattr(event.source, "chat_id", None) + message_id = getattr(event, "message_id", None) + if chat_id and message_id: + await self._add_reaction(chat_id, message_id, "\U0001f440") + + async def on_processing_complete( + self, event: MessageEvent, outcome: ProcessingOutcome + ) -> None: + """Swap the 👀 progress tapback for a 👍/👎 result. + + Remove-then-add rather than a bare replace: deterministic whether the + platform replaces a sender's previous tapback or stacks them, and it + keeps the sidecar's reaction-handle slot coherent. + """ + if not self._reactions_enabled(): + return + chat_id = getattr(event.source, "chat_id", None) + message_id = getattr(event, "message_id", None) + if not chat_id or not message_id: + return + await self._remove_reaction(chat_id, message_id) + if outcome == ProcessingOutcome.SUCCESS: + await self._add_reaction(chat_id, message_id, "\U0001f44d") + elif outcome == ProcessingOutcome.FAILURE: + await self._add_reaction(chat_id, message_id, "\U0001f44e") + # CANCELLED: leave the message unreacted. + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return whatever we know about a Spectrum space id. @@ -783,6 +926,11 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": chat_id, "type": "dm", "id": chat_id} def format_message(self, content: str) -> str: + # Markdown is passed through verbatim — the sidecar sends it with the + # markdown() builder and iMessage renders it. The strip path remains + # as the PHOTON_MARKDOWN=false kill-switch. + if _markdown_enabled(): + return content return strip_markdown(content) async def _send_with_retry( @@ -794,7 +942,12 @@ async def _send_with_retry( max_retries: int = 2, base_delay: float = 2.0, ) -> SendResult: - """Photon/iMessage is plain text, so never show the generic Markdown banner.""" + """Retry sends without the generic Markdown banner. + + Photon replies are markdown (rendered by iMessage) or stripped plain + text under ``PHOTON_MARKDOWN=false`` — either way the gateway's + generic banner never applies. + """ text = self.format_message(content) result = await self.send( chat_id=chat_id, @@ -858,10 +1011,15 @@ async def _sidecar_send(self, space_id: str, text: str) -> SendResult: ) text = text[: self.MAX_MESSAGE_LENGTH] body: Dict[str, Any] = {"spaceId": space_id, "text": text} + # Omit the key when disabled so an older sidecar (pre-`format`) + # keeps accepting the body during a half-upgraded restart. + if _markdown_enabled(): + body["format"] = "markdown" try: data = await self._sidecar_call("/send", body) except Exception as e: return SendResult(success=False, error=str(e)) + self._record_sent_message(data.get("messageId")) return SendResult(success=True, message_id=data.get("messageId")) async def _sidecar_send_attachment( @@ -910,6 +1068,7 @@ async def _sidecar_send_attachment( data = await self._sidecar_call("/send-attachment", body) except Exception as e: return SendResult(success=False, error=str(e)) + self._record_sent_message(data.get("messageId")) return SendResult(success=True, message_id=data.get("messageId")) async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: @@ -1062,10 +1221,14 @@ async def _standalone_send( async with httpx.AsyncClient(timeout=30.0) as client: # 1. Text body first (if any), so it leads the conversation. if message: + send_body: Dict[str, Any] = { + "spaceId": chat_id, + "text": message[:_MAX_MESSAGE_LENGTH], + } + if _markdown_enabled(): + send_body["format"] = "markdown" resp = await client.post( - f"{base}/send", - json={"spaceId": chat_id, "text": message[:_MAX_MESSAGE_LENGTH]}, - headers=headers, + f"{base}/send", json=send_body, headers=headers, ) if resp.status_code != 200: return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"} @@ -1146,10 +1309,11 @@ def register(ctx) -> None: allow_update_command=True, platform_hint=( "You are communicating via Photon Spectrum (iMessage). " - "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." + "Treat replies like regular text messages — short and friendly. " + "Markdown is rendered (bold, italics, lists, code), but keep " + "formatting light and conversational. Recipient identifiers are " + "E.164 phone numbers; never expose them in responses unless the " + "user asked. Attachments arrive as metadata only." ), ) diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index f93b33f2c956..5e93f76b670d 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -376,16 +376,26 @@ def _install_sidecar() -> int: file=sys.stderr, ) return 1 - # Always pull the newest published spectrum-ts so every setup runs against - # the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json - # to the current release before installing — a plain `npm install` would - # stay pinned to whatever the committed lockfile already resolved. - print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest") + # spectrum-ts is pinned exactly in package.json/package-lock.json because + # the SDK ships breaking majors (v2 removed defineFusorPlatform; v3 + # reworked space construction). 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") proc = subprocess.run( # noqa: S603 - [npm, "install", "spectrum-ts@latest"], + [npm, "ci"], cwd=str(_SIDECAR_DIR), check=False, ) + 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, + ) if proc.returncode != 0: print("npm install failed", file=sys.stderr) return proc.returncode diff --git a/plugins/platforms/photon/plugin.yaml b/plugins/platforms/photon/plugin.yaml index c9e149cbca4f..a39193a81bf4 100644 --- a/plugins/platforms/photon/plugin.yaml +++ b/plugins/platforms/photon/plugin.yaml @@ -1,7 +1,7 @@ name: photon-platform label: iMessage via Photon kind: platform -version: 0.2.0 +version: 0.3.0 description: > Photon Spectrum gateway adapter for Hermes Agent. Connects to iMessage (and other Spectrum interfaces) through Photon's @@ -78,3 +78,11 @@ optional_env: 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 diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index 065e90d84cb6..91073f32b4a6 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -19,11 +19,18 @@ // lines are heartbeats. One consumer at a time. // - POST /healthz -> {"ok": true} // - POST /send -> {"ok": true, "messageId": "..."} -// body: {"spaceId": "...", "text": "..."} +// body: {"spaceId": "...", "text": "...", +// "format": "text" | "markdown" (default "text")} // - POST /send-attachment -> {"ok": true, "messageId": "..."} // body: {"spaceId": "...", "path": "...", "name": "..." | null, // "mimeType": "..." | null, "caption": "..." | null, // "kind": "attachment" | "voice"} +// - POST /react -> {"ok": true, "reactionId": "..." | null} +// body: {"spaceId": "...", "messageId": "", +// "emoji": "👀"} +// - POST /unreact -> {"ok": true} | 400 soft failure +// body: {"spaceId": "...", "messageId": "", +// "reactionId": "..." | null (restart-recovery fallback)} // - POST /typing -> {"ok": true} // body: {"spaceId": "...", "state": "start" | "stop"} // - POST /shutdown -> {"ok": true}; then process exits @@ -31,6 +38,9 @@ // On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before // exiting. Logs go to stderr; Python supervises restart. // +// Requires spectrum-ts 3.x — pinned exactly in package.json because the SDK +// ships breaking majors; see README "Upgrading spectrum-ts". +// // Env vars (required): // PHOTON_PROJECT_ID (== the project's spectrumProjectId) // PHOTON_PROJECT_SECRET @@ -64,6 +74,8 @@ const MAX_INLINE_ATTACHMENT_BYTES = const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/; const E164_RE = /^\+\d{6,}$/; const MAX_KNOWN_SPACES = 2048; +const MAX_KNOWN_MESSAGES = 1024; +const MAX_REACTION_HANDLES = 512; if (!projectId || !projectSecret || !sharedToken) { console.error( @@ -75,13 +87,20 @@ if (!projectId || !projectSecret || !sharedToken) { // Lazy-load spectrum-ts so a missing install fails with a clear message // instead of a cryptic module-resolution error during import. -let Spectrum, imessage, attachment, voice, spectrumText, spectrumTyping; +let Spectrum, + imessage, + attachment, + voice, + spectrumText, + spectrumMarkdown, + spectrumTyping; try { ({ Spectrum, attachment, voice, text: spectrumText, + markdown: spectrumMarkdown, typing: spectrumTyping, } = await import("spectrum-ts")); ({ imessage } = await import("spectrum-ts/providers/imessage")); @@ -109,15 +128,34 @@ const app = await Spectrum({ let consumerRes = null; let consumerWaiters = []; const knownSpaces = new Map(); +// Inbound Message objects by id, so /react can usually skip a +// `space.getMessage` round trip when tapping back on a recent message. +const knownMessages = new Map(); +// One reaction handle per reacted-to message (key `${spaceId}\0${messageId}`, +// value {emoji, handle}) — mirrors iMessage's one-tapback-per-sender +// semantics; a new /react on the same target overwrites the slot. The handle +// is the outbound reaction Message returned by `target.react()`, kept so +// /unreact can `unsend()` it later. +const reactionHandles = new Map(); + +function lruSet(map, key, value, cap) { + if (map.has(key)) map.delete(key); + map.set(key, value); + if (map.size > cap) { + const oldest = map.keys().next().value; + if (oldest !== undefined) map.delete(oldest); + } +} function rememberKnownSpace(id, space) { if (!id || typeof id !== "string" || !space) return; - if (knownSpaces.has(id)) knownSpaces.delete(id); - knownSpaces.set(id, space); - if (knownSpaces.size > MAX_KNOWN_SPACES) { - const oldest = knownSpaces.keys().next().value; - if (oldest) knownSpaces.delete(oldest); - } + lruSet(knownSpaces, id, space, MAX_KNOWN_SPACES); +} + +function rememberKnownMessage(message) { + const id = message?.id; + if (!id || typeof id !== "string") return; + lruSet(knownMessages, id, message, MAX_KNOWN_MESSAGES); } function phoneTargetFromSpaceId(spaceId) { @@ -232,6 +270,17 @@ async function normalizeContent(content) { if (content.type === "attachment" || content.type === "voice") { return await normalizeBinaryContent(content); } + if (content.type === "reaction") { + return { + type: "reaction", + emoji: content.emoji || "", + targetMessageId: content.target?.id ?? null, + // Lets Python gate "is this a reaction to one of MY messages" without + // tracking every outbound id. May be null if the provider doesn't + // hydrate the target — Python falls back to its own sent-id cache. + targetDirection: content.target?.direction ?? null, + }; + } return { type: content.type || "unknown" }; } @@ -276,6 +325,7 @@ async function normalizeEvent(space, message) { continue; } rememberInboundSpace(space, message); + rememberKnownMessage(message); const event = await normalizeEvent(space, message); if (!event) continue; await deliver(JSON.stringify(event)); @@ -385,37 +435,44 @@ async function resolveSpace(spaceId) { const cached = knownSpaces.get(spaceId); if (cached) return cached; + const im = imessage(app); const phoneTarget = phoneTargetFromSpaceId(spaceId); - // A bare E.164 phone number addresses a DM. Resolve the user, then the (DM) - // space — `imessage(app).user(phone)` -> `im.space(user)` — so callers can - // pass just "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of - // an opaque inbound space id. Photon also represents DM chat ids as - // `any;-;+1...`; normalize those through the same path so replies to inbound - // DMs still resolve after Python stores the inbound `space.id`. - if (phoneTarget && imessage) { + let space = null; + + // A bare E.164 phone number addresses a DM, so callers can pass just + // "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of an opaque + // inbound space id. Photon also represents DM chat ids as `any;-;+1...`; + // normalize those through the same path. `space.create` accepts the raw + // phone string directly. + if (phoneTarget) { try { - const im = imessage(app); - const user = await im.user(phoneTarget); - const space = await im.space(user); - rememberKnownSpace(spaceId, space); - rememberKnownSpace(phoneTarget, space); - rememberKnownSpace(space?.id, space); - return space; + space = await im.space.create(phoneTarget); } catch (e) { console.error( - "photon-sidecar: phone->DM resolution failed: " + + "photon-sidecar: phone->DM space.create failed: " + (e && e.stack ? e.stack : String(e)) ); } } - // No cache hit and not a phone/DM target. spectrum-ts exposes no API to - // rehydrate an arbitrary opaque space id: a Space is only obtained from the - // inbound `[space, message]` stream (cached above in `knownSpaces`) or - // reconstructed for a DM from its phone number. So a group space whose cache - // entry was lost — e.g. after a sidecar restart with no fresh inbound message - // in that group — cannot be resolved here; a new inbound message in the group - // re-warms the cache. DMs are unaffected (reconstructed from the phone). - throw new Error(`unable to resolve space id ${spaceId}`); + // Anything else — typically an opaque group GUID — is rehydrated from the + // persisted id via `space.get`, so group spaces stay reachable after a + // sidecar restart even before any fresh inbound message in that group. + if (!space) { + try { + space = await im.space.get(spaceId); + } catch (e) { + console.error( + "photon-sidecar: space.get failed: " + + (e && e.stack ? e.stack : String(e)) + ); + } + } + if (!space) throw new Error(`unable to resolve space id ${spaceId}`); + + rememberKnownSpace(spaceId, space); + if (phoneTarget) rememberKnownSpace(phoneTarget, space); + rememberKnownSpace(space?.id, space); + return space; } // Constant-time token comparison — don't leak the token via `!==` timing. @@ -449,12 +506,19 @@ const server = http.createServer(async (req, res) => { } const body = await readBody(req); if (req.url === "/send") { - const { spaceId, text } = body || {}; + const { spaceId, text, format = "text" } = body || {}; if (!spaceId || typeof text !== "string") { return badRequest(res, "spaceId and text are required"); } + if (format !== "text" && format !== "markdown") { + return badRequest(res, "format must be text or markdown"); + } const space = await resolveSpace(spaceId); - const result = await space.send(spectrumText(text)); + // iMessage renders markdown natively; spectrum-ts degrades it to + // readable plain text on platforms that don't. + const builder = + format === "markdown" ? spectrumMarkdown(text) : spectrumText(text); + const result = await space.send(builder); return ok(res, { messageId: result?.id || null }); } if (req.url === "/send-attachment") { @@ -492,6 +556,64 @@ const server = http.createServer(async (req, res) => { } return ok(res, { messageId: result?.id || null }); } + if (req.url === "/react") { + const { spaceId, messageId, emoji } = body || {}; + if (!spaceId || !messageId || typeof emoji !== "string" || !emoji) { + return badRequest(res, "spaceId, messageId and emoji are required"); + } + const space = await resolveSpace(spaceId); + const target = + knownMessages.get(messageId) ?? (await space.getMessage(messageId)); + if (!target) { + return badRequest(res, "message not found"); + } + const handle = await target.react(emoji); + if (!handle) { + return badRequest(res, "reactions not supported on this platform"); + } + lruSet( + reactionHandles, + `${spaceId}\u0000${messageId}`, + { emoji, handle }, + MAX_REACTION_HANDLES + ); + return ok(res, { reactionId: handle.id ?? null }); + } + if (req.url === "/unreact") { + const { spaceId, messageId, reactionId } = body || {}; + if (!spaceId || !messageId) { + return badRequest(res, "spaceId and messageId are required"); + } + const key = `${spaceId}\u0000${messageId}`; + const slot = reactionHandles.get(key); + if (slot) { + await slot.handle.unsend(); + reactionHandles.delete(key); + return ok(res, {}); + } + // Restart-recovery: the live handle is gone, so try rehydrating the + // reaction message by id and retracting it. Only outbound messages can + // be unsent — if the provider rehydrates it as inbound (or not at all) + // this throws, and that's an expected soft failure, not a sidecar bug: + // a stale tapback self-heals when the next /react replaces it. + if (reactionId) { + try { + const space = await resolveSpace(spaceId); + const msg = await space.getMessage(reactionId); + if (msg) { + await space.unsend(msg); + return ok(res, {}); + } + } catch (e) { + console.error( + "photon-sidecar: best-effort unreact failed: " + + (e && e.message ? e.message : String(e)) + ); + } + return badRequest(res, "reaction not removable"); + } + return badRequest(res, "no tracked reaction for message"); + } if (req.url === "/typing") { const { spaceId, state = "start" } = body || {}; if (!spaceId) return badRequest(res, "spaceId is required"); diff --git a/plugins/platforms/photon/sidecar/package-lock.json b/plugins/platforms/photon/sidecar/package-lock.json index 8a19d1445ddf..76c44da5f02f 100644 --- a/plugins/platforms/photon/sidecar/package-lock.json +++ b/plugins/platforms/photon/sidecar/package-lock.json @@ -1,14 +1,14 @@ { "name": "@hermes-agent/photon-sidecar", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hermes-agent/photon-sidecar", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { - "spectrum-ts": "^1.18.0" + "spectrum-ts": "3.0.0" }, "engines": { "node": ">=18.17" @@ -413,6 +413,18 @@ "node": ">=18" } }, + "node_modules/@photon-ai/telegram-ts": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@photon-ai/telegram-ts/-/telegram-ts-10.0.0.tgz", + "integrity": "sha512-kYGj/ieKOCG+OxoD1R69xHoT7zHl9dboF52LMPUl4FnorbwA8b2pid0uFoDYF55WIfoeo+VSqwlmY84GgpSedg==", + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@photon-ai/whatsapp-business": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@photon-ai/whatsapp-business/-/whatsapp-business-0.1.1.tgz", @@ -1025,6 +1037,18 @@ "node": "20 || >=22" } }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -1396,9 +1420,9 @@ } }, "node_modules/spectrum-ts": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-1.18.0.tgz", - "integrity": "sha512-xgqGSCY4ltA737mJ2Yb2wniJDOYzZRby3YxeT9mv0iOvyWlsG2ptSp72LcXZBgkD4ejVSXAkzg7iLmSlf02buA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-3.0.0.tgz", + "integrity": "sha512-96XNXaEqohhTJfE/XL3+iNW9Pflc2jj7Xk5LLPthKEwOz6e6vdBh/KB5miAe2lQ+mkFtwEguVe2n4MVkBLcAtA==", "license": "MIT", "dependencies": { "@photon-ai/advanced-imessage": "^0.11.0", @@ -1406,10 +1430,12 @@ "@photon-ai/otel": "^0.1.1", "@photon-ai/proto": "^0.2.4", "@photon-ai/slack": "^0.2.0", + "@photon-ai/telegram-ts": "10.0.0", "@photon-ai/whatsapp-business": "^0.1.1", "@repeaterjs/repeater": "^3.0.6", "better-grpc": "^0.3.2", "lru-cache": "^11.0.0", + "marked": "^18.0.5", "mime-types": "^3.0.1", "nice-grpc": "^2.1.16", "nice-grpc-common": "^2.0.2", diff --git a/plugins/platforms/photon/sidecar/package.json b/plugins/platforms/photon/sidecar/package.json index 522335e46b13..424752eccb2b 100644 --- a/plugins/platforms/photon/sidecar/package.json +++ b/plugins/platforms/photon/sidecar/package.json @@ -1,7 +1,7 @@ { "name": "@hermes-agent/photon-sidecar", "private": true, - "version": "0.2.0", + "version": "0.3.0", "description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.", "type": "module", "main": "index.mjs", @@ -12,7 +12,7 @@ "node": ">=18.17" }, "dependencies": { - "spectrum-ts": "^1.18.0" + "spectrum-ts": "3.0.0" }, "overrides": { "protobufjs": "8.6.1", diff --git a/tests/plugins/platforms/photon/test_markdown.py b/tests/plugins/platforms/photon/test_markdown.py new file mode 100644 index 000000000000..6e803d653179 --- /dev/null +++ b/tests/plugins/platforms/photon/test_markdown.py @@ -0,0 +1,129 @@ +"""Markdown handling tests for PhotonAdapter. + +Markdown is on by default (the sidecar sends it via spectrum-ts' +``markdown()`` builder and iMessage renders it); ``PHOTON_MARKDOWN=false`` +reverts to the stripped-plain-text path. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.photon import adapter as photon_adapter +from plugins.platforms.photon.adapter import PhotonAdapter + +_MD = "**bold** and `code`" + + +def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter: + monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") + monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + cfg = PlatformConfig(enabled=True, token="", extra={}) + return PhotonAdapter(cfg) + + +def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]: + calls: List[Tuple[str, Dict[str, Any]]] = [] + + async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]: + calls.append((path, body)) + return {"ok": True, "messageId": "msg-123"} + + adapter._sidecar_call = _fake_call # type: ignore[assignment] + return calls + + +def test_format_message_passthrough_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + assert adapter.format_message(_MD) == _MD + + +def test_format_message_strips_when_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PHOTON_MARKDOWN", "false") + adapter = _make_adapter(monkeypatch) + assert adapter.format_message(_MD) == "bold and code" + + +def test_supports_code_blocks_mirrors_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + assert _make_adapter(monkeypatch).supports_code_blocks is True + monkeypatch.setenv("PHOTON_MARKDOWN", "false") + assert _make_adapter(monkeypatch).supports_code_blocks is False + + +@pytest.mark.asyncio +async def test_sidecar_send_includes_markdown_format( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.send("+15551234567", _MD) + + path, body = calls[0] + assert path == "/send" + assert body["format"] == "markdown" + assert body["text"] == _MD # passed through unstripped + + +@pytest.mark.asyncio +async def test_sidecar_send_omits_format_when_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Old-sidecar compat: the key is absent, not "text", when disabled.""" + monkeypatch.setenv("PHOTON_MARKDOWN", "false") + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.send("+15551234567", _MD) + + _, body = calls[0] + assert "format" not in body + assert body["text"] == "bold and code" + + +@pytest.mark.asyncio +async def test_standalone_send_includes_markdown_format( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok") + + posted: List[Tuple[str, Dict[str, Any]]] = [] + + class _Resp: + status_code = 200 + + @staticmethod + def json() -> Dict[str, Any]: + return {"ok": True, "messageId": "m-9"} + + class _FakeClient: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url: str, json: Dict[str, Any], headers=None): + posted.append((url, json)) + return _Resp() + + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient) + + cfg = PlatformConfig(enabled=True, token="", extra={}) + result = await photon_adapter._standalone_send(cfg, "+15551234567", _MD) + + assert result.get("success") is True + assert posted[0][1]["format"] == "markdown" diff --git a/tests/plugins/platforms/photon/test_reactions.py b/tests/plugins/platforms/photon/test_reactions.py new file mode 100644 index 000000000000..78789bd1469b --- /dev/null +++ b/tests/plugins/platforms/photon/test_reactions.py @@ -0,0 +1,275 @@ +"""Reaction (tapback) tests for PhotonAdapter. + +Outbound reactions go through the sidecar's ``/react`` / ``/unreact`` +endpoints; these tests stub ``_sidecar_call`` to assert endpoint + body +shape. Inbound reaction events are fed straight to ``_dispatch_inbound``. +Neither path spawns the Node sidecar or binds ports. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Tuple + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome +from plugins.platforms.photon.adapter import PhotonAdapter + +_EYES = "\U0001f440" +_THUMBS_UP = "\U0001f44d" +_THUMBS_DOWN = "\U0001f44e" + + +def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter: + monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") + monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + cfg = PlatformConfig(enabled=True, token="", extra={}) + return PhotonAdapter(cfg) + + +def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]: + calls: List[Tuple[str, Dict[str, Any]]] = [] + + async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]: + calls.append((path, body)) + return {"ok": True, "messageId": "msg-123", "reactionId": "react-1"} + + adapter._sidecar_call = _fake_call # type: ignore[assignment] + return calls + + +def _capture_handled( + adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch +) -> List[MessageEvent]: + captured: List[MessageEvent] = [] + + async def fake_handle(event: MessageEvent) -> None: + captured.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + return captured + + +def _message_event(adapter: PhotonAdapter) -> MessageEvent: + return MessageEvent( + text="hi", + message_type=MessageType.TEXT, + source=adapter.build_source( + chat_id="+15551234567", + chat_name="+15551234567", + chat_type="dm", + user_id="+15551234567", + user_name=None, + ), + message_id="target-msg-1", + timestamp=datetime.now(tz=timezone.utc), + ) + + +def _reaction_event( + emoji: str = "❤️", + target_id: str = "bot-msg-1", + target_direction: Any = "outbound", + space_type: str = "dm", +) -> Dict[str, Any]: + return { + "messageId": "reaction-evt-1", + "platform": "iMessage", + "space": {"id": "+15551234567", "type": space_type, "phone": "+15551234567"}, + "sender": {"id": "+15551234567"}, + "content": { + "type": "reaction", + "emoji": emoji, + "targetMessageId": target_id, + "targetDirection": target_direction, + }, + "timestamp": "2026-06-11T10:00:00.000Z", + } + + +# -- Outbound: /react and /unreact body shapes ------------------------------ + +@pytest.mark.asyncio +async def test_add_reaction_posts_react(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + ok = await adapter._add_reaction("+15551234567", "target-msg-1", _EYES) + + assert ok is True + assert calls == [ + ( + "/react", + { + "spaceId": "+15551234567", + "messageId": "target-msg-1", + "emoji": _EYES, + }, + ) + ] + + +@pytest.mark.asyncio +async def test_remove_reaction_posts_unreact(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + ok = await adapter._remove_reaction("+15551234567", "target-msg-1") + + assert ok is True + assert calls == [ + ("/unreact", {"spaceId": "+15551234567", "messageId": "target-msg-1"}) + ] + + +@pytest.mark.asyncio +async def test_reaction_failure_is_soft(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + + async def _boom(path: str, body: Dict[str, Any]) -> Dict[str, Any]: + raise RuntimeError("sidecar down") + + adapter._sidecar_call = _boom # type: ignore[assignment] + + assert await adapter._add_reaction("+1", "m", _EYES) is False + assert await adapter._remove_reaction("+1", "m") is False + + +# -- Lifecycle hooks --------------------------------------------------------- + +@pytest.mark.asyncio +async def test_hooks_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PHOTON_REACTIONS", raising=False) + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + event = _message_event(adapter) + await adapter.on_processing_start(event) + await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS) + + assert calls == [] + + +@pytest.mark.asyncio +async def test_processing_start_adds_eyes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PHOTON_REACTIONS", "true") + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.on_processing_start(_message_event(adapter)) + + assert len(calls) == 1 + path, body = calls[0] + assert path == "/react" + assert body["emoji"] == _EYES + assert body["messageId"] == "target-msg-1" + + +@pytest.mark.asyncio +async def test_processing_success_swaps_to_thumbs_up( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PHOTON_REACTIONS", "true") + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.on_processing_complete( + _message_event(adapter), ProcessingOutcome.SUCCESS + ) + + assert [path for path, _ in calls] == ["/unreact", "/react"] + assert calls[1][1]["emoji"] == _THUMBS_UP + + +@pytest.mark.asyncio +async def test_processing_failure_swaps_to_thumbs_down( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PHOTON_REACTIONS", "true") + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.on_processing_complete( + _message_event(adapter), ProcessingOutcome.FAILURE + ) + + assert [path for path, _ in calls] == ["/unreact", "/react"] + assert calls[1][1]["emoji"] == _THUMBS_DOWN + + +@pytest.mark.asyncio +async def test_processing_cancelled_only_removes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PHOTON_REACTIONS", "true") + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.on_processing_complete( + _message_event(adapter), ProcessingOutcome.CANCELLED + ) + + assert [path for path, _ in calls] == ["/unreact"] + + +# -- Inbound reaction routing ------------------------------------------------ + +@pytest.mark.asyncio +async def test_inbound_reaction_on_bot_message_routed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_handled(adapter, monkeypatch) + + await adapter._dispatch_inbound(_reaction_event(emoji="❤️")) + + assert len(captured) == 1 + event = captured[0] + assert event.text == "reaction:added:❤️" + assert event.message_type == MessageType.TEXT + assert event.source.chat_id == "+15551234567" + + +@pytest.mark.asyncio +async def test_inbound_reaction_sent_ids_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No targetDirection from the provider — gate on our own sent-id cache.""" + adapter = _make_adapter(monkeypatch) + captured = _capture_handled(adapter, monkeypatch) + adapter._record_sent_message("bot-msg-1") + + await adapter._dispatch_inbound( + _reaction_event(target_id="bot-msg-1", target_direction=None) + ) + + assert len(captured) == 1 + + +@pytest.mark.asyncio +async def test_inbound_reaction_on_foreign_message_dropped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_handled(adapter, monkeypatch) + + await adapter._dispatch_inbound( + _reaction_event(target_id="someone-elses-msg", target_direction=None) + ) + + assert captured == [] + + +@pytest.mark.asyncio +async def test_inbound_reaction_bypasses_require_mention( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A tapback never carries a wake word — it must skip group gating.""" + monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true") + adapter = _make_adapter(monkeypatch) + captured = _capture_handled(adapter, monkeypatch) + + await adapter._dispatch_inbound(_reaction_event(space_type="group")) + + assert len(captured) == 1 From 6ee9156488af5e4287fc674c55003bf1766ecbd5 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Thu, 11 Jun 2026 02:49:11 -0700 Subject: [PATCH 3/9] fix(photon): stop gateway restarts from orphaning the sidecar on its port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hard gateway exit (crash, SIGKILL, supervisor restart) left the detached Node sidecar running with a token the next gateway run doesn't know, so it could never be told to /shutdown. Every replacement spawn then died on EADDRINUSE, failing each 30→300s reconnect attempt while the orphan kept consuming the inbound gRPC stream. Two layers: - Lifetime binding: the adapter now holds the sidecar's stdin as a pipe, and the sidecar (PHOTON_SIDECAR_WATCH_STDIN=1) shuts down on stdin EOF — fired by the OS on any parent death, including SIGKILL. - Startup reaping: before spawning, the adapter probes the port and terminates a stale listener, but only after verifying its command line is a Photon sidecar; a foreign listener raises a clear error instead of being signalled. Co-Authored-By: Claude Fable 5 --- plugins/platforms/photon/adapter.py | 105 +++++++++++ plugins/platforms/photon/sidecar/index.mjs | 20 ++ .../photon/test_sidecar_lifecycle.py | 171 ++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 tests/plugins/platforms/photon/test_sidecar_lifecycle.py diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 9673d58c9eb2..1b2d1bd24b90 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -611,21 +611,118 @@ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None: # -- Sidecar lifecycle ------------------------------------------------- + @staticmethod + def _find_listener_pids(port: int) -> List[int]: + """PIDs listening on a local TCP port (empty if none/undeterminable).""" + try: + out = subprocess.run( # noqa: S603, S607 + ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"], + capture_output=True, text=True, timeout=5.0, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + return [int(tok) for tok in out.stdout.split() if tok.strip().isdigit()] + + @staticmethod + def _pid_is_sidecar(pid: int) -> bool: + """True if ``pid``'s command line is a Photon sidecar process.""" + try: + out = subprocess.run( # noqa: S603, S607 + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, text=True, timeout=5.0, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + # Checkout-agnostic: any Hermes checkout's sidecar entry point. + return "photon/sidecar/index.mjs" in out.stdout + + @staticmethod + def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + async def _reap_stale_sidecar(self) -> None: + """Kill an orphaned sidecar squatting our port before spawning ours. + + A hard gateway exit (crash, SIGKILL, supervisor restart) used to leave + the detached sidecar running with a token the new gateway doesn't + know, so it can't be told to ``/shutdown`` — and every replacement + spawn died on EADDRINUSE, failing each reconnect attempt. The + stdin-EOF watch prevents new orphans; this reclaims the port from + orphans that predate it (or survived it). Listeners are verified by + command line before being signalled. + """ + if sys.platform == "win32": # lsof/ps; orphaning is a POSIX-only path + return + try: + async with httpx.AsyncClient(timeout=2.0) as client: + await client.post( + f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz", + headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, + ) + except httpx.RequestError: + return # nothing listening — the normal case + pids = self._find_listener_pids(self._sidecar_port) + stale = [pid for pid in pids if self._pid_is_sidecar(pid)] + foreign = [pid for pid in pids if pid not in stale] + if not stale: + raise RuntimeError( + f"port {self._sidecar_port} is in use by another process " + f"(pids: {foreign or 'unknown'}, not a Photon sidecar) — " + f"free it or set PHOTON_SIDECAR_PORT to a different port" + ) + for pid in stale: + logger.warning( + "[photon] reaping orphaned sidecar (pid %d) on port %d", + pid, self._sidecar_port, + ) + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + deadline = time.time() + 3.0 + while time.time() < deadline and any(self._pid_alive(p) for p in stale): + await asyncio.sleep(0.1) + for pid in stale: + if self._pid_alive(pid): + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + # Give the OS a beat to release the listening socket. + await asyncio.sleep(0.2) + if foreign: + raise RuntimeError( + f"port {self._sidecar_port} is also held by non-sidecar " + f"processes (pids: {foreign}) — free it or set " + f"PHOTON_SIDECAR_PORT to a different port" + ) + async def _start_sidecar(self) -> None: if not (_SIDECAR_DIR / "node_modules").exists(): raise RuntimeError( f"Photon sidecar deps not installed. Run: " f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)" ) + await self._reap_stale_sidecar() + env = os.environ.copy() env["PHOTON_PROJECT_ID"] = self._project_id env["PHOTON_PROJECT_SECRET"] = self._project_secret env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port) env["PHOTON_SIDECAR_BIND"] = self._sidecar_bind env["PHOTON_SIDECAR_TOKEN"] = self._sidecar_token + # The sidecar exits when its stdin (the pipe below) hits EOF, so a + # gateway death of ANY kind — including SIGKILL, where disconnect() + # never runs — can't leave it orphaned on the port. + env["PHOTON_SIDECAR_WATCH_STDIN"] = "1" self._sidecar_proc = subprocess.Popen( # noqa: S603 [self._node_bin, str(_SIDECAR_DIR / "index.mjs")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, @@ -682,6 +779,14 @@ async def _stop_sidecar(self) -> None: if proc is None: return try: + # Closing our end of the stdin pipe is itself a shutdown signal + # (the sidecar watches for EOF), and covers the case where the + # HTTP call below can't get through. + if proc.stdin is not None: + try: + proc.stdin.close() + except Exception: + pass # Polite shutdown first. if self._http_client is not None: try: diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index 91073f32b4a6..0ca723764a5f 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -48,6 +48,9 @@ // PHOTON_SIDECAR_TOKEN // Optional: // PHOTON_SIDECAR_BIND (default 127.0.0.1) +// PHOTON_SIDECAR_WATCH_STDIN "1" = exit when stdin hits EOF (set by the +// adapter, which holds our stdin pipe — parent-death +// detection so a dead gateway can't orphan us) // PHOTON_TELEMETRY enable Spectrum SDK telemetry ("true"/"1"/"on"/"yes"; // default off — toggle with `hermes photon telemetry`) @@ -642,7 +645,12 @@ server.listen(port, bind, () => { console.error(`photon-sidecar: listening on ${bind}:${port}`); }); +let stopping = false; async function shutdown(signal) { + // Re-entry guard: stdin EOF, a signal and /shutdown can all fire together + // during one teardown. + if (stopping) return; + stopping = true; console.error(`photon-sidecar: received ${signal}, stopping...`); try { await Promise.race([ @@ -659,6 +667,18 @@ async function shutdown(signal) { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); +// Lifetime binding to the parent. The adapter spawns us with stdin as a pipe +// it holds open; EOF means the gateway process is gone — including hard +// deaths (crash, SIGKILL) where no signal and no /shutdown ever reaches us. +// Without this, an orphaned sidecar squats the port and keeps consuming the +// inbound gRPC stream, and every replacement spawn dies on EADDRINUSE. +// Opt-in via env so manual `node index.mjs` runs aren't affected. +if (process.env.PHOTON_SIDECAR_WATCH_STDIN === "1") { + process.stdin.resume(); + process.stdin.on("end", () => shutdown("stdin EOF (parent exited)")); + process.stdin.on("error", () => shutdown("stdin error (parent exited)")); +} + // Don't let a stray promise rejection take the process down silently — handlers // catch their own errors, so log and keep serving (Python supervises restart on // a real fatal exit). diff --git a/tests/plugins/platforms/photon/test_sidecar_lifecycle.py b/tests/plugins/platforms/photon/test_sidecar_lifecycle.py new file mode 100644 index 000000000000..31b005c2488f --- /dev/null +++ b/tests/plugins/platforms/photon/test_sidecar_lifecycle.py @@ -0,0 +1,171 @@ +"""Sidecar lifecycle tests: orphan reaping and parent-death wiring. + +A hard gateway exit used to leave the detached Node sidecar squatting the +loopback port with a token the next gateway run doesn't know — every +replacement spawn then died on EADDRINUSE. These tests cover the startup +reaper (`_reap_stale_sidecar`) and the stdin-pipe lifetime binding, without +spawning Node or binding ports. +""" +from __future__ import annotations + +import subprocess +from typing import Any, Dict, List, Tuple + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.photon import adapter as photon_adapter +from plugins.platforms.photon.adapter import PhotonAdapter + + +def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter: + monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") + monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + cfg = PlatformConfig(enabled=True, token="", extra={}) + return PhotonAdapter(cfg) + + +class _ProbeClient: + """Fake httpx.AsyncClient whose /healthz probe behavior is injectable.""" + + connects = True + + def __init__(self, *a: Any, **k: Any) -> None: + pass + + async def __aenter__(self) -> "_ProbeClient": + return self + + async def __aexit__(self, *a: Any) -> bool: + return False + + async def post(self, *a: Any, **k: Any) -> Any: + if not self.connects: + raise photon_adapter.httpx.ConnectError("connection refused") + + class _Resp: + status_code = 401 # orphan with a different token + + return _Resp() + + +def _capture_kills(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[int, int]]: + kills: List[Tuple[int, int]] = [] + + def _fake_kill(pid: int, sig: int) -> None: + kills.append((pid, sig)) + + monkeypatch.setattr(photon_adapter.os, "kill", _fake_kill) + return kills + + +@pytest.mark.asyncio +async def test_reap_noop_when_port_free(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + + class _Refused(_ProbeClient): + connects = False + + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _Refused) + kills = _capture_kills(monkeypatch) + + await adapter._reap_stale_sidecar() + + assert kills == [] + + +@pytest.mark.asyncio +async def test_reap_kills_verified_orphan(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient) + monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242]) + monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True) + # Dies promptly on SIGTERM — no escalation expected. + monkeypatch.setattr(adapter, "_pid_alive", lambda pid: False) + kills = _capture_kills(monkeypatch) + + await adapter._reap_stale_sidecar() + + assert kills == [(4242, photon_adapter.signal.SIGTERM)] + + +@pytest.mark.asyncio +async def test_reap_escalates_to_sigkill(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient) + monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242]) + monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True) + monkeypatch.setattr(adapter, "_pid_alive", lambda pid: True) # ignores TERM + # No clock fakery (logging also calls time.time, which makes a fake clock + # fragile) — this test rides out the real 3s SIGTERM grace window. + kills = _capture_kills(monkeypatch) + + await adapter._reap_stale_sidecar() + + assert (4242, photon_adapter.signal.SIGTERM) in kills + assert (4242, photon_adapter.signal.SIGKILL) in kills + + +@pytest.mark.asyncio +async def test_reap_raises_for_foreign_listener( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Never signal a process whose command line isn't our sidecar.""" + adapter = _make_adapter(monkeypatch) + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient) + monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [777]) + monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: False) + kills = _capture_kills(monkeypatch) + + with pytest.raises(RuntimeError, match="in use by another process"): + await adapter._reap_stale_sidecar() + + assert kills == [] + + +@pytest.mark.asyncio +async def test_start_sidecar_spawns_with_stdin_pipe( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """The spawn must hold a stdin pipe and enable the sidecar's EOF watch.""" + adapter = _make_adapter(monkeypatch) + + async def _no_reap() -> None: + pass + + monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap) + (tmp_path / "node_modules").mkdir() + monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path) + + spawned: Dict[str, Any] = {} + + class _FakeProc: + pid = 999 + stdout = None + stdin = None + + @staticmethod + def poll() -> None: + return None + + def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc: + spawned["cmd"] = cmd + spawned["kwargs"] = kwargs + return _FakeProc() + + monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen) + + class _HealthyClient(_ProbeClient): + async def post(self, *a: Any, **k: Any) -> Any: + class _Resp: + status_code = 200 + + return _Resp() + + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient) + + await adapter._start_sidecar() + + kwargs = spawned["kwargs"] + assert kwargs["stdin"] is subprocess.PIPE + assert kwargs["env"]["PHOTON_SIDECAR_WATCH_STDIN"] == "1" From 26d11e210be2f4e6a8e7b317779c2f12eda3bba6 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Thu, 11 Jun 2026 12:35:21 -0700 Subject: [PATCH 4/9] chore(photon): bump spectrum-ts to 3.1.0 --- plugins/platforms/photon/sidecar/package-lock.json | 8 ++++---- plugins/platforms/photon/sidecar/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/platforms/photon/sidecar/package-lock.json b/plugins/platforms/photon/sidecar/package-lock.json index 76c44da5f02f..d76e7ccdf629 100644 --- a/plugins/platforms/photon/sidecar/package-lock.json +++ b/plugins/platforms/photon/sidecar/package-lock.json @@ -8,7 +8,7 @@ "name": "@hermes-agent/photon-sidecar", "version": "0.3.0", "dependencies": { - "spectrum-ts": "3.0.0" + "spectrum-ts": "3.1.0" }, "engines": { "node": ">=18.17" @@ -1420,9 +1420,9 @@ } }, "node_modules/spectrum-ts": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-3.0.0.tgz", - "integrity": "sha512-96XNXaEqohhTJfE/XL3+iNW9Pflc2jj7Xk5LLPthKEwOz6e6vdBh/KB5miAe2lQ+mkFtwEguVe2n4MVkBLcAtA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-3.1.0.tgz", + "integrity": "sha512-Dv5rsXATxGUXFnKf3VPK0VpkMPyVkf4HHUYkti0V2AKhz2m+ut3I1UPNMsvZOsiqmF+5hW8Xvrw+u/I82+XcDA==", "license": "MIT", "dependencies": { "@photon-ai/advanced-imessage": "^0.11.0", diff --git a/plugins/platforms/photon/sidecar/package.json b/plugins/platforms/photon/sidecar/package.json index 424752eccb2b..d09b3c82dcf5 100644 --- a/plugins/platforms/photon/sidecar/package.json +++ b/plugins/platforms/photon/sidecar/package.json @@ -12,7 +12,7 @@ "node": ">=18.17" }, "dependencies": { - "spectrum-ts": "3.0.0" + "spectrum-ts": "3.1.0" }, "overrides": { "protobufjs": "8.6.1", From 3e775faf47e522f70c028905ad851cd6287eb205 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Thu, 11 Jun 2026 13:28:43 -0700 Subject: [PATCH 5/9] fix(photon): use per-call httpx client in _sidecar_call Prevents "Future attached to a different loop" errors when _sidecar_call is invoked from a worker thread via _run_async in send_message_tool. The persistent _http_client remains in use for the inbound streaming loop, which always runs on the gateway's loop. --- plugins/platforms/photon/adapter.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 1b2d1bd24b90..2d7c9c3c6b21 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -1177,14 +1177,18 @@ async def _sidecar_send_attachment( return SendResult(success=True, message_id=data.get("messageId")) async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + # Guard: adapter not yet connected (no sidecar address known). if self._http_client is None: raise RuntimeError("Photon adapter not connected") - resp = await self._http_client.post( - f"http://{self._sidecar_bind}:{self._sidecar_port}{path}", - json=body, - headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, - timeout=30.0, - ) + # Use a fresh client per call so this method is safe when invoked from + # a worker thread that owns a different event loop than the one the + # persistent _http_client was created on (e.g. via _run_async in + # send_message_tool). The inbound streaming loop continues to use + # _http_client directly — it always runs on the gateway's loop. + url = f"http://{self._sidecar_bind}:{self._sidecar_port}{path}" + headers = {"X-Hermes-Sidecar-Token": self._sidecar_token} + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, json=body, headers=headers) if resp.status_code != 200: raise RuntimeError( f"Photon sidecar {path} returned {resp.status_code}: {resp.text[:200]}" From c892066c7a938d83db6bf07b4b31480b5dec25be Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Thu, 11 Jun 2026 14:16:01 -0700 Subject: [PATCH 6/9] feat(photon): add agent-facing emoji reaction support Add `action='react'` to `send_message` tool and expose `add_reaction`/ `remove_reaction` on the Photon adapter. - Track latest inbound message id per chat (`_last_inbound_by_chat`, bounded to 200 entries) so the agent can react without threading message ids through tool calls - New `add_reaction`/`remove_reaction` public methods on PhotonAdapter; unlike the lifecycle tapbacks, these are not gated by PHOTON_REACTIONS - `send_message` gains `action='react'` with `emoji` and optional `message_id` params; resolves target via existing channel-directory and home-channel logic; requires a live gateway adapter --- plugins/platforms/photon/adapter.py | 77 +++++++++++++++++++++++ tools/send_message_tool.py | 97 ++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 2d7c9c3c6b21..a934db3756ea 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -230,6 +230,10 @@ def __init__(self, config: PlatformConfig): # reaction events are only routed to the agent when they target one of # these — a tapback on a human↔human message is not addressed to us. self._sent_message_ids: Dict[str, float] = {} + # Latest inbound message id per chat (bounded). Lets the agent-facing + # react action default to "the message that triggered me" without + # requiring the model to thread message ids through tool calls. + self._last_inbound_by_chat: Dict[str, str] = {} # Group-chat mention gating (parity with BlueBubbles). When enabled, # group messages are ignored unless they match a wake word; DMs are @@ -538,6 +542,11 @@ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None: ) ) return + # Anything past here is a real (reactable) message — remember it as + # the chat's latest inbound so `add_reaction` can target it when the + # caller doesn't pass an explicit message id. Recorded before the + # mention gate: a reaction to a non-wake-word group message is valid. + self._record_last_inbound(space_id, event.get("messageId")) if ctype == "text": text = content.get("text") or "" mtype = MessageType.TEXT @@ -944,6 +953,7 @@ async def stop_typing(self, chat_id: str) -> None: # is a personal-texting channel, and a tapback on every text is noisy. _SENT_IDS_MAX = 1000 + _LAST_INBOUND_CHATS_MAX = 200 def _record_sent_message(self, message_id: Optional[str]) -> None: if not message_id: @@ -956,6 +966,21 @@ def _record_sent_message(self, message_id: Optional[str]) -> None: for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]: del sent[old] + def _record_last_inbound( + self, chat_id: Optional[str], message_id: Optional[str] + ) -> None: + if not chat_id or not message_id: + return + last = self._last_inbound_by_chat + if chat_id in last: + del last[chat_id] # refresh insertion order + last[chat_id] = message_id + if len(last) > self._LAST_INBOUND_CHATS_MAX: + for old in list(last.keys())[ + : len(last) - self._LAST_INBOUND_CHATS_MAX + ]: + del last[old] + def _reactions_enabled(self) -> bool: return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in { "true", "1", "yes", "on", @@ -991,6 +1016,58 @@ async def _remove_reaction(self, chat_id: str, message_id: str) -> bool: logger.debug("[photon] remove_reaction failed: %s", e) return False + # -- Agent-facing reactions (send_message action="react") --------------- + # + # Unlike the lifecycle hooks below, these are deliberate agent intents, + # so they are NOT gated by PHOTON_REACTIONS (that env var exists to mute + # the automatic per-message tapback noise, not explicit requests). + + async def add_reaction( + self, + chat_id: str, + emoji: str, + message_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Tapback ``emoji`` onto a message in ``chat_id``. + + Without ``message_id``, targets the chat's most recent inbound + message (typically the one the agent is responding to). iMessage + maps ❤️👍👎😂‼️❓ to native tapbacks; anything else uses Apple's + custom-emoji reaction. + """ + target = message_id or self._last_inbound_by_chat.get(chat_id) + if not target: + return { + "success": False, + "error": "no message to react to — pass message_id (no " + "inbound message seen in this chat since the gateway started)", + } + ok = await self._add_reaction(chat_id, target, emoji) + if not ok: + return { + "success": False, + "error": "reaction failed (see gateway debug log)", + } + return {"success": True, "message_id": target} + + async def remove_reaction( + self, chat_id: str, message_id: Optional[str] = None + ) -> Dict[str, Any]: + """Retract our tapback from a message (best-effort).""" + target = message_id or self._last_inbound_by_chat.get(chat_id) + if not target: + return { + "success": False, + "error": "no message to unreact — pass message_id", + } + ok = await self._remove_reaction(chat_id, target) + if not ok: + return { + "success": False, + "error": "unreact failed (see gateway debug log)", + } + return {"success": True, "message_id": target} + async def on_processing_start(self, event: MessageEvent) -> None: """Tapback 👀 on the triggering message while the agent works.""" if not self._reactions_enabled(): diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index afa473e384bc..0c7137338719 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -138,8 +138,8 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs) "properties": { "action": { "type": "string", - "enum": ["send", "list"], - "description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms." + "enum": ["send", "list", "react"], + "description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms. 'react' attaches an emoji reaction to a message (platforms that support it, e.g. photon/iMessage tapbacks)." }, "target": { "type": "string", @@ -148,6 +148,14 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs) "message": { "type": "string", "description": "The message text to send. To send an image or file, include MEDIA: (e.g. 'MEDIA:/tmp/report.pdf') in the message — the platform will deliver it as a native media attachment." + }, + "emoji": { + "type": "string", + "description": "For action='react': the emoji to react with (e.g. '❤️'). On iMessage, ❤️👍👎😂‼️❓ render as native tapbacks; other emoji use custom-emoji reactions." + }, + "message_id": { + "type": "string", + "description": "For action='react': id of the message to react to. Omit to react to the most recent message received in that chat (usually the one being replied to)." } }, "required": [] @@ -162,6 +170,9 @@ def send_message_tool(args, **kw): if action == "list": return _handle_list() + if action == "react": + return _handle_react(args) + return _handle_send(args) @@ -174,6 +185,88 @@ def _handle_list(): return json.dumps(_error(f"Failed to load channel directory: {e}")) +def _handle_react(args): + """Attach an emoji reaction to a message via a live gateway adapter. + + Only adapters that expose an ``add_reaction(chat_id, emoji, message_id)`` + coroutine support this (e.g. photon/iMessage tapbacks). Requires the + gateway to be running in this process — there is no standalone fallback, + since reacting needs the adapter's live message-id state. + """ + target = args.get("target", "") + emoji = (args.get("emoji") or "").strip() + message_id = (args.get("message_id") or "").strip() or None + if not target or not emoji: + return tool_error( + "Both 'target' and 'emoji' are required when action='react'" + ) + + parts = target.split(":", 1) + platform_name = parts[0].strip().lower() + target_ref = parts[1].strip() if len(parts) > 1 else None + chat_id = None + if target_ref: + chat_id, _thread_id, _ = _parse_target_ref(platform_name, target_ref) + if not chat_id: + try: + from gateway.channel_directory import resolve_channel_name + resolved = resolve_channel_name(platform_name, target_ref) + except Exception: + resolved = None + # Opaque platform-native ids (e.g. photon space GUIDs like + # 'any;-;+1555...') match no parser pattern and no directory + # entry — pass them through verbatim; the adapter validates. + chat_id = resolved or target_ref + + try: + from gateway.config import Platform, load_gateway_config + platform = Platform(platform_name) + except (ValueError, KeyError): + return tool_error(f"Unknown platform: {platform_name}") + + if not chat_id: + try: + config = load_gateway_config() + home = config.get_home_channel(platform) + except Exception: + home = None + if not home: + return tool_error( + f"No chat specified and no home channel set for {platform_name}. " + f"Use '{platform_name}:chat_id'." + ) + chat_id = home.chat_id + + runner = None + try: + from gateway.run import _gateway_runner_ref + runner = _gateway_runner_ref() + except Exception: + runner = None + adapter = runner.adapters.get(platform) if runner is not None else None + if adapter is None: + return tool_error( + f"Reactions require a live {platform_name} adapter in the running " + "gateway (not available from cron/standalone contexts)." + ) + react_fn = getattr(adapter, "add_reaction", None) + if not callable(react_fn): + return tool_error( + f"Platform '{platform_name}' does not support message reactions." + ) + + try: + from model_tools import _run_async + result = _run_async( + react_fn(chat_id=chat_id, emoji=emoji, message_id=message_id) + ) + except Exception as e: + return json.dumps(_error(f"Reaction failed: {e}")) + if isinstance(result, dict): + return json.dumps(result) + return json.dumps({"success": bool(result)}) + + def _handle_send(args): """Send a message to a platform target.""" target = args.get("target", "") From 2c9337757ab3a430bc276beabf1e52d32c03c665 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Thu, 11 Jun 2026 14:20:34 -0700 Subject: [PATCH 7/9] fix(photon): normalize DM chat keys in last-inbound reaction tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound events key the tracker by the DM chat GUID (any;-;+1555...), but home-channel react calls address the same space by bare E.164 — normalize both to the phone so add_reaction's last-inbound default resolves regardless of which form the caller uses (mirrors the sidecar's phoneTargetFromSpaceId). Co-Authored-By: Claude Fable 5 --- plugins/platforms/photon/adapter.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index a934db3756ea..43a6953f3617 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -966,15 +966,28 @@ def _record_sent_message(self, message_id: Optional[str]) -> None: for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]: del sent[old] + # A DM space is addressable two ways — the chat GUID (`any;-;+1555...`) + # that inbound events carry, and the bare E.164 phone that home-channel + # config typically uses. The sidecar's resolveSpace treats them as the + # same space; normalize to the bare phone so the last-inbound tracker + # does too (mirrors phoneTargetFromSpaceId in sidecar/index.mjs). + _DM_CHAT_GUID_RE = re.compile(r"^any;-;(\+\d{6,})$") + + @classmethod + def _normalize_chat_key(cls, chat_id: str) -> str: + match = cls._DM_CHAT_GUID_RE.match(chat_id) + return match.group(1) if match else chat_id + def _record_last_inbound( self, chat_id: Optional[str], message_id: Optional[str] ) -> None: if not chat_id or not message_id: return + key = self._normalize_chat_key(chat_id) last = self._last_inbound_by_chat - if chat_id in last: - del last[chat_id] # refresh insertion order - last[chat_id] = message_id + if key in last: + del last[key] # refresh insertion order + last[key] = message_id if len(last) > self._LAST_INBOUND_CHATS_MAX: for old in list(last.keys())[ : len(last) - self._LAST_INBOUND_CHATS_MAX @@ -1035,7 +1048,9 @@ async def add_reaction( maps ❤️👍👎😂‼️❓ to native tapbacks; anything else uses Apple's custom-emoji reaction. """ - target = message_id or self._last_inbound_by_chat.get(chat_id) + target = message_id or self._last_inbound_by_chat.get( + self._normalize_chat_key(chat_id) + ) if not target: return { "success": False, @@ -1054,7 +1069,9 @@ async def remove_reaction( self, chat_id: str, message_id: Optional[str] = None ) -> Dict[str, Any]: """Retract our tapback from a message (best-effort).""" - target = message_id or self._last_inbound_by_chat.get(chat_id) + target = message_id or self._last_inbound_by_chat.get( + self._normalize_chat_key(chat_id) + ) if not target: return { "success": False, From 939fa3983b9a08d93e896ce10a007302f9b3f13b Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Thu, 11 Jun 2026 14:28:47 -0700 Subject: [PATCH 8/9] fix(photon): add clarifying comments for Windows-safe os.kill usage --- plugins/platforms/photon/adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 43a6953f3617..e5dfd358ed61 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -143,7 +143,7 @@ def _env_enablement() -> Optional[dict]: project_id, project_secret = load_project_credentials() if not (project_id and project_secret): return None - seed = {"project_id": project_id, "project_secret": project_secret} + seed: dict = {"project_id": project_id, "project_secret": project_secret} home = os.getenv("PHOTON_HOME_CHANNEL", "").strip() if home: seed["home_channel"] = { @@ -648,7 +648,7 @@ def _pid_is_sidecar(pid: int) -> bool: @staticmethod def _pid_alive(pid: int) -> bool: try: - os.kill(pid, 0) + os.kill(pid, 0) # windows-footgun: ok — only called from _reap_stale_sidecar which win32-guards early return True except OSError: return False @@ -698,7 +698,7 @@ async def _reap_stale_sidecar(self) -> None: for pid in stale: if self._pid_alive(pid): try: - os.kill(pid, signal.SIGKILL) + os.kill(pid, signal.SIGKILL) # windows-footgun: ok — unreachable on win32 (early return above) except OSError: pass # Give the OS a beat to release the listening socket. From f22a49a2380a0fd7903ac87d7e357e97ac0cc7d1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:36:53 -0700 Subject: [PATCH 9/9] feat(messaging): expose action='unreact' in send_message + react dispatch tests Follow-up for salvaged PR #44486: the adapter shipped remove_reaction but the tool only exposed 'react'. Generalize _handle_react(remove=) and add tool-level dispatch tests for react/unreact (missing from the original PR). --- tests/tools/test_send_message_react.py | 97 ++++++++++++++++++++++++++ tools/send_message_tool.py | 41 +++++++---- 2 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 tests/tools/test_send_message_react.py diff --git a/tests/tools/test_send_message_react.py b/tests/tools/test_send_message_react.py new file mode 100644 index 000000000000..dd78e5f2ae58 --- /dev/null +++ b/tests/tools/test_send_message_react.py @@ -0,0 +1,97 @@ +"""Tests for send_message action='react'/'unreact' dispatch. + +Kept separate from ``test_send_message_tool.py`` because that module skips +wholesale when optional Telegram dependencies are not installed. +""" + +import json +from types import SimpleNamespace +from unittest.mock import patch + +import tools.send_message_tool as smt + + +class _FakePhotonAdapter: + """Adapter exposing add_reaction/remove_reaction coroutines.""" + + def __init__(self): + self.calls = [] + + async def add_reaction(self, chat_id, emoji, message_id=None): + self.calls.append(("add", chat_id, emoji, message_id)) + return {"success": True, "emoji": emoji} + + async def remove_reaction(self, chat_id, message_id=None): + self.calls.append(("remove", chat_id, message_id)) + return {"success": True} + + +class _NoReactionAdapter: + """Adapter with no reaction support at all.""" + + +def _runner_with(adapter): + from gateway.config import Platform + + return SimpleNamespace(adapters={Platform("photon"): adapter}) + + +def _call(args): + return json.loads(smt.send_message_tool(args)) + + +def test_react_dispatches_to_add_reaction(): + adapter = _FakePhotonAdapter() + with patch("gateway.run._gateway_runner_ref", lambda: _runner_with(adapter)): + result = _call( + {"action": "react", "target": "photon:+15551234567", "emoji": "❤️"} + ) + assert result["success"] is True + assert adapter.calls == [("add", "+15551234567", "❤️", None)] + + +def test_unreact_dispatches_to_remove_reaction(): + adapter = _FakePhotonAdapter() + with patch("gateway.run._gateway_runner_ref", lambda: _runner_with(adapter)): + result = _call( + { + "action": "unreact", + "target": "photon:+15551234567", + "message_id": "msg-9", + } + ) + assert result["success"] is True + assert adapter.calls == [("remove", "+15551234567", "msg-9")] + + +def test_react_requires_emoji(): + result = _call({"action": "react", "target": "photon:+15551234567"}) + assert result.get("success") is not True + assert "emoji" in json.dumps(result) + + +def test_unreact_does_not_require_emoji(): + adapter = _FakePhotonAdapter() + with patch("gateway.run._gateway_runner_ref", lambda: _runner_with(adapter)): + result = _call({"action": "unreact", "target": "photon:+15551234567"}) + assert result["success"] is True + assert adapter.calls == [("remove", "+15551234567", None)] + + +def test_react_unsupported_platform_adapter(): + adapter = _NoReactionAdapter() + with patch("gateway.run._gateway_runner_ref", lambda: _runner_with(adapter)): + result = _call( + {"action": "react", "target": "photon:+15551234567", "emoji": "👍"} + ) + assert result.get("success") is not True + assert "does not support" in json.dumps(result) + + +def test_react_without_live_gateway(): + with patch("gateway.run._gateway_runner_ref", lambda: None): + result = _call( + {"action": "react", "target": "photon:+15551234567", "emoji": "👍"} + ) + assert result.get("success") is not True + assert "live" in json.dumps(result) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 0c7137338719..a37f9eb62a29 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -138,8 +138,8 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs) "properties": { "action": { "type": "string", - "enum": ["send", "list", "react"], - "description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms. 'react' attaches an emoji reaction to a message (platforms that support it, e.g. photon/iMessage tapbacks)." + "enum": ["send", "list", "react", "unreact"], + "description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms. 'react' attaches an emoji reaction to a message (platforms that support it, e.g. photon/iMessage tapbacks). 'unreact' retracts a previously-added reaction." }, "target": { "type": "string", @@ -155,7 +155,7 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs) }, "message_id": { "type": "string", - "description": "For action='react': id of the message to react to. Omit to react to the most recent message received in that chat (usually the one being replied to)." + "description": "For action='react'/'unreact': id of the message to react to. Omit to target the most recent message received in that chat (usually the one being replied to)." } }, "required": [] @@ -173,6 +173,9 @@ def send_message_tool(args, **kw): if action == "react": return _handle_react(args) + if action == "unreact": + return _handle_react(args, remove=True) + return _handle_send(args) @@ -185,20 +188,24 @@ def _handle_list(): return json.dumps(_error(f"Failed to load channel directory: {e}")) -def _handle_react(args): - """Attach an emoji reaction to a message via a live gateway adapter. +def _handle_react(args, remove=False): + """Attach (or with ``remove=True`` retract) an emoji reaction on a message + via a live gateway adapter. - Only adapters that expose an ``add_reaction(chat_id, emoji, message_id)`` - coroutine support this (e.g. photon/iMessage tapbacks). Requires the - gateway to be running in this process — there is no standalone fallback, - since reacting needs the adapter's live message-id state. + Only adapters that expose ``add_reaction(chat_id, emoji, message_id)`` / + ``remove_reaction(chat_id, message_id)`` coroutines support this (e.g. + photon/iMessage tapbacks). Requires the gateway to be running in this + process — there is no standalone fallback, since reacting needs the + adapter's live message-id state. """ target = args.get("target", "") emoji = (args.get("emoji") or "").strip() message_id = (args.get("message_id") or "").strip() or None - if not target or not emoji: + if not target or (not remove and not emoji): return tool_error( "Both 'target' and 'emoji' are required when action='react'" + if not remove + else "'target' is required when action='unreact'" ) parts = target.split(":", 1) @@ -249,7 +256,8 @@ def _handle_react(args): f"Reactions require a live {platform_name} adapter in the running " "gateway (not available from cron/standalone contexts)." ) - react_fn = getattr(adapter, "add_reaction", None) + fn_name = "remove_reaction" if remove else "add_reaction" + react_fn = getattr(adapter, fn_name, None) if not callable(react_fn): return tool_error( f"Platform '{platform_name}' does not support message reactions." @@ -257,9 +265,14 @@ def _handle_react(args): try: from model_tools import _run_async - result = _run_async( - react_fn(chat_id=chat_id, emoji=emoji, message_id=message_id) - ) + if remove: + result = _run_async( + react_fn(chat_id=chat_id, message_id=message_id) + ) + else: + result = _run_async( + react_fn(chat_id=chat_id, emoji=emoji, message_id=message_id) + ) except Exception as e: return json.dumps(_error(f"Reaction failed: {e}")) if isinstance(result, dict):