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:
underthestars-zhy 2026-06-11 02:48:11 -07:00 committed by Teknium
parent 0a963d8c9a
commit 573c4e6511
9 changed files with 832 additions and 62 deletions

View file

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

View file

@ -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."
),
)

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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