Skip to content
Draft
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
4 changes: 2 additions & 2 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
# Audio file extensions Hermes recognizes for native audio delivery.
# Kept in sync with tools/send_message_tool.py and cron/scheduler.py via
# should_send_media_as_audio() below.
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.caf', '.flac'})
# Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio
# formats either need to go through sendVoice (Opus/OGG) or must be
# delivered as a regular document.
Expand Down Expand Up @@ -1435,7 +1435,7 @@ def _log_safe_path(path: str) -> str:
# Video (embed inline where supported)
".mp4", ".mov", ".avi", ".mkv", ".webm",
# Audio (delivered as voice/audio where supported)
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".caf", ".flac",
# Documents (uploaded as file attachments)
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
# Spreadsheets / data
Expand Down
90 changes: 83 additions & 7 deletions plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ class PhotonAdapter(BasePlatformAdapter):
"""

MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
# Photon can send edits upstream, but this adapter does not yet implement
# edit_message(). Opt out of progressive streaming so a partial MEDIA:
# control line can never become an irreversible visible iMessage.
SUPPORTS_MESSAGE_EDITING = False

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("photon"))
Expand Down Expand Up @@ -997,7 +1001,9 @@ async def send(
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
return await self._sidecar_send(chat_id, self.format_message(content))
return await self._sidecar_send(
chat_id, self.format_message(content), reply_to=reply_to
)

# -- Outbound media (parity with the BlueBubbles iMessage channel) -----
#
Expand All @@ -1023,7 +1029,7 @@ async def send_image(
# Couldn't fetch the URL — fall back to sending it as text.
return await super().send_image(chat_id, image_url, caption, reply_to)
return await self._sidecar_send_attachment(
chat_id, local_path, caption=caption,
chat_id, local_path, caption=caption, reply_to=reply_to,
)

async def send_image_file(
Expand All @@ -1036,7 +1042,7 @@ async def send_image_file(
**kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(
chat_id, image_path, caption=caption,
chat_id, image_path, caption=caption, reply_to=reply_to,
)

async def send_voice(
Expand All @@ -1048,10 +1054,69 @@ async def send_voice(
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
prepared_path, mime_type = await self._prepare_imessage_voice(audio_path)
return await self._sidecar_send_attachment(
chat_id, audio_path, caption=caption, kind="voice",
chat_id,
prepared_path,
name=Path(prepared_path).name,
mime_type=mime_type,
caption=caption,
kind="voice",
reply_to=reply_to,
)

async def _prepare_imessage_voice(self, audio_path: str) -> tuple[str, Optional[str]]:
"""Normalize generated audio to the documented iMessage voice shape.

