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
11 changes: 7 additions & 4 deletions plugins/platforms/photon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,13 @@ All env vars are documented in `plugin.yaml`. The most important:
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
adapter caches them to the shared media cache and populates `media_urls` /
`media_types`, so the agent sees the real image/file or can transcribe the
voice note — parity with the BlueBubbles iMessage channel. Media larger than
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
fails, falls back to a text marker (`[Photon attachment received: …]` or
`[Photon voice received: …]`) so the agent still knows something arrived.
voice note — parity with the BlueBubbles iMessage channel. Mixed iMessage
bubbles that contain both text and attachments are normalized as a grouped
payload so the user's typed text is preserved alongside the cached media.
Media larger than `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or
any byte read that fails, falls back to a text marker (`[Photon attachment
received: …]` or `[Photon voice received: …]`) so the agent still knows
something arrived.
- **Outbound attachments are supported.** Images, voice notes, video, and
documents are sent via `space.send(attachment(...))` /
`space.send(voice(...))` through the sidecar's `/send-attachment`
Expand Down
119 changes: 88 additions & 31 deletions plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,38 @@ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
media_urls: List[str] = []
media_types: List[str] = []

def _normalize_binary_payload(
payload: Dict[str, Any]
) -> tuple[str, MessageType, List[str], List[str]]:
is_voice = payload.get("type") == "voice"
name = payload.get("name") or ("voice" if is_voice else "(unnamed)")
mime = payload.get("mimeType") or ""
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
cached = _cache_inbound_attachment(
payload, name, mime, force_audio=is_voice
)
if cached:
return (
"(voice)" if is_voice else "(attachment)",
mtype,
[cached],
[mime or ("audio/mp4" if is_voice else "application/octet-stream")],
)
label = "voice" if is_voice else "attachment"
duration = payload.get("duration")
duration_text = (
f", duration: {duration}s"
if isinstance(duration, (int, float))
else ""
)
return (
f"[Photon {label} received: {name} "
f"({mime or 'unknown MIME'}{duration_text})]",
mtype,
[],
[],
)

ctype = content.get("type")
if ctype == "reaction":
# Route only tapbacks on messages WE sent — those are implicitly
Expand Down Expand Up @@ -551,37 +583,40 @@ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
text = content.get("text") or ""
mtype = MessageType.TEXT
elif ctype in {"attachment", "voice"}:
is_voice = ctype == "voice"
name = content.get("name") or ("voice" if is_voice else "(unnamed)")
mime = content.get("mimeType") or ""
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
cached = _cache_inbound_attachment(
content, name, mime, force_audio=is_voice
)
if cached:
media_urls.append(cached)
media_types.append(
mime or ("audio/mp4" if is_voice else "application/octet-stream")
)
# The real bytes are attached, so the agent sees the media
# itself — a short marker is enough text, and it keeps group
# mention-gating consistent with plain messages.
text = "(voice)" if is_voice else "(attachment)"
else:
# No bytes (over the sidecar cap, a failed read, or a caching
# failure) — fall back to a metadata marker so the agent still
# knows something arrived.
label = "voice" if is_voice else "attachment"
duration = content.get("duration")
duration_text = (
f", duration: {duration}s"
if isinstance(duration, (int, float))
else ""
)
text = (
f"[Photon {label} received: {name} "
f"({mime or 'unknown MIME'}{duration_text})]"
)
text, mtype, media_urls, media_types = _normalize_binary_payload(content)
elif ctype == "group":
text_parts: List[str] = []
mtype = MessageType.TEXT
for item in content.get("items") or []:
if not isinstance(item, dict):
continue
item_content = item.get("content") or {}
if not isinstance(item_content, dict):
continue
item_type = item_content.get("type")
if item_type == "text":
item_text = item_content.get("text") or ""
if item_text:
text_parts.append(item_text)
continue
if item_type in {"attachment", "voice"}:
marker, item_mtype, item_urls, item_types = _normalize_binary_payload(
item_content
)
if mtype == MessageType.TEXT:
mtype = item_mtype
media_urls.extend(item_urls)
media_types.extend(item_types)
if not item_urls:
text_parts.append(marker)
continue
if item_type:
text_parts.append(f"[Photon content type not handled: {item_type}]")
if media_urls and mtype == MessageType.TEXT:
mtype = MessageType.DOCUMENT
text = "\n".join(part for part in text_parts if part).strip()
if not text:
text = "(attachment)" if media_urls else "[Photon empty group received]"
else:
text = f"[Photon content type not handled: {ctype}]"
mtype = MessageType.TEXT
Expand Down Expand Up @@ -729,6 +764,28 @@ async def _start_sidecar(self) -> None:
# never runs — can't leave it orphaned on the port.
env["PHOTON_SIDECAR_WATCH_STDIN"] = "1"

try:
patch = subprocess.run( # noqa: S603
[
self._node_bin,
str(_SIDECAR_DIR / "patch-spectrum-mixed-attachments.mjs"),
str(_SIDECAR_DIR),
],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if patch.returncode != 0:
raise RuntimeError((patch.stderr or patch.stdout or "").strip())
if patch.stderr.strip():
logger.debug("[photon] %s", patch.stderr.strip())
except Exception as exc:
logger.warning(
"[photon] failed to apply Spectrum mixed attachment patch: %s",
exc,
)

self._sidecar_proc = subprocess.Popen( # noqa: S603
[self._node_bin, str(_SIDECAR_DIR / "index.mjs")],
stdin=subprocess.PIPE,
Expand Down
32 changes: 31 additions & 1 deletion plugins/platforms/photon/sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import http from "node:http";
import crypto from "node:crypto";
import { once } from "node:events";
import { patchSpectrumTs } from "./patch-spectrum-mixed-attachments.mjs";

const projectId = process.env.PHOTON_PROJECT_ID;
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
Expand Down Expand Up @@ -89,7 +90,26 @@ 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.
// instead of a cryptic module-resolution error during import. Apply Hermes'
// pinned-sdk compatibility patch first so existing installs self-heal at
// runtime, not only during npm postinstall.
try {
const patchResult = patchSpectrumTs();
if (patchResult.patched) {
console.error(
`photon-sidecar: spectrum mixed attachment patch applied: ${patchResult.file}`
);
}
} catch (e) {
console.error(
"photon-sidecar: spectrum mixed attachment patch failed. " +
"Run `npm install` inside plugins/platforms/photon/sidecar/ or " +
"upgrade the Photon sidecar patch for the pinned spectrum-ts version. " +
"Original error: " +
(e && e.stack ? e.stack : String(e))
);
process.exit(3);
}
let Spectrum,
imessage,
attachment,
Expand Down Expand Up @@ -273,6 +293,16 @@ async function normalizeContent(content) {
if (content.type === "attachment" || content.type === "voice") {
return await normalizeBinaryContent(content);
}
if (content.type === "group") {
const items = [];
for (const item of Array.isArray(content.items) ? content.items : []) {
items.push({
id: item && typeof item === "object" ? item.id ?? null : null,
content: await normalizeContent(item?.content),
});
}
return { type: "group", items };
}
if (content.type === "reaction") {
return {
type: "reaction",
Expand Down
1 change: 1 addition & 0 deletions plugins/platforms/photon/sidecar/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion plugins/platforms/photon/sidecar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"type": "module",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs"
"start": "node index.mjs",
"postinstall": "node patch-spectrum-mixed-attachments.mjs"
},
"engines": {
"node": ">=18.17"
Expand Down
155 changes: 155 additions & 0 deletions plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env node
// Patch spectrum-ts' iMessage inbound mapper until upstream preserves mixed
// text + attachment Apple events. The current spectrum-ts mapper returns only
// buildAttachmentMessage(...) whenever attachments are present, which drops
// event.message.content.text before Hermes can see it.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const MARKER = "Hermes patch: Preserve mixed text + attachment iMessage payloads";

function scriptDir() {
return path.dirname(fileURLToPath(import.meta.url));
}

function replaceOnce(source, from, to, label) {
const count = source.split(from).length - 1;
if (count !== 1) {
throw new Error(`expected exactly one ${label} match, found ${count}`);
}
return source.replace(from, to);
}

function replaceFirst(source, from, to, label) {
if (!source.includes(from)) {
throw new Error(`expected at least one ${label} match, found 0`);
}
return source.replace(from, to);
}

function addTextChildSnippet(messageExpr) {
return `if (text2) {\n items.unshift({\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n });\n }`;
}

function patchRebuild(source) {
source = replaceOnce(
source,
` const attachments = messageAttachments(message);\n if (attachments.length === 1) {`,
` const attachments = messageAttachments(message);\n const text2 = message.content.text;\n if (attachments.length === 1) {`,
"rebuild text capture"
);
source = replaceOnce(
source,
` return buildAttachmentMessage(client, base, info, messageGuidStr, 0);`,
` const msg2 = await buildAttachmentMessage(\n client,\n base,\n info,\n text2 ? formatChildId(1, messageGuidStr) : messageGuidStr,\n text2 ? 1 : 0,\n text2 ? messageGuidStr : void 0\n );\n if (text2) {\n const textMsg = {\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n };\n return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup([textMsg, msg2])\n };\n }\n return msg2;`,
"rebuild single attachment"
);
source = replaceFirst(
source,
` formatChildId(i, messageGuidStr),\n i,\n messageGuidStr`,
` formatChildId(text2 ? i + 1 : i, messageGuidStr),\n text2 ? i + 1 : i,\n messageGuidStr`,
"rebuild multi attachment child index"
);
source = replaceFirst(
source,
` return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };\n }\n if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) {`,
` ${addTextChildSnippet("message")}\n return {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };\n }\n if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) {`,
"rebuild multi attachment text child"
);
source = replaceFirst(
source,
` const text2 = message.content.text;\n return {\n ...base,`,
` return {\n ...base,`,
"rebuild duplicate text declaration"
);
return source;
}

function patchInbound(source) {
source = replaceOnce(
source,
` const attachments = messageAttachments(event.message);\n if (attachments.length === 1) {`,
` const attachments = messageAttachments(event.message);\n const text2 = event.message.content.text;\n if (attachments.length === 1) {`,
"inbound text capture"
);
source = replaceOnce(
source,
` messageGuidStr,\n 0\n );\n cacheMessage(cache, msg2);\n return [msg2];`,
` text2 ? formatChildId(1, messageGuidStr) : messageGuidStr,\n text2 ? 1 : 0,\n text2 ? messageGuidStr : void 0\n );\n if (text2) {\n const textMsg = {\n ...base,\n id: formatChildId(0, messageGuidStr),\n content: asText(text2),\n partIndex: 0,\n parentId: messageGuidStr\n };\n const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup([textMsg, msg2])\n };\n cacheMessage(cache, parent);\n return [parent];\n }\n cacheMessage(cache, msg2);\n return [msg2];`,
"inbound single attachment"
);
source = replaceOnce(
source,
` formatChildId(i, messageGuidStr),\n i,\n messageGuidStr`,
` formatChildId(text2 ? i + 1 : i, messageGuidStr),\n text2 ? i + 1 : i,\n messageGuidStr`,
"inbound multi attachment child index"
);
source = replaceOnce(
source,
` const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };`,
` ${addTextChildSnippet("event.message")}\n const parent = {\n ...base,\n id: messageGuidStr,\n content: asProviderGroup(items)\n };`,
"inbound multi attachment text child"
);
source = replaceOnce(
source,
` const text2 = event.message.content.text;\n const msg = {`,
` const msg = {`,
"inbound duplicate text declaration"
);
return source;
}

export function patchSpectrumTs(root = scriptDir()) {
const dist = path.join(root, "node_modules", "spectrum-ts", "dist");
if (!fs.existsSync(dist)) {
throw new Error(`spectrum-ts dist not found: ${dist}`);
}
const files = fs.readdirSync(dist)
.filter((name) => name.endsWith(".js"))
.map((name) => path.join(dist, name));

for (const file of files) {
const raw = fs.readFileSync(file, "utf8");
if (raw.includes(MARKER)) {
return { patched: false, file, reason: "already patched" };
}
// Normalize to LF for matching so the patch works regardless of the
// checkout's line-ending style (Windows git autocrlf produces CRLF,
// which would otherwise defeat the \n-based search strings). The
// original EOL style is restored on write.
const CR = String.fromCharCode(13);
const CRLF = CR + "\n";
const usedCRLF = raw.includes(CRLF);
const original = usedCRLF ? raw.split(CRLF).join("\n") : raw;
if (!original.includes("var toInboundMessages = async") ||
!original.includes("var rebuildFromAppleMessage = async")) {
continue;
}
let patched = original;
patched = patchRebuild(patched);
patched = patchInbound(patched);
patched = `// ${MARKER}\n${patched}`;
if (usedCRLF) {
patched = patched.split("\n").join(CRLF);
}
fs.writeFileSync(file, patched, "utf8");
return { patched: true, file };
}
throw new Error("could not find spectrum-ts iMessage inbound chunk to patch");
}

const _invokedDirectly =
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (_invokedDirectly) {
try {
const root = process.argv[2] ? path.resolve(process.argv[2]) : scriptDir();
const result = patchSpectrumTs(root);
const action = result.patched ? "patched" : "ok";
console.error(`photon-sidecar: spectrum mixed attachment patch ${action}: ${result.file}`);
} catch (err) {
console.error(`photon-sidecar: spectrum mixed attachment patch failed: ${err?.stack || err}`);
process.exit(1);
}
}
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"yuhanglin@YuhangdeMac-mini.local": "1960697431",
"despitemeguru@gmail.com": "definitelynotguru",
"chaslui@outlook.com": "ChasLui",
"rio.jeong@thebytesize.ai": "rio-jeong",
Expand Down
Loading
Loading