Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions plugins/platforms/photon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,27 +152,38 @@ All env vars are documented in `plugin.yaml`. The most important:
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)`.
reachable across restarts via spectrum-ts' `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:
majors (v2 removed `defineFusorPlatform`; v3 reworked space construction; v5
split it into `@spectrum-ts/*` packages, with `spectrum-ts` as the umbrella
that re-exports them; v8 made `richlink` outbound-only, so inbound rich links
now arrive as plain `text`). A floating range or `npm install spectrum-ts@latest`
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,
3. Migrate `sidecar/index.mjs` against the new typings. `spectrum-ts` re-exports
`@spectrum-ts/core` (the framework: `Spectrum`, content builders,
`Space`/`Message`) and `@spectrum-ts/imessage` (the provider), so the source
of truth is `sidecar/node_modules/@spectrum-ts/{core,imessage}/dist/*.d.ts`
(the hosted docs can lag).
4. Re-validate `sidecar/patch-spectrum-mixed-attachments.mjs`. It rewrites the
compiled iMessage inbound mappers in `@spectrum-ts/imessage/dist/index.js`
so a bubble with both text and attachments keeps its typed text; the anchors
are tied to that build's output. `npm install` runs it via `postinstall` and
fails loudly if the anchors no longer match — update them to the new output
(`test_spectrum_patch.py` covers the patch).
5. Run `pytest tests/plugins/platforms/photon/`.
6. 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).

Expand Down
13 changes: 12 additions & 1 deletion plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,8 @@ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
"encoding"?}
| {"type": "reaction", "emoji": "❤️",
"targetMessageId": "..." | null,
"targetDirection": "inbound"|"outbound" | null},
"targetDirection": "inbound"|"outbound" | null,
"targetText": "..." | null},
"timestamp": "2026-05-14T19:06:32.000Z"

Attachment and voice content carry the bytes inline as base64 ``data``
Expand Down Expand Up @@ -642,12 +643,22 @@ def _normalize_binary_payload(
user_id=sender_id,
user_name=sender_id or None,
)
# Correlate the tapback to the message it reacted to, so the agent
# sees WHAT was reacted to. `is_ours` above guarantees the target is
# one of the bot's own messages, so reply_to_is_own_message holds and
# the gateway injects `[Replying to your previous message: "..."]`.
# reply_to_text comes from the sidecar (hydrated reaction target);
# it's None for attachment/voice-only targets, and the gateway only
# injects the pointer when both id and text are present.
await self.handle_message(
MessageEvent(
text=f"reaction:added:{emoji}",
message_type=MessageType.TEXT,
source=source,
message_id=event.get("messageId"),
reply_to_message_id=target_id,
reply_to_text=content.get("targetText") or None,
reply_to_is_own_message=True,
raw_message=event,
timestamp=timestamp,
)
Expand Down
11 changes: 6 additions & 5 deletions plugins/platforms/photon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,11 +376,12 @@ def _install_sidecar() -> int:
return 1
# 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).
# reworked space construction; v5 split it into @spectrum-ts/* packages).
# 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, "ci"],
Expand Down
39 changes: 36 additions & 3 deletions plugins/platforms/photon/sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
// 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
// Requires spectrum-ts 8.x — pinned exactly in package.json because the SDK
// ships breaking majors; see README "Upgrading spectrum-ts".
//
// Env vars (required):
Expand Down Expand Up @@ -405,6 +405,35 @@ async function normalizeBinaryContent(content) {
return meta;
}

// Best-effort text preview of a reaction's resolved target Message, so the
// Python adapter can populate the gateway's `reply_to_text` (context: WHAT was
// tapped back). The SDK only emits a reaction once it has resolved the full
// target Message (toReactionMessages bails otherwise), so `target.content` is
// hydrated here — no extra round trip. Handles plain text and our patched mixed
// text+attachment groups (first text child); null for attachment/voice-only
// targets. Capped so one long bubble can't balloon the NDJSON line.
const REACTION_TARGET_TEXT_CAP = 2000;
function reactionTargetText(target) {
const c = target && typeof target === "object" ? target.content : null;
if (!c || typeof c !== "object") return null;
let text = null;
if (c.type === "text") {
text = c.text;
} else if (c.type === "group") {
for (const item of Array.isArray(c.items) ? c.items : []) {
const ic = item && typeof item === "object" ? item.content : null;
if (ic && ic.type === "text" && ic.text) {
text = ic.text;
break;
}
}
}
if (typeof text !== "string" || !text) return null;
return text.length > REACTION_TARGET_TEXT_CAP
? text.slice(0, REACTION_TARGET_TEXT_CAP)
: text;
}

async function normalizeContent(content) {
if (!content || typeof content !== "object") {
return { type: "unknown" };
Expand All @@ -426,14 +455,18 @@ async function normalizeContent(content) {
return { type: "group", items };
}
if (content.type === "reaction") {
const target = content.target;
return {
type: "reaction",
emoji: content.emoji || "",
targetMessageId: content.target?.id ?? null,
targetMessageId: 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,
targetDirection: target?.direction ?? null,
// Text of the reacted-to message, so Python can correlate the tapback to
// the gateway's reply_to_text. Null for attachment/voice-only targets.
targetText: reactionTargetText(target),
};
}
return { type: content.type || "unknown" };
Expand Down
Loading
Loading