From 0d25cae0411f56eda119ed6ccc4d730863b63bbe Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Mon, 8 Jun 2026 18:54:22 -0700 Subject: [PATCH] fix(photon): remove reply-to support and fix typing API Drop `replyTo` from all outbound send paths and update the `/typing` endpoint to use the documented `typing("start" | "stop")` content builder. Adds a `stop_typing` method on the adapter to pair with `send_typing`. --- plugins/platforms/photon/adapter.py | 33 ++++++------ plugins/platforms/photon/sidecar/index.mjs | 59 ++++++---------------- 2 files changed, 34 insertions(+), 58 deletions(-) diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index ae0ab8b25be..81dfbbef3f2 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -629,7 +629,7 @@ class PhotonAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - return await self._sidecar_send(chat_id, content, reply_to=reply_to) + return await self._sidecar_send(chat_id, content) # -- Outbound media (parity with the BlueBubbles iMessage channel) ----- # @@ -655,7 +655,7 @@ class PhotonAdapter(BasePlatformAdapter): # 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, reply_to=reply_to, + chat_id, local_path, caption=caption, ) async def send_image_file( @@ -668,7 +668,7 @@ class PhotonAdapter(BasePlatformAdapter): **kwargs, ) -> SendResult: return await self._sidecar_send_attachment( - chat_id, image_path, caption=caption, reply_to=reply_to, + chat_id, image_path, caption=caption, ) async def send_voice( @@ -681,7 +681,7 @@ class PhotonAdapter(BasePlatformAdapter): **kwargs, ) -> SendResult: return await self._sidecar_send_attachment( - chat_id, audio_path, caption=caption, reply_to=reply_to, kind="voice", + chat_id, audio_path, caption=caption, kind="voice", ) async def send_video( @@ -694,7 +694,7 @@ class PhotonAdapter(BasePlatformAdapter): **kwargs, ) -> SendResult: return await self._sidecar_send_attachment( - chat_id, video_path, caption=caption, reply_to=reply_to, + chat_id, video_path, caption=caption, ) async def send_document( @@ -708,7 +708,7 @@ class PhotonAdapter(BasePlatformAdapter): **kwargs, ) -> SendResult: return await self._sidecar_send_attachment( - chat_id, file_path, name=file_name, caption=caption, reply_to=reply_to, + chat_id, file_path, name=file_name, caption=caption, ) async def send_animation( @@ -726,10 +726,20 @@ class PhotonAdapter(BasePlatformAdapter): async def send_typing(self, chat_id: str, metadata=None) -> None: try: - await self._sidecar_call("/typing", {"spaceId": chat_id}) + await self._sidecar_call( + "/typing", {"spaceId": chat_id, "state": "start"} + ) except Exception as e: logger.debug("[photon] send_typing failed: %s", e) + async def stop_typing(self, chat_id: str) -> None: + try: + await self._sidecar_call( + "/typing", {"spaceId": chat_id, "state": "stop"} + ) + except Exception as e: + logger.debug("[photon] stop_typing failed: %s", e) + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return whatever we know about a Spectrum space id. @@ -738,9 +748,7 @@ class PhotonAdapter(BasePlatformAdapter): """ return {"name": chat_id, "type": "dm", "id": chat_id} - async def _sidecar_send( - self, space_id: str, text: str, *, reply_to: Optional[str] = None, - ) -> SendResult: + async def _sidecar_send(self, space_id: str, text: str) -> SendResult: if len(text) > self.MAX_MESSAGE_LENGTH: logger.warning( "[photon] truncating outbound from %d to %d chars", @@ -748,8 +756,6 @@ class PhotonAdapter(BasePlatformAdapter): ) text = text[: self.MAX_MESSAGE_LENGTH] body: Dict[str, Any] = {"spaceId": space_id, "text": text} - if reply_to: - body["replyTo"] = reply_to try: data = await self._sidecar_call("/send", body) except Exception as e: @@ -764,7 +770,6 @@ class PhotonAdapter(BasePlatformAdapter): name: Optional[str] = None, mime_type: Optional[str] = None, caption: Optional[str] = None, - reply_to: Optional[str] = None, kind: str = "attachment", ) -> SendResult: """POST a local file to the sidecar's ``/send-attachment`` endpoint. @@ -799,8 +804,6 @@ class PhotonAdapter(BasePlatformAdapter): body["mimeType"] = mime_type if caption: body["caption"] = caption - if reply_to: - body["replyTo"] = reply_to try: data = await self._sidecar_call("/send-attachment", body) except Exception as e: diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index dfc2d6a193a..30f4ff5b381 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -11,21 +11,21 @@ // loopback `GET /inbound` (NDJSON). We pause pulling from the stream while // no consumer is attached so a backlog isn't pulled-and-lost before the // gateway connects. -// Outbound (Hermes -> gRPC): `/send` and `/typing` drive `space.send(...)` / -// `space.startTyping()` on the SDK. +// Outbound (Hermes -> gRPC): `/send` drives `space.send(...)`; `/typing` +// sends the documented `typing("start" | "stop")` content builder. // // Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`): // - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank // lines are heartbeats. One consumer at a time. // - POST /healthz -> {"ok": true} // - POST /send -> {"ok": true, "messageId": "..."} -// body: {"spaceId": "...", "text": "...", "replyTo": "..." | null} +// body: {"spaceId": "...", "text": "..."} // - POST /send-attachment -> {"ok": true, "messageId": "..."} // body: {"spaceId": "...", "path": "...", "name": "..." | null, // "mimeType": "..." | null, "caption": "..." | null, -// "kind": "attachment" | "voice", "replyTo": "..." | null} +// "kind": "attachment" | "voice"} // - POST /typing -> {"ok": true} -// body: {"spaceId": "..."} +// body: {"spaceId": "...", "state": "start" | "stop"} // - POST /shutdown -> {"ok": true}; then process exits // // On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before @@ -69,14 +69,14 @@ 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. -let Spectrum, imessage, attachment, voice, spectrumReply, spectrumText; +let Spectrum, imessage, attachment, voice, spectrumText, spectrumTyping; try { ({ Spectrum, attachment, voice, - reply: spectrumReply, text: spectrumText, + typing: spectrumTyping, } = await import("spectrum-ts")); ({ imessage } = await import("spectrum-ts/providers/imessage")); } catch (e) { @@ -401,28 +401,6 @@ async function resolveSpace(spaceId) { throw new Error(`unable to resolve space id ${spaceId}`); } -async function maybeReplyContent(space, builder, replyTo) { - if (!replyTo) return builder; - if (typeof space.getMessage !== "function") { - console.error("photon-sidecar: reply requested but space.getMessage is unavailable"); - return builder; - } - try { - const target = await space.getMessage(replyTo); - if (!target) { - console.error(`photon-sidecar: reply target ${replyTo} not found; sending normally`); - return builder; - } - return spectrumReply(builder, target); - } catch (e) { - console.error( - "photon-sidecar: failed to resolve reply target; sending normally: " + - (e && e.stack ? e.stack : String(e)) - ); - return builder; - } -} - const server = http.createServer(async (req, res) => { if (req.headers["x-hermes-sidecar-token"] !== sharedToken) { return unauthorized(res); @@ -446,17 +424,16 @@ const server = http.createServer(async (req, res) => { } const body = await readBody(req); if (req.url === "/send") { - const { spaceId, text, replyTo } = body || {}; + const { spaceId, text } = body || {}; if (!spaceId || typeof text !== "string") { return badRequest(res, "spaceId and text are required"); } const space = await resolveSpace(spaceId); - const content = await maybeReplyContent(space, spectrumText(text), replyTo); - const result = await space.send(content); + const result = await space.send(spectrumText(text)); return ok(res, { messageId: result?.id || result?.messageId || null }); } if (req.url === "/send-attachment") { - const { spaceId, path, name, mimeType, caption, kind, replyTo } = + const { spaceId, path, name, mimeType, caption, kind } = body || {}; if (!spaceId || typeof path !== "string" || !path) { return badRequest(res, "spaceId and path are required"); @@ -474,8 +451,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 content = await maybeReplyContent(space, builder, replyTo); - const result = await space.send(content); + const result = await space.send(builder); // iMessage delivers the caption as a separate bubble; send it // after the media so the attachment renders first. @@ -492,16 +468,13 @@ const server = http.createServer(async (req, res) => { return ok(res, { messageId: result?.id || result?.messageId || null }); } if (req.url === "/typing") { - const { spaceId } = body || {}; + const { spaceId, state = "start" } = body || {}; if (!spaceId) return badRequest(res, "spaceId is required"); - const space = await resolveSpace(spaceId); - if (typeof space.startTyping === "function") { - await space.startTyping(); - } else if (typeof space.typing === "function") { - await space.typing(); - } else if (typeof space.setTyping === "function") { - await space.setTyping(true); + if (state !== "start" && state !== "stop") { + return badRequest(res, "state must be start or stop"); } + const space = await resolveSpace(spaceId); + await space.send(spectrumTyping(state)); return ok(res, {}); } res.statusCode = 404;