mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(photon): support rich link previews
This commit is contained in:
parent
324f310263
commit
cf550c0863
7 changed files with 955 additions and 45 deletions
2
contributors/emails/hunter.c.yeagley@outlook.com
Normal file
2
contributors/emails/hunter.c.yeagley@outlook.com
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
huntsyea
|
||||
# PR #54646 salvage
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
|
|
|||
644
tests/plugins/platforms/photon/test_rich_links.py
Normal file
644
tests/plugins/platforms/photon/test_rich_links.py
Normal 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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue