mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
feat(photon): upgrade to spectrum-ts 3.0.0 (pinned) with markdown + reactions
Pin spectrum-ts to exactly 3.0.0 (was ^1.18.0 plus an `npm install spectrum-ts@latest` on every setup) so breaking SDK majors can't take down fresh installs silently; `hermes photon setup` now runs `npm ci`. Upgrade procedure documented in the README. Migrate resolveSpace to the v3 namespace API: `im.space.create(phone)` for DMs and `im.space.get(id)` for everything else — group spaces are now rehydratable from their persisted id after a sidecar restart, which v1 could not do. Markdown: replies go out via the v3 `markdown()` builder (iMessage renders natively; other Spectrum platforms degrade to plain text). `PHOTON_MARKDOWN=false` reverts to the stripped plain-text path. Reactions, behind PHOTON_REACTIONS (default off): lifecycle tapbacks (👀 while processing, 👍/👎 on completion) via new sidecar /react and /unreact endpoints with per-target reaction-handle tracking, and user tapbacks on bot-sent messages routed to the agent as synthetic `reaction:added:<emoji>` events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0a963d8c9a
commit
573c4e6511
9 changed files with 832 additions and 62 deletions
|
|
@ -35,8 +35,9 @@ talks to it over loopback.
|
|||
`GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches
|
||||
a `MessageEvent` to the gateway. It reconnects automatically if the stream
|
||||
drops; the sidecar owns the gRPC reconnect to Photon.
|
||||
- **Outbound**: `send` / `send_typing` are loopback POSTs to the sidecar,
|
||||
authenticated with a shared `X-Hermes-Sidecar-Token`.
|
||||
- **Outbound**: `send` / `send_typing` / reaction tapbacks are loopback POSTs
|
||||
to the sidecar (`/send`, `/send-attachment`, `/typing`, `/react`,
|
||||
`/unreact`), authenticated with a shared `X-Hermes-Sidecar-Token`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
|
|
@ -59,7 +60,9 @@ hermes gateway start --platform photon
|
|||
a user with that number already exists).
|
||||
5. **Print the assigned iMessage line** — the number you text to reach your
|
||||
agent.
|
||||
6. **Install the sidecar deps** (`spectrum-ts`).
|
||||
6. **Install the sidecar deps** (`npm ci` — installs the committed lockfile
|
||||
verbatim, so every setup runs the exact `spectrum-ts` version this plugin
|
||||
was written against).
|
||||
|
||||
There is no separate `login` command; like every other Hermes channel,
|
||||
onboarding goes through one setup surface. Re-running `setup` reuses an
|
||||
|
|
@ -117,6 +120,8 @@ All env vars are documented in `plugin.yaml`. The most important:
|
|||
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
|
||||
| `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines |
|
||||
| `PHOTON_TELEMETRY` | false | Spectrum SDK telemetry — toggle with `hermes photon telemetry on\|off` (restart the gateway to apply) |
|
||||
| `PHOTON_MARKDOWN` | true | Send agent replies as markdown (iMessage renders natively). `false` strips formatting to plain text |
|
||||
| `PHOTON_REACTIONS` | false | Tapback 👀/👍/👎 as processing status; tapbacks on bot messages reach the agent as `reaction:added:<emoji>` |
|
||||
|
||||
## Attachments & limitations
|
||||
|
||||
|
|
@ -132,7 +137,38 @@ All env vars are documented in `plugin.yaml`. The most important:
|
|||
documents are sent via `space.send(attachment(...))` /
|
||||
`space.send(voice(...))` through the sidecar's `/send-attachment`
|
||||
endpoint; a caption is delivered as a separate text bubble after the media.
|
||||
- **Reactions, message effects, polls** — supported by `spectrum-ts` but not
|
||||
yet exposed; the sidecar is the natural place to add them.
|
||||
- **Markdown is rendered.** Replies go out via spectrum-ts' `markdown()`
|
||||
builder; iMessage renders bold/italics/lists/code natively and other
|
||||
Spectrum platforms degrade to readable plain text. `PHOTON_MARKDOWN=false`
|
||||
reverts to stripped plain text.
|
||||
- **Reactions (tapbacks) are supported** behind `PHOTON_REACTIONS` (default
|
||||
off): the adapter tapbacks 👀 while processing and swaps it for 👍/👎 on
|
||||
completion, and a user tapback on a bot-sent message is routed to the agent
|
||||
as a synthetic `reaction:added:<emoji>` event. Removal after a sidecar
|
||||
restart is best-effort — the live reaction handle is lost, so a stale
|
||||
tapback heals when the next reaction replaces it. Group spaces stay
|
||||
reachable across restarts via spectrum-ts v3's `space.get(id)`.
|
||||
- **Message effects, polls** — supported by `spectrum-ts` but not yet
|
||||
exposed; the sidecar is the natural place to add them.
|
||||
|
||||
## Upgrading spectrum-ts
|
||||
|
||||
`spectrum-ts` is pinned to an **exact version** in `sidecar/package.json`
|
||||
(no `^` range) and installed with `npm ci`, because the SDK ships breaking
|
||||
majors (v2 removed `defineFusorPlatform`; v3 reworked space construction).
|
||||
A floating range or `npm install spectrum-ts@latest` would let a breaking
|
||||
release take down fresh setups silently. Upgrades are deliberate:
|
||||
|
||||
1. Read the [SDK release notes](https://github.com/photon-hq/spectrum-ts/releases)
|
||||
for every version between the current pin and the target.
|
||||
2. Bump the exact pin in `sidecar/package.json`, then run `npm install`
|
||||
inside `sidecar/` to regenerate `package-lock.json`. Commit both.
|
||||
3. Migrate `sidecar/index.mjs` against the new typings
|
||||
(`sidecar/node_modules/spectrum-ts/dist/*.d.ts` is the source of truth —
|
||||
the hosted docs can lag).
|
||||
4. Run `pytest tests/plugins/platforms/photon/`.
|
||||
5. Verify end-to-end: `hermes photon status`, a DM and a group roundtrip,
|
||||
and an agent reply into a group right after a gateway restart (exercises
|
||||
`space.get` rehydration).
|
||||
|
||||
[photon]: https://photon.codes/
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ from gateway.platforms.base import (
|
|||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
)
|
||||
from gateway.platforms.helpers import strip_markdown
|
||||
|
|
@ -152,6 +153,19 @@ def _env_enablement() -> Optional[dict]:
|
|||
return seed
|
||||
|
||||
|
||||
def _markdown_enabled() -> bool:
|
||||
"""Send agent replies as markdown (spectrum-ts ``markdown()`` builder).
|
||||
|
||||
iMessage renders it natively; other Spectrum platforms degrade to
|
||||
readable plain text. On-device rendering can't be unit-tested, so
|
||||
``PHOTON_MARKDOWN=false`` is the kill-switch back to stripped plain
|
||||
text without a release.
|
||||
"""
|
||||
return os.getenv("PHOTON_MARKDOWN", "true").strip().lower() not in {
|
||||
"false", "0", "no",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter
|
||||
|
||||
|
|
@ -199,6 +213,10 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
).lower() not in ("0", "false", "no")
|
||||
self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
|
||||
|
||||
# With markdown on, format_message preserves fences and the sidecar's
|
||||
# markdown() builder renders them (or degrades them readably).
|
||||
self.supports_code_blocks = _markdown_enabled()
|
||||
|
||||
# Runtime state
|
||||
self._sidecar_proc: Optional[subprocess.Popen] = None
|
||||
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
|
||||
|
|
@ -208,6 +226,10 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
# Lightweight in-memory dedup. The gRPC stream is at-least-once, so we
|
||||
# may see the same messageId more than once (e.g. after a reconnect).
|
||||
self._seen_messages: Dict[str, float] = {}
|
||||
# Ids of messages WE sent (bounded, insertion-order eviction). Inbound
|
||||
# reaction events are only routed to the agent when they target one of
|
||||
# these — a tapback on a human↔human message is not addressed to us.
|
||||
self._sent_message_ids: Dict[str, float] = {}
|
||||
|
||||
# Group-chat mention gating (parity with BlueBubbles). When enabled,
|
||||
# group messages are ignored unless they match a wake word; DMs are
|
||||
|
|
@ -442,7 +464,10 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
"content": {"type": "text", "text": "..."}
|
||||
| {"type": "attachment"|"voice", "id", "name",
|
||||
"mimeType", "size", "duration"?, "data"?,
|
||||
"encoding"?},
|
||||
"encoding"?}
|
||||
| {"type": "reaction", "emoji": "❤️",
|
||||
"targetMessageId": "..." | null,
|
||||
"targetDirection": "inbound"|"outbound" | null},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z"
|
||||
|
||||
Attachment and voice content carry the bytes inline as base64 ``data``
|
||||
|
|
@ -480,6 +505,39 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
media_types: List[str] = []
|
||||
|
||||
ctype = content.get("type")
|
||||
if ctype == "reaction":
|
||||
# Route only tapbacks on messages WE sent — those are implicitly
|
||||
# addressed to the bot (feishu precedent: synthetic text event).
|
||||
# Reactions on human↔human messages are not for us. Checked before
|
||||
# the mention gate: a tapback never carries a wake word.
|
||||
target_id = content.get("targetMessageId")
|
||||
is_ours = content.get("targetDirection") == "outbound" or (
|
||||
target_id and target_id in self._sent_message_ids
|
||||
)
|
||||
if not is_ours:
|
||||
logger.debug(
|
||||
"[photon] ignoring reaction on a message we didn't send"
|
||||
)
|
||||
return
|
||||
emoji = content.get("emoji") or ""
|
||||
source = self.build_source(
|
||||
chat_id=space_id,
|
||||
chat_name=space_id,
|
||||
chat_type=chat_type,
|
||||
user_id=sender_id,
|
||||
user_name=sender_id or None,
|
||||
)
|
||||
await self.handle_message(
|
||||
MessageEvent(
|
||||
text=f"reaction:added:{emoji}",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=event.get("messageId"),
|
||||
raw_message=event,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
)
|
||||
return
|
||||
if ctype == "text":
|
||||
text = content.get("text") or ""
|
||||
mtype = MessageType.TEXT
|
||||
|
|
@ -774,6 +832,91 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
except Exception as e:
|
||||
logger.debug("[photon] stop_typing failed: %s", e)
|
||||
|
||||
# -- Reactions (tapbacks) -----------------------------------------------
|
||||
#
|
||||
# Same lifecycle-hook pattern as Telegram/Discord: 👀 while processing,
|
||||
# swapped for 👍/👎 on completion. Opt-in via PHOTON_REACTIONS — iMessage
|
||||
# is a personal-texting channel, and a tapback on every text is noisy.
|
||||
|
||||
_SENT_IDS_MAX = 1000
|
||||
|
||||
def _record_sent_message(self, message_id: Optional[str]) -> None:
|
||||
if not message_id:
|
||||
return
|
||||
sent = self._sent_message_ids
|
||||
if message_id in sent:
|
||||
del sent[message_id] # refresh insertion order
|
||||
sent[message_id] = time.time()
|
||||
if len(sent) > self._SENT_IDS_MAX:
|
||||
for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]:
|
||||
del sent[old]
|
||||
|
||||
def _reactions_enabled(self) -> bool:
|
||||
return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in {
|
||||
"true", "1", "yes", "on",
|
||||
}
|
||||
|
||||
async def _add_reaction(
|
||||
self, chat_id: str, message_id: str, emoji: str
|
||||
) -> bool:
|
||||
"""Tapback ``emoji`` onto a message. Soft-fails (False), never raises."""
|
||||
try:
|
||||
await self._sidecar_call(
|
||||
"/react",
|
||||
{"spaceId": chat_id, "messageId": message_id, "emoji": emoji},
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("[photon] add_reaction failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _remove_reaction(self, chat_id: str, message_id: str) -> bool:
|
||||
"""Retract our tapback from a message. Soft-fails (False), never raises.
|
||||
|
||||
The sidecar tracks one reaction handle per target message; after a
|
||||
sidecar restart the handle is gone and removal is best-effort (the
|
||||
stale tapback self-heals when the next reaction replaces it).
|
||||
"""
|
||||
try:
|
||||
await self._sidecar_call(
|
||||
"/unreact", {"spaceId": chat_id, "messageId": message_id},
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("[photon] remove_reaction failed: %s", e)
|
||||
return False
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Tapback 👀 on the triggering message while the agent works."""
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
chat_id = getattr(event.source, "chat_id", None)
|
||||
message_id = getattr(event, "message_id", None)
|
||||
if chat_id and message_id:
|
||||
await self._add_reaction(chat_id, message_id, "\U0001f440")
|
||||
|
||||
async def on_processing_complete(
|
||||
self, event: MessageEvent, outcome: ProcessingOutcome
|
||||
) -> None:
|
||||
"""Swap the 👀 progress tapback for a 👍/👎 result.
|
||||
|
||||
Remove-then-add rather than a bare replace: deterministic whether the
|
||||
platform replaces a sender's previous tapback or stacks them, and it
|
||||
keeps the sidecar's reaction-handle slot coherent.
|
||||
"""
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
chat_id = getattr(event.source, "chat_id", None)
|
||||
message_id = getattr(event, "message_id", None)
|
||||
if not chat_id or not message_id:
|
||||
return
|
||||
await self._remove_reaction(chat_id, message_id)
|
||||
if outcome == ProcessingOutcome.SUCCESS:
|
||||
await self._add_reaction(chat_id, message_id, "\U0001f44d")
|
||||
elif outcome == ProcessingOutcome.FAILURE:
|
||||
await self._add_reaction(chat_id, message_id, "\U0001f44e")
|
||||
# CANCELLED: leave the message unreacted.
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return whatever we know about a Spectrum space id.
|
||||
|
||||
|
|
@ -783,6 +926,11 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
return {"name": chat_id, "type": "dm", "id": chat_id}
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
# Markdown is passed through verbatim — the sidecar sends it with the
|
||||
# markdown() builder and iMessage renders it. The strip path remains
|
||||
# as the PHOTON_MARKDOWN=false kill-switch.
|
||||
if _markdown_enabled():
|
||||
return content
|
||||
return strip_markdown(content)
|
||||
|
||||
async def _send_with_retry(
|
||||
|
|
@ -794,7 +942,12 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
max_retries: int = 2,
|
||||
base_delay: float = 2.0,
|
||||
) -> SendResult:
|
||||
"""Photon/iMessage is plain text, so never show the generic Markdown banner."""
|
||||
"""Retry sends without the generic Markdown banner.
|
||||
|
||||
Photon replies are markdown (rendered by iMessage) or stripped plain
|
||||
text under ``PHOTON_MARKDOWN=false`` — either way the gateway's
|
||||
generic banner never applies.
|
||||
"""
|
||||
text = self.format_message(content)
|
||||
result = await self.send(
|
||||
chat_id=chat_id,
|
||||
|
|
@ -858,10 +1011,15 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
)
|
||||
text = text[: self.MAX_MESSAGE_LENGTH]
|
||||
body: Dict[str, Any] = {"spaceId": space_id, "text": text}
|
||||
# Omit the key when disabled so an older sidecar (pre-`format`)
|
||||
# keeps accepting the body during a half-upgraded restart.
|
||||
if _markdown_enabled():
|
||||
body["format"] = "markdown"
|
||||
try:
|
||||
data = await self._sidecar_call("/send", body)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
self._record_sent_message(data.get("messageId"))
|
||||
return SendResult(success=True, message_id=data.get("messageId"))
|
||||
|
||||
async def _sidecar_send_attachment(
|
||||
|
|
@ -910,6 +1068,7 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
data = await self._sidecar_call("/send-attachment", body)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
self._record_sent_message(data.get("messageId"))
|
||||
return SendResult(success=True, message_id=data.get("messageId"))
|
||||
|
||||
async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
|
@ -1062,10 +1221,14 @@ async def _standalone_send(
|
|||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# 1. Text body first (if any), so it leads the conversation.
|
||||
if message:
|
||||
send_body: Dict[str, Any] = {
|
||||
"spaceId": chat_id,
|
||||
"text": message[:_MAX_MESSAGE_LENGTH],
|
||||
}
|
||||
if _markdown_enabled():
|
||||
send_body["format"] = "markdown"
|
||||
resp = await client.post(
|
||||
f"{base}/send",
|
||||
json={"spaceId": chat_id, "text": message[:_MAX_MESSAGE_LENGTH]},
|
||||
headers=headers,
|
||||
f"{base}/send", json=send_body, headers=headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"}
|
||||
|
|
@ -1146,10 +1309,11 @@ def register(ctx) -> None:
|
|||
allow_update_command=True,
|
||||
platform_hint=(
|
||||
"You are communicating via Photon Spectrum (iMessage). "
|
||||
"Treat replies like regular text messages — short, friendly, no "
|
||||
"markdown rendering. Recipient identifiers are E.164 phone "
|
||||
"numbers; never expose them in responses unless the user asked. "
|
||||
"Attachments arrive as metadata only."
|
||||
"Treat replies like regular text messages — short and friendly. "
|
||||
"Markdown is rendered (bold, italics, lists, code), but keep "
|
||||
"formatting light and conversational. Recipient identifiers are "
|
||||
"E.164 phone numbers; never expose them in responses unless the "
|
||||
"user asked. Attachments arrive as metadata only."
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -376,16 +376,26 @@ def _install_sidecar() -> int:
|
|||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
# Always pull the newest published spectrum-ts so every setup runs against
|
||||
# the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json
|
||||
# to the current release before installing — a plain `npm install` would
|
||||
# stay pinned to whatever the committed lockfile already resolved.
|
||||
print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest")
|
||||
# spectrum-ts is pinned exactly in package.json/package-lock.json because
|
||||
# the SDK ships breaking majors (v2 removed defineFusorPlatform; v3
|
||||
# reworked space construction). Upgrades are deliberate: bump the pin,
|
||||
# migrate sidecar/index.mjs, re-run the photon tests — never `@latest`
|
||||
# (see README "Upgrading spectrum-ts"). `npm ci` installs the committed
|
||||
# lockfile verbatim; fall back to `npm install` when the lockfile is
|
||||
# missing or drifted (e.g. a dev checkout mid-upgrade).
|
||||
print(f" $ cd {_SIDECAR_DIR} && {npm} ci")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "install", "spectrum-ts@latest"],
|
||||
[npm, "ci"],
|
||||
cwd=str(_SIDECAR_DIR),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print(f" npm ci failed — falling back to: {npm} install")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "install"],
|
||||
cwd=str(_SIDECAR_DIR),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print("npm install failed", file=sys.stderr)
|
||||
return proc.returncode
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: photon-platform
|
||||
label: iMessage via Photon
|
||||
kind: platform
|
||||
version: 0.2.0
|
||||
version: 0.3.0
|
||||
description: >
|
||||
Photon Spectrum gateway adapter for Hermes Agent.
|
||||
Connects to iMessage (and other Spectrum interfaces) through Photon's
|
||||
|
|
@ -78,3 +78,11 @@ optional_env:
|
|||
description: "Enable Spectrum SDK telemetry in the sidecar (true/false, default false; toggle with `hermes photon telemetry on|off`)"
|
||||
prompt: "Enable Spectrum telemetry? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_MARKDOWN
|
||||
description: "Send agent replies as markdown — iMessage renders it natively, other Spectrum platforms degrade to plain text (true/false, default true)"
|
||||
prompt: "Render replies as markdown? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_REACTIONS
|
||||
description: "Tapback 👀/👍/👎 on messages as processing status and route tapbacks on bot messages to the agent (true/false, default false)"
|
||||
prompt: "Enable reaction tapbacks? (true/false)"
|
||||
password: false
|
||||
|
|
|
|||
|
|
@ -19,11 +19,18 @@
|
|||
// lines are heartbeats. One consumer at a time.
|
||||
// - POST /healthz -> {"ok": true}
|
||||
// - POST /send -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "text": "..."}
|
||||
// body: {"spaceId": "...", "text": "...",
|
||||
// "format": "text" | "markdown" (default "text")}
|
||||
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
|
||||
// "mimeType": "..." | null, "caption": "..." | null,
|
||||
// "kind": "attachment" | "voice"}
|
||||
// - POST /react -> {"ok": true, "reactionId": "..." | null}
|
||||
// body: {"spaceId": "...", "messageId": "<target msg id>",
|
||||
// "emoji": "👀"}
|
||||
// - POST /unreact -> {"ok": true} | 400 soft failure
|
||||
// body: {"spaceId": "...", "messageId": "<target msg id>",
|
||||
// "reactionId": "..." | null (restart-recovery fallback)}
|
||||
// - POST /typing -> {"ok": true}
|
||||
// body: {"spaceId": "...", "state": "start" | "stop"}
|
||||
// - POST /shutdown -> {"ok": true}; then process exits
|
||||
|
|
@ -31,6 +38,9 @@
|
|||
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
|
||||
// exiting. Logs go to stderr; Python supervises restart.
|
||||
//
|
||||
// Requires spectrum-ts 3.x — pinned exactly in package.json because the SDK
|
||||
// ships breaking majors; see README "Upgrading spectrum-ts".
|
||||
//
|
||||
// Env vars (required):
|
||||
// PHOTON_PROJECT_ID (== the project's spectrumProjectId)
|
||||
// PHOTON_PROJECT_SECRET
|
||||
|
|
@ -64,6 +74,8 @@ const MAX_INLINE_ATTACHMENT_BYTES =
|
|||
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
|
||||
const E164_RE = /^\+\d{6,}$/;
|
||||
const MAX_KNOWN_SPACES = 2048;
|
||||
const MAX_KNOWN_MESSAGES = 1024;
|
||||
const MAX_REACTION_HANDLES = 512;
|
||||
|
||||
if (!projectId || !projectSecret || !sharedToken) {
|
||||
console.error(
|
||||
|
|
@ -75,13 +87,20 @@ if (!projectId || !projectSecret || !sharedToken) {
|
|||
|
||||
// Lazy-load spectrum-ts so a missing install fails with a clear message
|
||||
// instead of a cryptic module-resolution error during import.
|
||||
let Spectrum, imessage, attachment, voice, spectrumText, spectrumTyping;
|
||||
let Spectrum,
|
||||
imessage,
|
||||
attachment,
|
||||
voice,
|
||||
spectrumText,
|
||||
spectrumMarkdown,
|
||||
spectrumTyping;
|
||||
try {
|
||||
({
|
||||
Spectrum,
|
||||
attachment,
|
||||
voice,
|
||||
text: spectrumText,
|
||||
markdown: spectrumMarkdown,
|
||||
typing: spectrumTyping,
|
||||
} = await import("spectrum-ts"));
|
||||
({ imessage } = await import("spectrum-ts/providers/imessage"));
|
||||
|
|
@ -109,15 +128,34 @@ const app = await Spectrum({
|
|||
let consumerRes = null;
|
||||
let consumerWaiters = [];
|
||||
const knownSpaces = new Map();
|
||||
// Inbound Message objects by id, so /react can usually skip a
|
||||
// `space.getMessage` round trip when tapping back on a recent message.
|
||||
const knownMessages = new Map();
|
||||
// One reaction handle per reacted-to message (key `${spaceId}\0${messageId}`,
|
||||
// value {emoji, handle}) — mirrors iMessage's one-tapback-per-sender
|
||||
// semantics; a new /react on the same target overwrites the slot. The handle
|
||||
// is the outbound reaction Message returned by `target.react()`, kept so
|
||||
// /unreact can `unsend()` it later.
|
||||
const reactionHandles = new Map();
|
||||
|
||||
function lruSet(map, key, value, cap) {
|
||||
if (map.has(key)) map.delete(key);
|
||||
map.set(key, value);
|
||||
if (map.size > cap) {
|
||||
const oldest = map.keys().next().value;
|
||||
if (oldest !== undefined) map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberKnownSpace(id, space) {
|
||||
if (!id || typeof id !== "string" || !space) return;
|
||||
if (knownSpaces.has(id)) knownSpaces.delete(id);
|
||||
knownSpaces.set(id, space);
|
||||
if (knownSpaces.size > MAX_KNOWN_SPACES) {
|
||||
const oldest = knownSpaces.keys().next().value;
|
||||
if (oldest) knownSpaces.delete(oldest);
|
||||
}
|
||||
lruSet(knownSpaces, id, space, MAX_KNOWN_SPACES);
|
||||
}
|
||||
|
||||
function rememberKnownMessage(message) {
|
||||
const id = message?.id;
|
||||
if (!id || typeof id !== "string") return;
|
||||
lruSet(knownMessages, id, message, MAX_KNOWN_MESSAGES);
|
||||
}
|
||||
|
||||
function phoneTargetFromSpaceId(spaceId) {
|
||||
|
|
@ -232,6 +270,17 @@ async function normalizeContent(content) {
|
|||
if (content.type === "attachment" || content.type === "voice") {
|
||||
return await normalizeBinaryContent(content);
|
||||
}
|
||||
if (content.type === "reaction") {
|
||||
return {
|
||||
type: "reaction",
|
||||
emoji: content.emoji || "",
|
||||
targetMessageId: content.target?.id ?? null,
|
||||
// Lets Python gate "is this a reaction to one of MY messages" without
|
||||
// tracking every outbound id. May be null if the provider doesn't
|
||||
// hydrate the target — Python falls back to its own sent-id cache.
|
||||
targetDirection: content.target?.direction ?? null,
|
||||
};
|
||||
}
|
||||
return { type: content.type || "unknown" };
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +325,7 @@ async function normalizeEvent(space, message) {
|
|||
continue;
|
||||
}
|
||||
rememberInboundSpace(space, message);
|
||||
rememberKnownMessage(message);
|
||||
const event = await normalizeEvent(space, message);
|
||||
if (!event) continue;
|
||||
await deliver(JSON.stringify(event));
|
||||
|
|
@ -385,37 +435,44 @@ async function resolveSpace(spaceId) {
|
|||
const cached = knownSpaces.get(spaceId);
|
||||
if (cached) return cached;
|
||||
|
||||
const im = imessage(app);
|
||||
const phoneTarget = phoneTargetFromSpaceId(spaceId);
|
||||
// A bare E.164 phone number addresses a DM. Resolve the user, then the (DM)
|
||||
// space — `imessage(app).user(phone)` -> `im.space(user)` — so callers can
|
||||
// pass just "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of
|
||||
// an opaque inbound space id. Photon also represents DM chat ids as
|
||||
// `any;-;+1...`; normalize those through the same path so replies to inbound
|
||||
// DMs still resolve after Python stores the inbound `space.id`.
|
||||
if (phoneTarget && imessage) {
|
||||
let space = null;
|
||||
|
||||
// A bare E.164 phone number addresses a DM, so callers can pass just
|
||||
// "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of an opaque
|
||||
// inbound space id. Photon also represents DM chat ids as `any;-;+1...`;
|
||||
// normalize those through the same path. `space.create` accepts the raw
|
||||
// phone string directly.
|
||||
if (phoneTarget) {
|
||||
try {
|
||||
const im = imessage(app);
|
||||
const user = await im.user(phoneTarget);
|
||||
const space = await im.space(user);
|
||||
rememberKnownSpace(spaceId, space);
|
||||
rememberKnownSpace(phoneTarget, space);
|
||||
rememberKnownSpace(space?.id, space);
|
||||
return space;
|
||||
space = await im.space.create(phoneTarget);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: phone->DM resolution failed: " +
|
||||
"photon-sidecar: phone->DM space.create failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
// No cache hit and not a phone/DM target. spectrum-ts exposes no API to
|
||||
// rehydrate an arbitrary opaque space id: a Space is only obtained from the
|
||||
// inbound `[space, message]` stream (cached above in `knownSpaces`) or
|
||||
// reconstructed for a DM from its phone number. So a group space whose cache
|
||||
// entry was lost — e.g. after a sidecar restart with no fresh inbound message
|
||||
// in that group — cannot be resolved here; a new inbound message in the group
|
||||
// re-warms the cache. DMs are unaffected (reconstructed from the phone).
|
||||
throw new Error(`unable to resolve space id ${spaceId}`);
|
||||
// Anything else — typically an opaque group GUID — is rehydrated from the
|
||||
// persisted id via `space.get`, so group spaces stay reachable after a
|
||||
// sidecar restart even before any fresh inbound message in that group.
|
||||
if (!space) {
|
||||
try {
|
||||
space = await im.space.get(spaceId);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: space.get failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!space) throw new Error(`unable to resolve space id ${spaceId}`);
|
||||
|
||||
rememberKnownSpace(spaceId, space);
|
||||
if (phoneTarget) rememberKnownSpace(phoneTarget, space);
|
||||
rememberKnownSpace(space?.id, space);
|
||||
return space;
|
||||
}
|
||||
|
||||
// Constant-time token comparison — don't leak the token via `!==` timing.
|
||||
|
|
@ -449,12 +506,19 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
const body = await readBody(req);
|
||||
if (req.url === "/send") {
|
||||
const { spaceId, text } = body || {};
|
||||
const { spaceId, text, format = "text" } = body || {};
|
||||
if (!spaceId || typeof text !== "string") {
|
||||
return badRequest(res, "spaceId and text are required");
|
||||
}
|
||||
if (format !== "text" && format !== "markdown") {
|
||||
return badRequest(res, "format must be text or markdown");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
const result = await space.send(spectrumText(text));
|
||||
// iMessage renders markdown natively; spectrum-ts degrades it to
|
||||
// readable plain text on platforms that don't.
|
||||
const builder =
|
||||
format === "markdown" ? spectrumMarkdown(text) : spectrumText(text);
|
||||
const result = await space.send(builder);
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/send-attachment") {
|
||||
|
|
@ -492,6 +556,64 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/react") {
|
||||
const { spaceId, messageId, emoji } = body || {};
|
||||
if (!spaceId || !messageId || typeof emoji !== "string" || !emoji) {
|
||||
return badRequest(res, "spaceId, messageId and emoji are required");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
const target =
|
||||
knownMessages.get(messageId) ?? (await space.getMessage(messageId));
|
||||
if (!target) {
|
||||
return badRequest(res, "message not found");
|
||||
}
|
||||
const handle = await target.react(emoji);
|
||||
if (!handle) {
|
||||
return badRequest(res, "reactions not supported on this platform");
|
||||
}
|
||||
lruSet(
|
||||
reactionHandles,
|
||||
`${spaceId}\u0000${messageId}`,
|
||||
{ emoji, handle },
|
||||
MAX_REACTION_HANDLES
|
||||
);
|
||||
return ok(res, { reactionId: handle.id ?? null });
|
||||
}
|
||||
if (req.url === "/unreact") {
|
||||
const { spaceId, messageId, reactionId } = body || {};
|
||||
if (!spaceId || !messageId) {
|
||||
return badRequest(res, "spaceId and messageId are required");
|
||||
}
|
||||
const key = `${spaceId}\u0000${messageId}`;
|
||||
const slot = reactionHandles.get(key);
|
||||
if (slot) {
|
||||
await slot.handle.unsend();
|
||||
reactionHandles.delete(key);
|
||||
return ok(res, {});
|
||||
}
|
||||
// Restart-recovery: the live handle is gone, so try rehydrating the
|
||||
// reaction message by id and retracting it. Only outbound messages can
|
||||
// be unsent — if the provider rehydrates it as inbound (or not at all)
|
||||
// this throws, and that's an expected soft failure, not a sidecar bug:
|
||||
// a stale tapback self-heals when the next /react replaces it.
|
||||
if (reactionId) {
|
||||
try {
|
||||
const space = await resolveSpace(spaceId);
|
||||
const msg = await space.getMessage(reactionId);
|
||||
if (msg) {
|
||||
await space.unsend(msg);
|
||||
return ok(res, {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: best-effort unreact failed: " +
|
||||
(e && e.message ? e.message : String(e))
|
||||
);
|
||||
}
|
||||
return badRequest(res, "reaction not removable");
|
||||
}
|
||||
return badRequest(res, "no tracked reaction for message");
|
||||
}
|
||||
if (req.url === "/typing") {
|
||||
const { spaceId, state = "start" } = body || {};
|
||||
if (!spaceId) return badRequest(res, "spaceId is required");
|
||||
|
|
|
|||
38
plugins/platforms/photon/sidecar/package-lock.json
generated
38
plugins/platforms/photon/sidecar/package-lock.json
generated
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"spectrum-ts": "^1.18.0"
|
||||
"spectrum-ts": "3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
|
@ -413,6 +413,18 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@photon-ai/telegram-ts": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@photon-ai/telegram-ts/-/telegram-ts-10.0.0.tgz",
|
||||
"integrity": "sha512-kYGj/ieKOCG+OxoD1R69xHoT7zHl9dboF52LMPUl4FnorbwA8b2pid0uFoDYF55WIfoeo+VSqwlmY84GgpSedg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@photon-ai/whatsapp-business": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@photon-ai/whatsapp-business/-/whatsapp-business-0.1.1.tgz",
|
||||
|
|
@ -1025,6 +1037,18 @@
|
|||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
|
|
@ -1396,9 +1420,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/spectrum-ts": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-1.18.0.tgz",
|
||||
"integrity": "sha512-xgqGSCY4ltA737mJ2Yb2wniJDOYzZRby3YxeT9mv0iOvyWlsG2ptSp72LcXZBgkD4ejVSXAkzg7iLmSlf02buA==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/spectrum-ts/-/spectrum-ts-3.0.0.tgz",
|
||||
"integrity": "sha512-96XNXaEqohhTJfE/XL3+iNW9Pflc2jj7Xk5LLPthKEwOz6e6vdBh/KB5miAe2lQ+mkFtwEguVe2n4MVkBLcAtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@photon-ai/advanced-imessage": "^0.11.0",
|
||||
|
|
@ -1406,10 +1430,12 @@
|
|||
"@photon-ai/otel": "^0.1.1",
|
||||
"@photon-ai/proto": "^0.2.4",
|
||||
"@photon-ai/slack": "^0.2.0",
|
||||
"@photon-ai/telegram-ts": "10.0.0",
|
||||
"@photon-ai/whatsapp-business": "^0.1.1",
|
||||
"@repeaterjs/repeater": "^3.0.6",
|
||||
"better-grpc": "^0.3.2",
|
||||
"lru-cache": "^11.0.0",
|
||||
"marked": "^18.0.5",
|
||||
"mime-types": "^3.0.1",
|
||||
"nice-grpc": "^2.1.16",
|
||||
"nice-grpc-common": "^2.0.2",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "^1.18.0"
|
||||
"spectrum-ts": "3.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "8.6.1",
|
||||
|
|
|
|||
129
tests/plugins/platforms/photon/test_markdown.py
Normal file
129
tests/plugins/platforms/photon/test_markdown.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Markdown handling tests for PhotonAdapter.
|
||||
|
||||
Markdown is on by default (the sidecar sends it via spectrum-ts'
|
||||
``markdown()`` builder and iMessage renders it); ``PHOTON_MARKDOWN=false``
|
||||
reverts to the stripped-plain-text path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_MD = "**bold** and `code`"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def test_format_message_passthrough_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message(_MD) == _MD
|
||||
|
||||
|
||||
def test_format_message_strips_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message(_MD) == "bold and code"
|
||||
|
||||
|
||||
def test_supports_code_blocks_mirrors_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is True
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+15551234567", _MD)
|
||||
|
||||
path, body = calls[0]
|
||||
assert path == "/send"
|
||||
assert body["format"] == "markdown"
|
||||
assert body["text"] == _MD # passed through unstripped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_send_omits_format_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Old-sidecar compat: the key is absent, not "text", when disabled."""
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+15551234567", _MD)
|
||||
|
||||
_, body = calls[0]
|
||||
assert "format" not in body
|
||||
assert body["text"] == "bold and code"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(cfg, "+15551234567", _MD)
|
||||
|
||||
assert result.get("success") is True
|
||||
assert posted[0][1]["format"] == "markdown"
|
||||
275
tests/plugins/platforms/photon/test_reactions.py
Normal file
275
tests/plugins/platforms/photon/test_reactions.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"""Reaction (tapback) tests for PhotonAdapter.
|
||||
|
||||
Outbound reactions go through the sidecar's ``/react`` / ``/unreact``
|
||||
endpoints; these tests stub ``_sidecar_call`` to assert endpoint + body
|
||||
shape. Inbound reaction events are fed straight to ``_dispatch_inbound``.
|
||||
Neither path spawns the Node sidecar or binds ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_EYES = "\U0001f440"
|
||||
_THUMBS_UP = "\U0001f44d"
|
||||
_THUMBS_DOWN = "\U0001f44e"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123", "reactionId": "react-1"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def _capture_handled(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _message_event(adapter: PhotonAdapter) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text="hi",
|
||||
message_type=MessageType.TEXT,
|
||||
source=adapter.build_source(
|
||||
chat_id="+15551234567",
|
||||
chat_name="+15551234567",
|
||||
chat_type="dm",
|
||||
user_id="+15551234567",
|
||||
user_name=None,
|
||||
),
|
||||
message_id="target-msg-1",
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _reaction_event(
|
||||
emoji: str = "❤️",
|
||||
target_id: str = "bot-msg-1",
|
||||
target_direction: Any = "outbound",
|
||||
space_type: str = "dm",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": "reaction-evt-1",
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+15551234567", "type": space_type, "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {
|
||||
"type": "reaction",
|
||||
"emoji": emoji,
|
||||
"targetMessageId": target_id,
|
||||
"targetDirection": target_direction,
|
||||
},
|
||||
"timestamp": "2026-06-11T10:00:00.000Z",
|
||||
}
|
||||
|
||||
|
||||
# -- Outbound: /react and /unreact body shapes ------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_reaction_posts_react(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._add_reaction("+15551234567", "target-msg-1", _EYES)
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
(
|
||||
"/react",
|
||||
{
|
||||
"spaceId": "+15551234567",
|
||||
"messageId": "target-msg-1",
|
||||
"emoji": _EYES,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_reaction_posts_unreact(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._remove_reaction("+15551234567", "target-msg-1")
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
("/unreact", {"spaceId": "+15551234567", "messageId": "target-msg-1"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_failure_is_soft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def _boom(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise RuntimeError("sidecar down")
|
||||
|
||||
adapter._sidecar_call = _boom # type: ignore[assignment]
|
||||
|
||||
assert await adapter._add_reaction("+1", "m", _EYES) is False
|
||||
assert await adapter._remove_reaction("+1", "m") is False
|
||||
|
||||
|
||||
# -- Lifecycle hooks ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hooks_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_REACTIONS", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
event = _message_event(adapter)
|
||||
await adapter.on_processing_start(event)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_start_adds_eyes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_start(_message_event(adapter))
|
||||
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/react"
|
||||
assert body["emoji"] == _EYES
|
||||
assert body["messageId"] == "target-msg-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_success_swaps_to_thumbs_up(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.SUCCESS
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact", "/react"]
|
||||
assert calls[1][1]["emoji"] == _THUMBS_UP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_failure_swaps_to_thumbs_down(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.FAILURE
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact", "/react"]
|
||||
assert calls[1][1]["emoji"] == _THUMBS_DOWN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_cancelled_only_removes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.CANCELLED
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact"]
|
||||
|
||||
|
||||
# -- Inbound reaction routing ------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_on_bot_message_routed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(emoji="❤️"))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "reaction:added:❤️"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.source.chat_id == "+15551234567"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_sent_ids_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No targetDirection from the provider — gate on our own sent-id cache."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
adapter._record_sent_message("bot-msg-1")
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_reaction_event(target_id="bot-msg-1", target_direction=None)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_on_foreign_message_dropped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_reaction_event(target_id="someone-elses-msg", target_direction=None)
|
||||
)
|
||||
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_bypasses_require_mention(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A tapback never carries a wake word — it must skip group gating."""
|
||||
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(space_type="group"))
|
||||
|
||||
assert len(captured) == 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue