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