Photon accepts ``voice(path, {mimeType})`` and documents M4A with
``audio/mp4`` as the portable iMessage form. Hermes TTS commonly emits
MP3, while inbound Apple voice notes arrive as CAF. Convert either to
AAC/M4A before sending so Messages renders a voice note instead of an
opaque audio attachment. If ffmpeg is unavailable or conversion fails,
retain the original path and let Spectrum perform its normal fallback.
"""
source = Path(audio_path).expanduser()
if source.suffix.lower() in {".m4a", ".mp4"}:
return str(source), "audio/mp4"

ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
logger.warning("[photon] ffmpeg unavailable; sending voice in source format")
return str(source), None

output = source.with_name(f"{source.stem}-imessage.m4a")
proc = await asyncio.create_subprocess_exec(
ffmpeg,
"-y",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-y overwrites an existing <stem>-imessage.m4a sibling. If conversion then fails, lines 1103-1111 unlink that path, so sending voice.mp3 can replace and delete a pre-existing voice-imessage.m4a. Please use a unique temporary/cache output and clean up only the file created by this invocation.

"-loglevel",
"error",
"-i",
str(source),
"-vn",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(output),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0 or not output.is_file() or output.stat().st_size == 0:
logger.warning(
"[photon] voice normalization failed; sending source format: %s",
stderr.decode("utf-8", errors="replace")[:300],
)
try:
output.unlink(missing_ok=True)
except OSError:
pass
return str(source), None

return str(output), "audio/mp4"

async def send_video(
self,
chat_id: str,
Expand All @@ -1062,7 +1127,7 @@ async def send_video(
**kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(
chat_id, video_path, caption=caption,
chat_id, video_path, caption=caption, reply_to=reply_to,
)

async def send_document(
Expand All @@ -1076,7 +1141,11 @@ async def send_document(
**kwargs,
) -> SendResult:
return await self._sidecar_send_attachment(
chat_id, file_path, name=file_name, caption=caption,
chat_id,
file_path,
name=file_name,
caption=caption,
reply_to=reply_to,
)

async def send_animation(
Expand Down Expand Up @@ -1378,14 +1447,18 @@ async def _send_with_retry(
logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error)
return fallback_result

async def _sidecar_send(self, space_id: str, text: str) -> SendResult:
async def _sidecar_send(
self, space_id: str, text: str, *, reply_to: Optional[str] = None
) -> SendResult:
if len(text) > self.MAX_MESSAGE_LENGTH:
logger.warning(
"[photon] truncating outbound from %d to %d chars",
len(text), self.MAX_MESSAGE_LENGTH,
)
text = text[: self.MAX_MESSAGE_LENGTH]
body: Dict[str, Any] = {"spaceId": space_id, "text": text}
if reply_to:
body["replyToId"] = reply_to
# Omit the key when disabled so an older sidecar (pre-`format`)
# keeps accepting the body during a half-upgraded restart.
if _markdown_enabled():
Expand All @@ -1406,6 +1479,7 @@ async def _sidecar_send_attachment(
mime_type: Optional[str] = None,
caption: Optional[str] = None,
kind: str = "attachment",
reply_to: Optional[str] = None,
) -> SendResult:
"""POST a local file to the sidecar's ``/send-attachment`` endpoint.

