diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index f78be5d41d9..af885cc6104 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 78902234b1b..9673d58c9eb 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -58,6 +58,7 @@ from gateway.platforms.base import ( 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 @@ class PhotonAdapter(BasePlatformAdapter): ).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 @@ class PhotonAdapter(BasePlatformAdapter): # 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 @@ class PhotonAdapter(BasePlatformAdapter): "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 @@ class PhotonAdapter(BasePlatformAdapter): 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 @@ class PhotonAdapter(BasePlatformAdapter): 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 @@ class PhotonAdapter(BasePlatformAdapter): 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 @@ class PhotonAdapter(BasePlatformAdapter): 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 @@ class PhotonAdapter(BasePlatformAdapter): ) 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 @@ class PhotonAdapter(BasePlatformAdapter): 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 f93b33f2c95..5e93f76b670 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 c9e149cbca4..a39193a81bf 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 065e90d84cb..91073f32b4a 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 8a19d1445dd..76c44da5f02 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 522335e46b1..424752eccb2 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 00000000000..6e803d65317 --- /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 00000000000..78789bd1469 --- /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