From fd674af47fa6f6599bc83f639cab166529796b3c Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 17 Jun 2026 17:14:24 -0400 Subject: [PATCH] fix(photon): preserve text in mixed iMessage attachments (salvage #46513) (#46818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(photon): preserve text in mixed iMessage attachments When an iMessage bubble carried both text and an attachment, spectrum-ts' inbound mapper returned only buildAttachmentMessage(...), dropping the user's typed text before Hermes could see it. The Photon adapter then had no 'group' content path, so the text was lost entirely. - adapter.py: handle a new 'group' content type that flattens text + attachment items, preserving the typed text alongside cached media (extracted shared _normalize_binary_payload helper). - sidecar: emit 'group' content in normalizeContent, and ship patch-spectrum-mixed-attachments.mjs which patches spectrum-ts' pinned mapper (at npm postinstall AND at sidecar startup, so existing installs self-heal). Windows robustness fixes on top of the original PR: - The patcher's CLI guard used 'import.meta.url === file://${argv[1]}', which never matches on Windows (file:/// + drive letter) — it silently no-opped. Switched to pathToFileURL(argv[1]).href. - The patcher matched \n-joined strings, so a CRLF checkout (Windows git autocrlf) defeated every replacement. It now normalizes CRLF->LF for matching and restores the original EOL style on write. Co-authored-by: Yuhang Lin * chore: map YuhangLin contributor email for attribution (#46513) --------- Co-authored-by: Yuhang Lin Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- plugins/platforms/photon/README.md | 11 +- plugins/platforms/photon/adapter.py | 119 +++++++++---- plugins/platforms/photon/sidecar/index.mjs | 32 +++- .../photon/sidecar/package-lock.json | 1 + plugins/platforms/photon/sidecar/package.json | 3 +- .../patch-spectrum-mixed-attachments.mjs | 155 +++++++++++++++++ scripts/release.py | 1 + .../plugins/platforms/photon/test_inbound.py | 50 ++++++ .../platforms/photon/test_spectrum_patch.py | 156 ++++++++++++++++++ 9 files changed, 491 insertions(+), 37 deletions(-) create mode 100644 plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs create mode 100644 tests/plugins/platforms/photon/test_spectrum_patch.py diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index 1989e271fb1..e92f46329d2 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -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` diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index e5dfd358ed6..01c1cabbc01 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -508,6 +508,38 @@ class PhotonAdapter(BasePlatformAdapter): 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 @@ -551,37 +583,40 @@ class PhotonAdapter(BasePlatformAdapter): 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 @@ -729,6 +764,28 @@ class PhotonAdapter(BasePlatformAdapter): # 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, diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index 0ca723764a5..85c3aa28736 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -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; @@ -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, @@ -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", diff --git a/plugins/platforms/photon/sidecar/package-lock.json b/plugins/platforms/photon/sidecar/package-lock.json index d76e7ccdf62..15c55d55d31 100644 --- a/plugins/platforms/photon/sidecar/package-lock.json +++ b/plugins/platforms/photon/sidecar/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@hermes-agent/photon-sidecar", "version": "0.3.0", + "hasInstallScript": true, "dependencies": { "spectrum-ts": "3.1.0" }, diff --git a/plugins/platforms/photon/sidecar/package.json b/plugins/platforms/photon/sidecar/package.json index d09b3c82dcf..314276f6384 100644 --- a/plugins/platforms/photon/sidecar/package.json +++ b/plugins/platforms/photon/sidecar/package.json @@ -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" diff --git a/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs b/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs new file mode 100644 index 00000000000..d4ffca83eea --- /dev/null +++ b/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs @@ -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); + } +} diff --git a/scripts/release.py b/scripts/release.py index 86a59245f5c..94756753ca2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # 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", diff --git a/tests/plugins/platforms/photon/test_inbound.py b/tests/plugins/platforms/photon/test_inbound.py index 7b8c39723d5..521bc26aa91 100644 --- a/tests/plugins/platforms/photon/test_inbound.py +++ b/tests/plugins/platforms/photon/test_inbound.py @@ -168,6 +168,56 @@ async def test_dispatch_attachment_downloads_image( cached.unlink(missing_ok=True) +@pytest.mark.asyncio +async def test_dispatch_group_preserves_text_and_attachment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Spectrum group content from a mixed text+image iMessage must not drop text.""" + adapter = _make_adapter(monkeypatch) + captured = _capture(adapter, monkeypatch) + raw = base64.b64decode(_PNG_1X1_B64) + + event = _attachment_event( + {}, + msg_id="spc-msg-mixed", + ) + event["content"] = { + "type": "group", + "items": [ + { + "id": "p:0/spc-msg-mixed", + "content": {"type": "text", "text": "请分析这张图的重点"}, + }, + { + "id": "p:1/spc-msg-mixed", + "content": { + "type": "attachment", + "name": "photo.png", + "mimeType": "image/png", + "size": len(raw), + "data": _PNG_1X1_B64, + "encoding": "base64", + }, + }, + ], + } + + await adapter._dispatch_inbound(event) + + assert len(captured) == 1 + ev = captured[0] + assert ev.text == "请分析这张图的重点" + assert ev.message_type == MessageType.PHOTO + assert ev.media_types == ["image/png"] + assert len(ev.media_urls) == 1 + cached = Path(ev.media_urls[0]) + try: + assert cached.is_file() + assert cached.read_bytes() == raw + finally: + cached.unlink(missing_ok=True) + + @pytest.mark.asyncio async def test_dispatch_voice_downloads_audio( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/plugins/platforms/photon/test_spectrum_patch.py b/tests/plugins/platforms/photon/test_spectrum_patch.py new file mode 100644 index 00000000000..2f1943fa119 --- /dev/null +++ b/tests/plugins/platforms/photon/test_spectrum_patch.py @@ -0,0 +1,156 @@ +"""Regression tests for Hermes' Spectrum mixed text+attachment workaround.""" +from __future__ import annotations + +import subprocess +import textwrap +from pathlib import Path + + +_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs") + + +def test_sidecar_applies_spectrum_patch_before_importing_sdk() -> None: + """Existing installs should self-heal at runtime, not only during npm postinstall.""" + index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8") + assert "import { patchSpectrumTs }" in index + assert "patchSpectrumTs();" in index + assert index.index("patchSpectrumTs();") < index.index('await import("spectrum-ts")') + + +def test_spectrum_patch_preserves_text_when_single_attachment(tmp_path: Path) -> None: + """The sidecar dependency patch must turn text+one attachment into group content.""" + dist = tmp_path / "node_modules" / "spectrum-ts" / "dist" + dist.mkdir(parents=True) + chunk = dist / "chunk-test.js" + chunk.write_text( + textwrap.dedent( + """ + var rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => { + const messageGuidStr = message.guid; + const timestamp = message.dateCreated ?? /* @__PURE__ */ new Date(); + const base = buildMessageBase(message, chatGuidHint, timestamp, phone); + const attachments = messageAttachments(message); + if (attachments.length === 1) { + const info = attachments[0]; + if (!info) { + throw new Error("Unreachable: attachments.length === 1 but no element"); + } + return buildAttachmentMessage(client, base, info, messageGuidStr, 0); + } + if (attachments.length > 1) { + const items = []; + for (let i = 0; i < attachments.length; i++) { + const info = attachments[i]; + if (!info) { + continue; + } + items.push( + await buildAttachmentMessage( + client, + base, + info, + formatChildId(i, messageGuidStr), + i, + messageGuidStr + ) + ); + } + return { + ...base, + id: messageGuidStr, + content: asProviderGroup(items) + }; + } + if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) { + return toRichlinkMessage(message, base, messageGuidStr); + } + const text2 = message.content.text; + return { + ...base, + id: messageGuidStr, + content: text2 ? asText(text2) : asCustom(message) + }; + }; + var toInboundMessages = async (client, cache, event, phone) => { + const base = buildMessageBase( + event.message, + event.chatGuid, + event.occurredAt, + phone + ); + const messageGuidStr = event.message.guid; + if (getBalloonBundleId(event.message) === URL_BALLOON_BUNDLE_ID) { + const msg2 = toRichlinkMessage(event.message, base, messageGuidStr); + cacheMessage(cache, msg2); + return [msg2]; + } + const attachments = messageAttachments(event.message); + if (attachments.length === 1) { + const info = attachments[0]; + if (!info) { + throw new Error("Unreachable: attachments.length === 1 but no element"); + } + const msg2 = await buildAttachmentMessage( + client, + base, + info, + messageGuidStr, + 0 + ); + cacheMessage(cache, msg2); + return [msg2]; + } + if (attachments.length > 1) { + const items = []; + for (let i = 0; i < attachments.length; i++) { + const info = attachments[i]; + if (!info) { + continue; + } + items.push( + await buildAttachmentMessage( + client, + base, + info, + formatChildId(i, messageGuidStr), + i, + messageGuidStr + ) + ); + } + const parent = { + ...base, + id: messageGuidStr, + content: asProviderGroup(items) + }; + cacheMessage(cache, parent); + return [parent]; + } + const text2 = event.message.content.text; + const msg = { + ...base, + id: messageGuidStr, + content: text2 ? asText(text2) : asCustom(event.message) + }; + cacheMessage(cache, msg); + return [msg]; + }; + """ + ), + encoding="utf-8", + ) + + result = subprocess.run( + ["node", str(_PATCHER), str(tmp_path)], + cwd=Path.cwd(), + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + patched = chunk.read_text(encoding="utf-8") + assert "Preserve mixed text + attachment iMessage payloads" in patched + assert "content: asProviderGroup([textMsg, msg2])" in patched + assert "content: asProviderGroup(items)" in patched + assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched