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-12 01:07:38 -07:00
committed by Teknium
co-authored by Claude Fable 5
parent 0a963d8c9a
commit 573c4e6511
9 changed files with 832 additions and 62 deletions
+155 -33
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");
+32 -6
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",
@@ -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",