feat(photon): support rich link previews

This commit is contained in:
huntsyea 2026-06-28 23:50:32 -05:00 committed by Teknium
parent 324f310263
commit cf550c0863
7 changed files with 955 additions and 45 deletions

View file

@ -0,0 +1,2 @@
huntsyea
# PR #54646 salvage

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -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 || {};

View file

@ -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"

View file

@ -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.