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`.
This commit is contained in:
underthestars-zhy 2026-06-08 18:54:22 -07:00 committed by Teknium
parent e79e44af79
commit 0d25cae041
2 changed files with 34 additions and 58 deletions

View file

@ -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:

View file

@ -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;