* 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 <yuhanglin@YuhangdeMac-mini.local>
* chore: map YuhangLin contributor email for attribution (#46513)
---------
Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
co-authored by
Yuhang Lin
Teknium
parent
7fbb8c9df5
commit
fd674af47f
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user