From cf550c086396d6baa90ce7ff7ca96f7dd1527778 Mon Sep 17 00:00:00 2001 From: huntsyea Date: Sun, 28 Jun 2026 23:50:32 -0500 Subject: [PATCH] feat(photon): support rich link previews --- .../emails/hunter.c.yeagley@outlook.com | 2 + plugins/platforms/photon/README.md | 26 +- plugins/platforms/photon/adapter.py | 262 ++++++- plugins/platforms/photon/sidecar/README.md | 18 +- plugins/platforms/photon/sidecar/index.mjs | 39 ++ .../platforms/photon/test_rich_links.py | 644 ++++++++++++++++++ .../platforms/photon/test_spectrum_patch.py | 9 + 7 files changed, 955 insertions(+), 45 deletions(-) create mode 100644 contributors/emails/hunter.c.yeagley@outlook.com create mode 100644 tests/plugins/platforms/photon/test_rich_links.py diff --git a/contributors/emails/hunter.c.yeagley@outlook.com b/contributors/emails/hunter.c.yeagley@outlook.com new file mode 100644 index 000000000000..528d1c518b27 --- /dev/null +++ b/contributors/emails/hunter.c.yeagley@outlook.com @@ -0,0 +1,2 @@ +huntsyea +# PR #54646 salvage diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index 53908f05b52b..39d42295f885 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -23,7 +23,8 @@ talks to it over loopback. │ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │ └─────────────────────────┘ └──────────┬───────────┘ GET /inbound (NDJSON) │ ▲ POST /send - inbound events ▼ │ /typing + inbound events ▼ │ /send-richlink + │ │ /typing ┌──────────────────────┐ │ PhotonAdapter │ │ (Python, in gateway) │ @@ -36,8 +37,9 @@ talks to it over loopback. a `MessageEvent` to the gateway. It reconnects automatically if the stream drops; the sidecar owns the gRPC reconnect to Photon. - **Outbound**: `send` / `send_typing` / reaction tapbacks are loopback POSTs - to the sidecar (`/send`, `/send-attachment`, `/typing`, `/react`, - `/unreact`), authenticated with a shared `X-Hermes-Sidecar-Token`. + to the sidecar (`/send`, `/send-richlink`, `/send-attachment`, `/typing`, + `/react`, `/unreact`), authenticated with a shared + `X-Hermes-Sidecar-Token`. ## First-time setup @@ -137,15 +139,23 @@ All env vars are documented in `plugin.yaml`. The most important: Media larger than `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that fails, falls back to a text marker (`[Photon attachment received: …]` or `[Photon voice received: …]`) so the agent still knows - something arrived. + something arrived. If Spectrum emits a `richlink` content object, Hermes + preserves its URL plus any title/summary metadata Spectrum already exposed; + current Spectrum versions may still deliver ordinary inbound links as plain + `text`. iMessage may also emit rich-link preview artwork as + `.pluginPayloadAttachment` images immediately after the URL; Hermes coalesces + those artifacts so the agent receives one link message instead of a follow-up + `(attachment)` prompt. - **Outbound attachments are supported.** Images, voice notes, video, and documents are sent via `space.send(attachment(...))` / `space.send(voice(...))` through the sidecar's `/send-attachment` endpoint; a caption is delivered as a separate text bubble after the media. - **Markdown is rendered.** Replies go out via spectrum-ts' `markdown()` builder; iMessage renders bold/italics/lists/code natively and other - Spectrum platforms degrade to readable plain text. `PHOTON_MARKDOWN=false` - reverts to stripped plain text. + Spectrum platforms degrade to readable plain text. URL-only replies go out + via spectrum-ts' `richlink()` builder so iMessage can render a native link + preview card. `PHOTON_MARKDOWN=false` reverts to stripped plain text and + disables rich-link routing. - **Reactions (tapbacks) are supported** behind `PHOTON_REACTIONS` (default off): the adapter tapbacks 👀 while processing and swaps it for 👍/👎 on completion, and a user tapback on a bot-sent message is routed to the agent @@ -173,8 +183,8 @@ All env vars are documented in `plugin.yaml`. The most important: (no `^` range) and installed with `npm ci`, because the SDK ships breaking majors (v2 removed `defineFusorPlatform`; v3 reworked space construction; v5 split it into `@spectrum-ts/*` packages, with `spectrum-ts` as the umbrella -that re-exports them; v8 made `richlink` outbound-only, so inbound rich links -now arrive as plain `text`). A floating range or `npm install spectrum-ts@latest` +that re-exports them; v8 made `richlink` primarily outbound, so many inbound +links now arrive as plain `text`). A floating range or `npm install spectrum-ts@latest` would let a breaking release take down fresh setups silently. Upgrades are deliberate: diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index b8539c6a41a7..219c2f2c1dca 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -16,10 +16,11 @@ Inbound: Outbound: ``send`` / ``send_typing`` are loopback POSTs to the sidecar's control - endpoints, authenticated with a shared bearer token. Outbound media - (images, voice notes, video, documents) goes through spectrum-ts' - ``attachment()`` / ``voice()`` content builders via the sidecar's - ``/send-attachment`` endpoint. + endpoints, authenticated with a shared bearer token. URL-only messages can + route through spectrum-ts' ``richlink()`` builder via ``/send-richlink``. + Outbound media (images, voice notes, video, documents) goes through + spectrum-ts' ``attachment()`` / ``voice()`` content builders via the + sidecar's ``/send-attachment`` endpoint. """ from __future__ import annotations @@ -38,6 +39,7 @@ import time from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional +from urllib.parse import urlparse if TYPE_CHECKING: # Type checkers see ``httpx`` as the always-imported module, so every use @@ -205,6 +207,12 @@ _PHOTON_RETRYABLE_PATTERNS = ( "upstream_unavailable", ) +# iMessage may emit the Open Graph preview art for a rich link as one or more +# image attachments immediately after the URL/richlink message. Suppress those +# artifacts so Hermes sees the link once, not a follow-up "(attachment)" prompt. +_RICHLINK_PREVIEW_SUPPRESS_SECONDS = 30.0 +_RICHLINK_PREVIEW_ATTACHMENT_SUFFIX = ".pluginpayloadattachment" + # Minimum seconds between typing-indicator calls for the same chat. # iMessage is a personal channel — suppressing rapid repeats reduces # upstream gRPC pressure during Photon overflow events. @@ -471,9 +479,117 @@ def _markdown_enabled() -> bool: } +def _url_only_candidate(text: str) -> Optional[str]: + candidate = (text or "").strip() + if not re.fullmatch(r"https?://\S+", candidate, flags=re.IGNORECASE): + return None + try: + parsed = urlparse(candidate) + except ValueError: + return None + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + return None + return candidate + + +def _richlink_candidate(text: str) -> Optional[str]: + """Return a URL that should be sent via spectrum-ts ``richlink()``. + + Keep this intentionally narrow: only exact http(s) URL messages become + rich links. Prose containing URLs and Markdown links stay on the normal + markdown/text path so Hermes does not drop labels or rewrite intent. + """ + if not _markdown_enabled(): + return None + return _url_only_candidate(text) + + +def _format_richlink_content(content: Dict[str, Any]) -> str: + url = str(content.get("url") or "").strip() + title = str(content.get("title") or "").strip() + summary = str(content.get("summary") or "").strip() + parts: List[str] = [] + if title: + parts.append(title) + if summary and summary != title: + parts.append(summary) + if url: + parts.append(url) + return "\n".join(parts) if parts else "[Photon rich link received with no URL]" + + +def _richlink_url_from_content(content: Dict[str, Any]) -> Optional[str]: + ctype = content.get("type") + if ctype == "text": + return _url_only_candidate(content.get("text") or "") + if ctype == "richlink": + return _url_only_candidate(content.get("url") or "") + if ctype == "group": + for item in content.get("items") or []: + if not isinstance(item, dict): + continue + item_content = item.get("content") or {} + if isinstance(item_content, dict): + url = _richlink_url_from_content(item_content) + if url: + return url + return None + + +def _is_richlink_preview_attachment(payload: Dict[str, Any]) -> bool: + if payload.get("type") != "attachment": + return False + name = str(payload.get("name") or "").lower() + attachment_id = str(payload.get("id") or "").lower() + is_preview_payload = ( + name.endswith(_RICHLINK_PREVIEW_ATTACHMENT_SUFFIX) + or attachment_id.endswith(_RICHLINK_PREVIEW_ATTACHMENT_SUFFIX) + or _RICHLINK_PREVIEW_ATTACHMENT_SUFFIX in name + or _RICHLINK_PREVIEW_ATTACHMENT_SUFFIX in attachment_id + ) + # Live iMessage rich-link preview art can arrive with this marker but an + # opaque document MIME such as application/octet-stream. The marker is the + # reliable signal; the recent-link window guards real attachments. + return is_preview_payload + + +def _richlink_preview_label(content: Dict[str, Any]) -> str: + if content.get("type") == "attachment": + return str(content.get("name") or content.get("id") or "(unnamed)") + if content.get("type") == "group": + labels = [] + for item in content.get("items") or []: + item_content = item.get("content") if isinstance(item, dict) else None + if isinstance(item_content, dict): + labels.append( + str(item_content.get("name") or item_content.get("id") or "(unnamed)") + ) + return ", ".join(labels) or "(group)" + return "(unknown)" + + +def _is_richlink_preview_content(content: Dict[str, Any]) -> bool: + if _is_richlink_preview_attachment(content): + return True + if content.get("type") != "group": + return False + items = content.get("items") or [] + if not items: + return False + for item in items: + if not isinstance(item, dict): + return False + item_content = item.get("content") or {} + if not isinstance(item_content, dict): + return False + if not _is_richlink_preview_attachment(item_content): + return False + return True + # --------------------------------------------------------------------------- # Adapter + class PhotonAdapter(BasePlatformAdapter): """Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar. @@ -545,6 +661,10 @@ class PhotonAdapter(BasePlatformAdapter): # 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] = {} + # Latest inbound URL/richlink per chat. iMessage can send rich-link + # preview artwork as separate image attachments immediately after the + # URL bubble; this lets us coalesce those artifacts. + self._recent_richlinks_by_chat: Dict[str, float] = {} # Last time we sent a typing indicator per chat, for cooldown gating. self._typing_last_sent: Dict[str, float] = {} @@ -1040,6 +1160,15 @@ class PhotonAdapter(BasePlatformAdapter): prev[1].cancel() logger.debug("[photon] attachment arrived — cancelling U+FFFC timeout") + # Preview art for an immediately preceding URL/richlink should not + # become a second user prompt. Suppress before recording it as the + # latest reactable inbound or decoding/caching image bytes. + if self._is_recent_richlink_preview(space_id, content): + logger.info( + "[photon] suppressing rich-link preview attachment: %s", + _richlink_preview_label(content), + ) + 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 @@ -1081,6 +1210,9 @@ class PhotonAdapter(BasePlatformAdapter): mtype = MessageType.TEXT elif ctype in {"attachment", "voice"}: text, mtype, media_urls, media_types = _normalize_binary_payload(content) + elif ctype == "richlink": + text = _format_richlink_content(content) + mtype = MessageType.TEXT elif ctype == "group": text_parts: List[str] = [] mtype = MessageType.TEXT @@ -1096,6 +1228,9 @@ class PhotonAdapter(BasePlatformAdapter): if item_text: text_parts.append(item_text) continue + if item_type == "richlink": + text_parts.append(_format_richlink_content(item_content)) + continue if item_type in {"attachment", "voice"}: marker, item_mtype, item_urls, item_types = _normalize_binary_payload( item_content @@ -1131,6 +1266,8 @@ class PhotonAdapter(BasePlatformAdapter): return text = self._clean_mention_text(text) + self._record_recent_richlink(space_id, _richlink_url_from_content(content) or text) + source = self.build_source( chat_id=space_id, chat_name=space_id, @@ -1727,6 +1864,32 @@ class PhotonAdapter(BasePlatformAdapter): ]: del last[old] + def _record_recent_richlink(self, chat_id: str, text: str) -> None: + if not chat_id or not _url_only_candidate(text): + return + key = self._normalize_chat_key(chat_id) + recent = self._recent_richlinks_by_chat + if key in recent: + del recent[key] # refresh insertion order + recent[key] = time.time() + if len(recent) > self._LAST_INBOUND_CHATS_MAX: + for old in list(recent.keys())[ + : len(recent) - self._LAST_INBOUND_CHATS_MAX + ]: + del recent[old] + + def _is_recent_richlink_preview(self, chat_id: str, content: Dict[str, Any]) -> bool: + if not chat_id or not _is_richlink_preview_content(content): + return False + key = self._normalize_chat_key(chat_id) + last = self._recent_richlinks_by_chat.get(key) + if last is None: + return False + if time.time() - last > _RICHLINK_PREVIEW_SUPPRESS_SECONDS: + self._recent_richlinks_by_chat.pop(key, None) + return False + return True + def _reactions_enabled(self) -> bool: return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in { "true", "1", "yes", "on", @@ -1957,23 +2120,52 @@ class PhotonAdapter(BasePlatformAdapter): "[photon] Failed to deliver response after %d retries: %s", max_retries, error_str, ) - return result + # Fall through to the plain-text fallback below. For URL-only + # responses this bypasses ``richlink()`` so a rich-link-specific + # outage or sidecar skew does not strand an otherwise sendable URL. logger.warning( "[photon] Send failed: %s - retrying plain-text message", error_str, ) - fallback_result = await self.send( - chat_id=chat_id, - content=text[: self.MAX_MESSAGE_LENGTH], - reply_to=reply_to, - metadata=metadata, + fallback_result = await self._sidecar_send( + chat_id, + text[: self.MAX_MESSAGE_LENGTH], + richlink=False, + markdown=False, ) if not fallback_result.success: logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error) return fallback_result - async def _sidecar_send(self, space_id: str, text: str) -> SendResult: + async def _sidecar_send_richlink(self, space_id: str, url: str) -> SendResult: + try: + data = await self._sidecar_call( + "/send-richlink", {"spaceId": space_id, "url": url} + ) + 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( + self, + space_id: str, + text: str, + *, + richlink: bool = True, + markdown: bool = True, + ) -> SendResult: + rich_url = _richlink_candidate(text) if richlink else None + if rich_url: + rich_result = await self._sidecar_send_richlink(space_id, rich_url) + if rich_result.success: + return rich_result + logger.warning( + "[photon] rich-link send failed, falling back to plain text: %s", + rich_result.error, + ) + markdown = False if len(text) > self.MAX_MESSAGE_LENGTH: logger.warning( "[photon] truncating outbound from %d to %d chars", @@ -1983,7 +2175,7 @@ class PhotonAdapter(BasePlatformAdapter): 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(): + if markdown and _markdown_enabled(): body["format"] = "markdown" try: data = await self._sidecar_call("/send", body) @@ -2282,21 +2474,37 @@ async def _standalone_send( async with httpx.AsyncClient(timeout=30.0, trust_env=False) 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=send_body, headers=headers, - ) - if resp.status_code != 200: - return _standalone_error(resp) - data = resp.json() or {} - if not data.get("ok"): - return _standalone_error(resp) - last_message_id = data.get("messageId") + rich_url = _richlink_candidate(message) + if rich_url: + resp = await client.post( + f"{base}/send-richlink", + json={"spaceId": chat_id, "url": rich_url}, + headers=headers, + ) + if resp.status_code == 200: + data = resp.json() or {} + if data.get("ok"): + last_message_id = data.get("messageId") + else: + rich_url = None + else: + rich_url = None + if not rich_url: + send_body: Dict[str, Any] = { + "spaceId": chat_id, + "text": message[:_MAX_MESSAGE_LENGTH], + } + if _markdown_enabled() and not _richlink_candidate(message): + send_body["format"] = "markdown" + resp = await client.post( + f"{base}/send", json=send_body, headers=headers, + ) + if resp.status_code != 200: + return _standalone_error(resp) + data = resp.json() or {} + if not data.get("ok"): + return _standalone_error(resp) + last_message_id = data.get("messageId") # 2. Each attachment as a separate /send-attachment call. # media_files is List[Tuple[path, is_voice]] (see diff --git a/plugins/platforms/photon/sidecar/README.md b/plugins/platforms/photon/sidecar/README.md index eb5c2509424a..67d332a8427d 100644 --- a/plugins/platforms/photon/sidecar/README.md +++ b/plugins/platforms/photon/sidecar/README.md @@ -10,8 +10,8 @@ The sidecar: - exposes a loopback-only HTTP control channel for the Python adapter to push send/typing requests (auth via `X-Hermes-Sidecar-Token`) - drains the inbound message stream so `spectrum-ts` keeps its - reconnect/heartbeat machinery alive (real inbound delivery is via - Photon's signed webhook hitting our Python aiohttp server) + reconnect/heartbeat machinery alive and Hermes can receive inbound messages + over the adapter's loopback `GET /inbound` stream ## Install @@ -39,14 +39,12 @@ it by hand. ## Why a sidecar at all? -Photon publishes webhooks (inbound) but their docs state explicitly: - -> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts` -> SDK instance to reply. No public HTTP send endpoint exists today. - -— https://photon.codes/docs/webhooks/events +Photon's Spectrum send path is exposed through the TypeScript SDK's +`Space.send(...)` API. Hermes is Python, so replies go through this sidecar +until Photon ships a public HTTP send endpoint. When Photon ships an HTTP send endpoint, the plan is to retire this sidecar entirely and call it directly from Python. The plugin's -outbound code path is already isolated behind a single helper -(`_sidecar_send` in `adapter.py`) to make that swap a one-file change. +outbound code path is already isolated behind small helpers +(`_sidecar_send`, `_sidecar_send_richlink`, and `_sidecar_send_attachment` in +`adapter.py`) to make that swap localized. diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index 7491bf214be2..43c2c9b82d01 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -21,6 +21,8 @@ // - POST /send -> {"ok": true, "messageId": "..."} // body: {"spaceId": "...", "text": "...", // "format": "text" | "markdown" (default "text")} +// - POST /send-richlink -> {"ok": true, "messageId": "..."} +// body: {"spaceId": "...", "url": "https://..."} // - POST /send-attachment -> {"ok": true, "messageId": "..."} // body: {"spaceId": "...", "path": "...", "name": "..." | null, // "mimeType": "..." | null, "caption": "..." | null, @@ -243,6 +245,7 @@ let Spectrum, voice, spectrumText, spectrumMarkdown, + spectrumRichlink, spectrumTyping, spectrumPoll, imessageEffect; @@ -254,6 +257,7 @@ try { poll: spectrumPoll, text: spectrumText, markdown: spectrumMarkdown, + richlink: spectrumRichlink, typing: spectrumTyping, } = await import("spectrum-ts")); ({ imessage, effect: imessageEffect } = await import("spectrum-ts/providers/imessage")); @@ -429,6 +433,8 @@ function reactionTargetText(target) { let text = null; if (c.type === "text") { text = c.text; + } else if (c.type === "richlink") { + text = c.url; } else if (c.type === "group") { for (const item of Array.isArray(c.items) ? c.items : []) { const ic = item && typeof item === "object" ? item.content : null; @@ -436,6 +442,10 @@ function reactionTargetText(target) { text = ic.text; break; } + if (ic && ic.type === "richlink" && ic.url) { + text = ic.url; + break; + } } } if (typeof text !== "string" || !text) return null; @@ -451,6 +461,16 @@ async function normalizeContent(content) { if (content.type === "text") { return { type: "text", text: content.text || "" }; } + if (content.type === "richlink") { + const out = { type: "richlink", url: content.url || "" }; + if (typeof content.title === "string") { + out.title = content.title; + } + if (typeof content.summary === "string") { + out.summary = content.summary; + } + return out; + } if (content.type === "attachment" || content.type === "voice") { return await normalizeBinaryContent(content); } @@ -782,6 +802,16 @@ function tokenOk(header) { return h.length === _tokenBuf.length && crypto.timingSafeEqual(h, _tokenBuf); } +function isHttpUrl(value) { + if (typeof value !== "string" || !value.trim()) return false; + try { + const parsed = new URL(value.trim()); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + const server = http.createServer(async (req, res) => { if (!tokenOk(req.headers["x-hermes-sidecar-token"])) { return unauthorized(res); @@ -820,6 +850,15 @@ const server = http.createServer(async (req, res) => { const result = await space.send(builder); return ok(res, { messageId: result?.id || null }); } + if (req.url === "/send-richlink") { + const { spaceId, url } = body || {}; + if (!spaceId || !isHttpUrl(url)) { + return badRequest(res, "spaceId and http(s) url are required"); + } + const space = await resolveSpace(spaceId); + const result = await space.send(spectrumRichlink(url.trim())); + return ok(res, { messageId: result?.id || null }); + } if (req.url === "/send-attachment") { const { spaceId, path, name, mimeType, caption, kind } = body || {}; diff --git a/tests/plugins/platforms/photon/test_rich_links.py b/tests/plugins/platforms/photon/test_rich_links.py new file mode 100644 index 000000000000..efb10201a73c --- /dev/null +++ b/tests/plugins/platforms/photon/test_rich_links.py @@ -0,0 +1,644 @@ +"""Rich-link handling tests for PhotonAdapter. + +Photon's spectrum-ts SDK exposes a ``richlink()`` content builder for native +URL previews. Hermes routes URL-only outbound messages to the sidecar's +rich-link endpoint and preserves inbound rich-link URLs when Spectrum emits +that content type. +""" +from __future__ import annotations + +import base64 +from typing import Any, Dict, List, Tuple + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from plugins.platforms.photon import adapter as photon_adapter +from plugins.platforms.photon.adapter import PhotonAdapter + +_URL = "https://example.com/article" + + +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 _capture_inbound( + 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 _dm_event(content: Dict[str, Any], msg_id: str = "spc-msg-rich") -> Dict[str, Any]: + return { + "messageId": msg_id, + "platform": "iMessage", + "space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"}, + "sender": {"id": "+155****4567"}, + "content": content, + "timestamp": "2026-05-14T19:06:32.000Z", + } + + +@pytest.mark.asyncio +async def test_url_only_send_routes_to_richlink_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + result = await adapter.send("+155****4567", _URL) + + assert result.success is True + assert calls == [("/send-richlink", {"spaceId": "+155****4567", "url": _URL})] + + +@pytest.mark.asyncio +async def test_url_only_send_trims_surrounding_whitespace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.send("+155****4567", f" {_URL}\n") + + assert calls == [("/send-richlink", {"spaceId": "+155****4567", "url": _URL})] + + +@pytest.mark.asyncio +async def test_mixed_prose_url_stays_on_markdown_send( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.send("+155****4567", f"Read this: {_URL}") + + path, body = calls[0] + assert path == "/send" + assert body["format"] == "markdown" + assert body["text"] == f"Read this: {_URL}" + + +@pytest.mark.asyncio +async def test_malformed_url_like_send_stays_on_markdown_send( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.send("+155****4567", "http://[::1") + + path, body = calls[0] + assert path == "/send" + assert body["format"] == "markdown" + assert body["text"] == "http://[::1" + + +@pytest.mark.asyncio +async def test_markdown_link_stays_on_markdown_send( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.send("+155****4567", f"[Read this]({_URL})") + + path, body = calls[0] + assert path == "/send" + assert body["format"] == "markdown" + + +@pytest.mark.asyncio +async def test_markdown_disabled_keeps_url_on_plain_send( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PHOTON_MARKDOWN", "false") + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + await adapter.send("+155****4567", _URL) + + assert calls == [("/send", {"spaceId": "+155****4567", "text": _URL})] + + +@pytest.mark.asyncio +async def test_url_only_send_fallback_bypasses_richlink_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls: List[Tuple[str, Dict[str, Any]]] = [] + + async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]: + calls.append((path, body)) + if path == "/send-richlink": + raise RuntimeError("richlink unsupported") + return {"ok": True, "messageId": "plain-msg"} + + adapter._sidecar_call = _fake_call # type: ignore[assignment] + + result = await adapter._send_with_retry("+155****4567", _URL, base_delay=0) + + assert result.success is True + assert result.message_id == "plain-msg" + assert calls == [ + ("/send-richlink", {"spaceId": "+155****4567", "url": _URL}), + ("/send", {"spaceId": "+155****4567", "text": _URL}), + ] + + +@pytest.mark.asyncio +async def test_direct_url_only_send_falls_back_to_plain_send( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls: List[Tuple[str, Dict[str, Any]]] = [] + + async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]: + calls.append((path, body)) + if path == "/send-richlink": + raise RuntimeError("richlink unsupported") + return {"ok": True, "messageId": "plain-msg"} + + adapter._sidecar_call = _fake_call # type: ignore[assignment] + + result = await adapter.send("+155****4567", _URL) + + assert result.success is True + assert result.message_id == "plain-msg" + assert calls == [ + ("/send-richlink", {"spaceId": "+155****4567", "url": _URL}), + ("/send", {"spaceId": "+155****4567", "text": _URL}), + ] + + +@pytest.mark.asyncio +async def test_url_only_retry_exhaustion_falls_back_to_plain_send( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PHOTON_MARKDOWN", raising=False) + adapter = _make_adapter(monkeypatch) + calls: List[Tuple[str, Dict[str, Any]]] = [] + + async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]: + calls.append((path, body)) + if path == "/send-richlink": + raise RuntimeError("upstream unavailable") + return {"ok": True, "messageId": "plain-msg"} + + adapter._sidecar_call = _fake_call # type: ignore[assignment] + + result = await adapter._send_with_retry( + "+155****4567", _URL, max_retries=1, base_delay=0 + ) + + assert result.success is True + assert result.message_id == "plain-msg" + assert calls == [ + ("/send-richlink", {"spaceId": "+155****4567", "url": _URL}), + ("/send", {"spaceId": "+155****4567", "text": _URL}), + ] + + +@pytest.mark.asyncio +async def test_standalone_url_only_send_routes_to_richlink_endpoint( + 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, "+155****4567", _URL) + + assert result.get("success") is True + assert posted == [ + ( + "http://127.0.0.1:8789/send-richlink", + {"spaceId": "+155****4567", "url": _URL}, + ) + ] + + +@pytest.mark.asyncio +async def test_standalone_url_only_send_falls_back_to_plain_send( + 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: + def __init__(self, status_code: int, message_id: str = "m-9"): + self.status_code = status_code + self.text = "not found" if status_code != 200 else "" + self._message_id = message_id + + def json(self) -> Dict[str, Any]: + return {"ok": True, "messageId": self._message_id} + + 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)) + if url.endswith("/send-richlink"): + return _Resp(404) + return _Resp(200, "plain-msg") + + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient) + + cfg = PlatformConfig(enabled=True, token="", extra={}) + result = await photon_adapter._standalone_send(cfg, "+155****4567", _URL) + + assert result.get("success") is True + assert result.get("message_id") == "plain-msg" + assert posted == [ + ( + "http://127.0.0.1:8789/send-richlink", + {"spaceId": "+155****4567", "url": _URL}, + ), + ( + "http://127.0.0.1:8789/send", + {"spaceId": "+155****4567", "text": _URL}, + ), + ] + + +@pytest.mark.asyncio +async def test_standalone_markdown_disabled_keeps_url_on_plain_send( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PHOTON_MARKDOWN", "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, "+155****4567", _URL) + + assert result.get("success") is True + assert posted == [ + ( + "http://127.0.0.1:8789/send", + {"spaceId": "+155****4567", "text": _URL}, + ) + ] + + +@pytest.mark.asyncio +async def test_inbound_richlink_dispatches_url_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + event = _dm_event({"type": "richlink", "url": _URL}) + + await adapter._dispatch_inbound(event) + + assert len(captured) == 1 + assert captured[0].text == _URL + assert captured[0].message_type == MessageType.TEXT + assert captured[0].raw_message["content"] == {"type": "richlink", "url": _URL} + + +@pytest.mark.asyncio +async def test_inbound_richlink_preserves_metadata_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + event = _dm_event( + { + "type": "richlink", + "url": _URL, + "title": "Example Article", + "summary": "A summary of the article", + } + ) + + await adapter._dispatch_inbound(event) + + assert len(captured) == 1 + assert captured[0].text == f"Example Article\nA summary of the article\n{_URL}" + assert captured[0].message_type == MessageType.TEXT + + +@pytest.mark.asyncio +async def test_inbound_richlink_dedupes_repeated_summary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + event = _dm_event( + { + "type": "richlink", + "url": "https://example.com/dup", + "title": "Same Text", + "summary": "Same Text", + } + ) + + await adapter._dispatch_inbound(event) + + assert len(captured) == 1 + assert captured[0].text == "Same Text\nhttps://example.com/dup" + + +@pytest.mark.asyncio +async def test_inbound_richlink_without_url_preserves_title( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + event = _dm_event({"type": "richlink", "url": "", "title": "Some Title"}) + + await adapter._dispatch_inbound(event) + + assert len(captured) == 1 + assert captured[0].text == "Some Title" + + +@pytest.mark.asyncio +async def test_malformed_url_like_inbound_text_dispatches_normally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + + await adapter._dispatch_inbound( + _dm_event({"type": "text", "text": "http://[::1"}, "malformed-url-msg") + ) + + assert len(captured) == 1 + assert captured[0].text == "http://[::1" + assert captured[0].message_id == "malformed-url-msg" + + +@pytest.mark.asyncio +async def test_inbound_group_preserves_richlink_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + event = _dm_event( + { + "type": "group", + "items": [ + {"id": "p:0", "content": {"type": "text", "text": "Read this"}}, + {"id": "p:1", "content": {"type": "richlink", "url": _URL}}, + ], + }, + msg_id="spc-msg-rich-group", + ) + + await adapter._dispatch_inbound(event) + + assert len(captured) == 1 + assert captured[0].text == f"Read this\n{_URL}" + assert captured[0].message_type == MessageType.TEXT + + +@pytest.mark.asyncio +async def test_inbound_group_preserves_richlink_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + event = _dm_event( + { + "type": "group", + "items": [ + {"id": "p:0", "content": {"type": "text", "text": "Read this"}}, + { + "id": "p:1", + "content": { + "type": "richlink", + "url": _URL, + "title": "Example Article", + "summary": "A summary of the article", + }, + }, + ], + }, + msg_id="spc-msg-rich-group-metadata", + ) + + await adapter._dispatch_inbound(event) + + assert len(captured) == 1 + assert captured[0].text == f"Read this\nExample Article\nA summary of the article\n{_URL}" + assert captured[0].message_type == MessageType.TEXT + + +_PNG_1X1_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf" + "DwAChwGA60e6kgAAAABJRU5ErkJggg==" +) + + +def _preview_attachment( + name: str = "preview.pluginPayloadAttachment", + mime_type: str = "image/png", +) -> Dict[str, Any]: + raw = base64.b64decode(_PNG_1X1_B64) + return { + "type": "attachment", + "name": name, + "mimeType": mime_type, + "size": len(raw), + "data": _PNG_1X1_B64, + "encoding": "base64", + } + + +def _preview_attachment_by_id( + attachment_id: str = "doc_123.pluginPayloadAttachment", +) -> Dict[str, Any]: + payload = _preview_attachment(name="") + payload["id"] = attachment_id + payload["name"] = None + return payload + + +@pytest.mark.asyncio +async def test_inbound_url_preview_attachment_is_coalesced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + + await adapter._dispatch_inbound(_dm_event({"type": "text", "text": _URL}, "url-msg")) + await adapter._dispatch_inbound(_dm_event(_preview_attachment(), "preview-msg")) + + assert len(captured) == 1 + assert captured[0].text == _URL + assert captured[0].message_id == "url-msg" + + +@pytest.mark.asyncio +async def test_inbound_url_preview_attachment_id_only_is_coalesced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + + await adapter._dispatch_inbound(_dm_event({"type": "text", "text": _URL}, "url-msg")) + await adapter._dispatch_inbound( + _dm_event(_preview_attachment_by_id("doc_5f418810.pluginPayloadAttachment"), "preview-msg") + ) + + assert len(captured) == 1 + assert captured[0].text == _URL + + +@pytest.mark.asyncio +async def test_inbound_url_preview_octet_stream_plugin_payload_is_coalesced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + + await adapter._dispatch_inbound(_dm_event({"type": "text", "text": _URL}, "url-msg")) + await adapter._dispatch_inbound( + _dm_event( + _preview_attachment( + "8DBFD7DD-97E6-40DA-BBBD-8B920E36951D.pluginPayloadAttachment", + mime_type="application/octet-stream", + ), + "preview-doc-msg", + ) + ) + + assert len(captured) == 1 + assert captured[0].text == _URL + + +@pytest.mark.asyncio +async def test_inbound_grouped_url_preview_attachments_are_coalesced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + + await adapter._dispatch_inbound(_dm_event({"type": "richlink", "url": _URL}, "url-msg")) + await adapter._dispatch_inbound( + _dm_event( + { + "type": "group", + "items": [ + {"id": "p:0", "content": _preview_attachment("wide.pluginPayloadAttachment")}, + {"id": "p:1", "content": _preview_attachment("icon.pluginPayloadAttachment")}, + ], + }, + "preview-group", + ) + ) + + assert len(captured) == 1 + assert captured[0].text == _URL + + +@pytest.mark.asyncio +async def test_inbound_richlink_metadata_preview_attachment_is_coalesced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture_inbound(adapter, monkeypatch) + + await adapter._dispatch_inbound( + _dm_event( + { + "type": "richlink", + "url": _URL, + "title": "Example Article", + "summary": "A summary of the article", + }, + "rich-metadata-msg", + ) + ) + await adapter._dispatch_inbound(_dm_event(_preview_attachment(), "preview-msg")) + + assert len(captured) == 1 + assert captured[0].text == f"Example Article\nA summary of the article\n{_URL}" + assert captured[0].message_id == "rich-metadata-msg" diff --git a/tests/plugins/platforms/photon/test_spectrum_patch.py b/tests/plugins/platforms/photon/test_spectrum_patch.py index 0fe6969727b2..fcf63fc637a2 100644 --- a/tests/plugins/platforms/photon/test_spectrum_patch.py +++ b/tests/plugins/platforms/photon/test_spectrum_patch.py @@ -150,6 +150,15 @@ def test_sidecar_healthz_reports_stream_health() -> None: assert "process.exit(75);" in index +def test_sidecar_exposes_richlink_send_endpoint() -> None: + """The loopback endpoint should wrap spectrum-ts' richlink() builder.""" + index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8") + assert "richlink: spectrumRichlink" in index + assert 'if (req.url === "/send-richlink")' in index + assert "isHttpUrl(url)" in index + assert "space.send(spectrumRichlink(url.trim()))" in index + + def test_sidecar_intercepts_both_console_channels() -> None: """spectrum-ts routes its stream telemetry through @photon-ai/otel, which sends severity >= ERROR to console.error and WARN/INFO to console.log.