From 077c583c75d420a382a1a54cece2f6891712157b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 10 Jun 2026 16:50:48 +0000 Subject: [PATCH] feat(photon): add native poll sending --- contributors/emails/hermes@kortify.local | 2 ++ plugins/platforms/photon/README.md | 6 ++-- plugins/platforms/photon/adapter.py | 23 +++++++++++++ plugins/platforms/photon/sidecar/index.mjs | 21 +++++++++++- .../platforms/photon/test_outbound_media.py | 33 +++++++++++++++++++ website/docs/user-guide/messaging/photon.md | 2 ++ 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 contributors/emails/hermes@kortify.local diff --git a/contributors/emails/hermes@kortify.local b/contributors/emails/hermes@kortify.local new file mode 100644 index 00000000000..0bd38042533 --- /dev/null +++ b/contributors/emails/hermes@kortify.local @@ -0,0 +1,2 @@ +arnoldfrancisca +# PR #43665/#43718 salvage diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index c1f558ad6e5..bef7a68a2cf 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -153,8 +153,10 @@ All env vars are documented in `plugin.yaml`. The most important: restart is best-effort — the live reaction handle is lost, so a stale tapback heals when the next reaction replaces it. Group spaces stay reachable across restarts via spectrum-ts' `space.get(id)`. -- **Message effects, polls** — supported by `spectrum-ts` but not yet - exposed; the sidecar is the natural place to add them. +- **Native polls are supported.** Hermes posts poll content through + `spectrum-ts`' `poll(...)` builder via the sidecar's `/send-poll` endpoint. +- **Message effects** — supported by `spectrum-ts` but not yet exposed; the + sidecar is the natural place to add them. - **Cron/standalone sends require a running gateway.** Processes outside the gateway (cron subprocesses, `hermes send`) cannot spawn the sidecar; they authenticate to the gateway's live sidecar via the runtime record at diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 476430e41ef..ea42da19f57 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -1547,6 +1547,29 @@ class PhotonAdapter(BasePlatformAdapter): chat_id, animation_url, caption, reply_to, metadata, ) + async def send_poll( + self, + chat_id: str, + title: str, + options: list[str], + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a native iMessage poll through Photon Spectrum.""" + choices = [str(option).strip() for option in options if str(option).strip()] + if len(choices) < 2: + return SendResult( + success=False, + error="Photon polls require at least two non-empty options", + ) + try: + data = await self._sidecar_call( + "/send-poll", + {"spaceId": chat_id, "title": title.strip(), "options": choices}, + ) + except Exception as e: + return SendResult(success=False, error=str(e)) + return SendResult(success=True, message_id=data.get("messageId")) + async def send_typing(self, chat_id: str, metadata=None) -> None: now = time.time() if now - self._typing_last_sent.get(chat_id, 0.0) < _TYPING_COOLDOWN_SECONDS: diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index 4c9ece8f0ca..18af5cab0a9 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -31,6 +31,8 @@ // - POST /unreact -> {"ok": true} | 400 soft failure // body: {"spaceId": "...", "messageId": "", // "reactionId": "..." | null (restart-recovery fallback)} +// - POST /send-poll -> {"ok": true, "messageId": "..."} +// body: {"spaceId": "...", "title": "...", "options": ["...", "..."]} // - POST /typing -> {"ok": true} // body: {"spaceId": "...", "state": "start" | "stop"} // - POST /shutdown -> {"ok": true}; then process exits @@ -237,12 +239,14 @@ let Spectrum, voice, spectrumText, spectrumMarkdown, - spectrumTyping; + spectrumTyping, + spectrumPoll; try { ({ Spectrum, attachment, voice, + poll: spectrumPoll, text: spectrumText, markdown: spectrumMarkdown, typing: spectrumTyping, @@ -879,6 +883,21 @@ const server = http.createServer(async (req, res) => { } return badRequest(res, "no tracked reaction for message"); } + if (req.url === "/send-poll") { + const { spaceId, title, options } = body || {}; + const choices = Array.isArray(options) + ? options.map((option) => String(option || "").trim()).filter(Boolean) + : []; + if (!spaceId || typeof title !== "string" || !title.trim()) { + return badRequest(res, "spaceId and title are required"); + } + if (choices.length < 2) { + return badRequest(res, "options must contain at least two choices"); + } + const space = await resolveSpace(spaceId); + const result = await space.send(spectrumPoll(title.trim(), choices)); + return ok(res, { messageId: result?.id || null }); + } if (req.url === "/typing") { const { spaceId, state = "start" } = body || {}; if (!spaceId) return badRequest(res, "spaceId is required"); diff --git a/tests/plugins/platforms/photon/test_outbound_media.py b/tests/plugins/platforms/photon/test_outbound_media.py index 09d4402a148..144b5b6e16e 100644 --- a/tests/plugins/platforms/photon/test_outbound_media.py +++ b/tests/plugins/platforms/photon/test_outbound_media.py @@ -182,6 +182,39 @@ async def test_send_image_url_fetch_failure_falls_back_to_text( assert "https://example.com/cat.jpg" in calls[0][1]["text"] +@pytest.mark.asyncio +async def test_send_poll_hits_poll_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + result = await adapter.send_poll( + "any;-;+1", + "Pick one", + [" Alpha ", "", "Beta"], + ) + + assert result.success is True + assert result.message_id == "msg-123" + assert calls == [ + ( + "/send-poll", + {"spaceId": "any;-;+1", "title": "Pick one", "options": ["Alpha", "Beta"]}, + ) + ] + + +@pytest.mark.asyncio +async def test_send_poll_requires_two_options(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + calls = _capture_sidecar(adapter) + + result = await adapter.send_poll("any;-;+1", "Pick one", ["Alpha", " "]) + + assert result.success is False + assert "at least two" in (result.error or "") + assert calls == [] + + @pytest.mark.asyncio async def test_send_attachment_rejects_unsafe_path( monkeypatch: pytest.MonkeyPatch diff --git a/website/docs/user-guide/messaging/photon.md b/website/docs/user-guide/messaging/photon.md index 1022cb91dc9..a3e4631fadd 100644 --- a/website/docs/user-guide/messaging/photon.md +++ b/website/docs/user-guide/messaging/photon.md @@ -202,6 +202,8 @@ Common issues: `voice()` content builders via the sidecar's `/send-attachment` endpoint. Captions arrive as a separate iMessage bubble after the media. +- **Native polls are supported.** Hermes sends poll content through + spectrum-ts' `poll()` builder via the sidecar's `/send-poll` endpoint. - **Photon's free quotas:** 5,000 messages per server per day, 50 new-conversation initiations per shared line per day. Increases available — email `help@photon.codes`.