Expand Down Expand Up @@ -1439,6 +1513,8 @@ async def _sidecar_send_attachment(
body["mimeType"] = mime_type
if caption:
body["caption"] = caption
if reply_to:
body["replyToId"] = reply_to
try:
data = await self._sidecar_call("/send-attachment", body)
except Exception as e:
Expand Down
22 changes: 18 additions & 4 deletions plugins/platforms/photon/sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,20 @@ function tokenOk(header) {
return h.length === _tokenBuf.length && crypto.timingSafeEqual(h, _tokenBuf);
}

async function sendWithOptionalReply(space, builder, replyToId) {
if (!replyToId) return await space.send(builder);

const target =
knownMessages.get(replyToId) ?? (await space.getMessage(replyToId));
if (!target) {
console.error(
`photon-sidecar: reply target ${replyToId} not found; sending fresh message`
);
return await space.send(builder);
}
return await target.reply(builder);
}

const server = http.createServer(async (req, res) => {
if (!tokenOk(req.headers["x-hermes-sidecar-token"])) {
return unauthorized(res);
Expand All @@ -720,7 +734,7 @@ const server = http.createServer(async (req, res) => {
}
const body = await readBody(req);
if (req.url === "/send") {
const { spaceId, text, format = "text" } = body || {};
const { spaceId, text, format = "text", replyToId } = body || {};
if (!spaceId || typeof text !== "string") {
return badRequest(res, "spaceId and text are required");
}
Expand All @@ -732,11 +746,11 @@ const server = http.createServer(async (req, res) => {
// readable plain text on platforms that don't.
const builder =
format === "markdown" ? spectrumMarkdown(text) : spectrumText(text);
const result = await space.send(builder);
const result = await sendWithOptionalReply(space, builder, replyToId);
return ok(res, { messageId: result?.id || null });
}
if (req.url === "/send-attachment") {
const { spaceId, path, name, mimeType, caption, kind } =
const { spaceId, path, name, mimeType, caption, kind, replyToId } =
body || {};
if (!spaceId || typeof path !== "string" || !path) {
return badRequest(res, "spaceId and path are required");
Expand All @@ -754,7 +768,7 @@ const server = http.createServer(async (req, res) => {
? voice(path, Object.keys(opts).length ? opts : undefined)
: attachment(path, Object.keys(opts).length ? opts : undefined);

const result = await space.send(builder);
const result = await sendWithOptionalReply(space, builder, replyToId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This threads the attachment, but the caption immediately below is still sent with bare space.send() at line 777. For any attachment with a caption and replyToId, the caption escapes the native reply. Route it through sendWithOptionalReply as well and add coverage.


// iMessage delivers the caption as a separate bubble; send it
// after the media so the attachment renders first.
Expand Down
9 changes: 8 additions & 1 deletion tests/gateway/test_platform_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1395,7 +1395,7 @@ def test_unknown_extension_returns_false(self):

def test_non_telegram_platforms_route_all_audio(self):
from gateway.platforms.base import should_send_media_as_audio
for ext in (".mp3", ".m4a", ".wav", ".flac", ".ogg", ".opus"):
for ext in (".mp3", ".m4a", ".wav", ".caf", ".flac", ".ogg", ".opus"):
assert should_send_media_as_audio("discord", ext) is True
assert should_send_media_as_audio("slack", ext) is True

Expand Down Expand Up @@ -1423,6 +1423,13 @@ def test_accepts_platform_enum(self):
assert should_send_media_as_audio(Platform.TELEGRAM, ".flac") is False
assert should_send_media_as_audio(Platform.DISCORD, ".flac") is True

def test_caf_media_directive_is_hidden_from_display(self):
from gateway.platforms.base import BasePlatformAdapter, MEDIA_DELIVERY_EXTS

assert ".caf" in MEDIA_DELIVERY_EXTS
text = "MEDIA:/Users/example/.hermes/audio_cache/reply.caf"
assert BasePlatformAdapter.strip_media_directives_for_display(text) == ""


# ---------------------------------------------------------------------------
# truncate_message
Expand Down
39 changes: 38 additions & 1 deletion tests/plugins/platforms/photon/test_outbound_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
return calls


def test_photon_disables_progressive_text_streaming() -> None:
assert PhotonAdapter.SUPPORTS_MESSAGE_EDITING is False


@pytest.fixture()
def real_file(tmp_path) -> str:
p = tmp_path / "photo.jpg"
Expand Down Expand Up @@ -88,12 +92,45 @@ async def test_send_voice_marks_kind_voice(
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)

result = await adapter.send_voice("any;-;+1", str(audio))
result = await adapter.send_voice(
"any;-;+1", str(audio), reply_to="spc-msg-inbound"
)

assert result.success is True
path, body = calls[0]
assert path == "/send-attachment"
assert body["kind"] == "voice"
assert body["path"] == str(audio)
assert body["name"] == "note.m4a"
assert body["mimeType"] == "audio/mp4"
assert body["replyToId"] == "spc-msg-inbound"


@pytest.mark.asyncio
async def test_send_voice_uses_normalized_imessage_audio(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
_patch_safe_path(monkeypatch)
source = tmp_path / "note.mp3"
source.write_bytes(b"fake-mp3")
normalized = tmp_path / "note-imessage.m4a"
normalized.write_bytes(b"fake-m4a")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)

async def _fake_prepare(path: str) -> tuple[str, str]:
assert path == str(source)
return str(normalized), "audio/mp4"

monkeypatch.setattr(adapter, "_prepare_imessage_voice", _fake_prepare)

result = await adapter.send_voice("any;-;+1", str(source))

assert result.success is True
_, body = calls[0]
assert body["path"] == str(normalized)
assert body["kind"] == "voice"
assert body["mimeType"] == "audio/mp4"


@pytest.mark.asyncio
Expand Down