diff --git a/contributors/emails/vaibhavs362@gmail.com b/contributors/emails/vaibhavs362@gmail.com new file mode 100644 index 000000000000..21a42eacc74c --- /dev/null +++ b/contributors/emails/vaibhavs362@gmail.com @@ -0,0 +1,2 @@ +vaibhavjnf +# PR #48194 salvage diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 84bfd43b0dde..1f19c017a424 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -1045,6 +1045,37 @@ class PhotonAdapter(BasePlatformAdapter): # caller doesn't pass an explicit message id. Recorded before the # mention gate: a reaction to a non-wake-word group message is valid. self._record_last_inbound(space_id, event.get("messageId")) + if ctype == "poll_option": + # A native poll vote. A *selection* carries the chosen option text + # straight to the agent as if the user had typed it — the gateway's + # pending-clarify text-intercept then resolves the open clarify and + # unblocks the agent. A *deselection* (selected=false) is dropped: + # there's no answer to record, and forwarding "" would mis-resolve. + if content.get("selected") is False: + logger.debug("[photon] ignoring poll deselection") + return + choice = (content.get("title") or "").strip() + if not choice: + logger.debug("[photon] ignoring poll vote with empty title") + return + source = self.build_source( + chat_id=space_id, + chat_name=space_id, + chat_type=chat_type, + user_id=sender_id, + user_name=sender_id or None, + ) + await self.handle_message( + MessageEvent( + text=choice, + message_type=MessageType.TEXT, + source=source, + message_id=event.get("messageId"), + raw_message=event, + timestamp=timestamp, + ) + ) + return if ctype == "text": text = content.get("text") or "" mtype = MessageType.TEXT @@ -1454,6 +1485,52 @@ class PhotonAdapter(BasePlatformAdapter): ) -> SendResult: return await self._sidecar_send(chat_id, self.format_message(content)) + # -- Clarify (native iMessage poll) ------------------------------------ + # + # iMessage has a native poll bubble; spectrum-ts exposes it via the + # `poll()` content builder. A multiple-choice clarify renders as that poll + # and the user taps a choice instead of typing a number — the vote streams + # back inbound as a `poll_option` event, which `_dispatch_inbound` + # translates into a plain-text message carrying the chosen option. We flip + # the clarify into text-capture mode (exactly like the base text fallback) + # so the gateway's pending-clarify intercept resolves it with that choice. + # Open-ended clarifies (no choices) keep the plain-text path. + + async def send_clarify( + self, + chat_id: str, + question: str, + choices: Optional[list], + clarify_id: str, + session_key: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not choices: + # No choices → open-ended. Base behaviour (plain text; the next + # message resolves it) is exactly right. + return await super().send_clarify( + chat_id, question, choices, clarify_id, session_key, metadata + ) + # The poll vote comes back as a normal text message, so enable + # text-capture and let the gateway intercept resolve the clarify. + from tools.clarify_gateway import mark_awaiting_text + + mark_awaiting_text(clarify_id) + result = await self._sidecar_send_poll(chat_id, question, list(choices)) + if not result.success: + # Native poll failed (old sidecar without /send-poll, or a send + # error) — fall back to the numbered-text clarify so the user can + # still answer. The base impl also calls mark_awaiting_text (a + # second call is harmless). + logger.warning( + "[photon] poll clarify failed (%s); falling back to text list", + result.error, + ) + return await super().send_clarify( + chat_id, question, choices, clarify_id, session_key, metadata + ) + return result + # -- Outbound media (parity with the BlueBubbles iMessage channel) ----- # # Photon ships outbound attachments via spectrum-ts' `attachment()` / @@ -1933,6 +2010,32 @@ class PhotonAdapter(BasePlatformAdapter): self._record_sent_message(data.get("messageId")) return SendResult(success=True, message_id=data.get("messageId")) + async def _sidecar_send_poll( + self, space_id: str, title: str, options: list, + ) -> SendResult: + """POST a poll to the sidecar's ``/send-poll`` endpoint. + + Renders a native iMessage poll. ``options`` are choice strings; the + sidecar's ``poll()`` builder degrades to a numbered text list on + platforms without native polls. + """ + opts = [str(o).strip() for o in (options or []) if str(o).strip()] + if not title or not title.strip(): + return SendResult(success=False, error="poll title is required") + if not opts: + return SendResult(success=False, error="poll needs at least one option") + body: Dict[str, Any] = { + "spaceId": space_id, + "title": title.strip()[: self.MAX_MESSAGE_LENGTH], + "options": opts, + } + try: + data = await self._sidecar_call("/send-poll", body) + except Exception as e: + return SendResult(success=False, error=str(e)) + self._record_sent_message(data.get("messageId")) + return SendResult(success=True, message_id=data.get("messageId")) + async def _sidecar_send_attachment( self, space_id: str, diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index f49120215a3c..35b7bb6522cd 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -37,6 +37,10 @@ // body: {"spaceId": "...", "text": "...", "effect": "confetti" | ...} // - POST /typing -> {"ok": true} // body: {"spaceId": "...", "state": "start" | "stop"} +// - POST /send-poll -> {"ok": true, "messageId": "..."} +// body: {"spaceId": "...", "title": "...", "options": ["A", "B", ...]} +// Sends a native poll (orange iMessage poll bubble). A tap streams +// back inbound as a `poll_option` event ({title, selected}). // - POST /shutdown -> {"ok": true}; then process exits // // On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before @@ -253,6 +257,7 @@ try { text: spectrumText, markdown: spectrumMarkdown, typing: spectrumTyping, + poll: spectrumPoll, } = await import("spectrum-ts")); ({ imessage, effect: imessageEffect } = await import("spectrum-ts/providers/imessage")); } catch (e) { @@ -477,6 +482,29 @@ async function normalizeContent(content) { targetText: reactionTargetText(target), }; } + // A user tapping a poll choice arrives as `poll_option` carrying the chosen + // option title + whether it was selected (true) or deselected (false). This + // is how a native iMessage poll's vote streams back — Python turns a + // selection into the answer that resolves a pending `clarify`. + if (content.type === "poll_option") { + return { + type: "poll_option", + title: content.option?.title ?? content.title ?? "", + selected: content.selected !== false, + pollTitle: content.poll?.title ?? null, + }; + } + // The poll message itself (its creation) — surfaced for completeness so the + // agent isn't told "content type not handled" if it sees the echo. + if (content.type === "poll") { + return { + type: "poll", + title: content.title ?? "", + options: Array.isArray(content.options) + ? content.options.map((o) => o?.title ?? "") + : [], + }; + } return { type: content.type || "unknown" }; } @@ -830,6 +858,27 @@ const server = http.createServer(async (req, res) => { } return ok(res, { messageId: result?.id || null }); } + if (req.url === "/send-poll") { + const { spaceId, title, options } = body || {}; + if (!spaceId || typeof title !== "string" || !title) { + return badRequest(res, "spaceId and title are required"); + } + if (!Array.isArray(options) || options.length < 1) { + return badRequest(res, "options must be a non-empty array"); + } + // spectrum-ts' poll() builder accepts string options; it degrades to a + // numbered text list on platforms without native polls (so the gateway + // text-intercept still resolves the clarify there). + const opts = options + .map((o) => (typeof o === "string" ? o : o?.title)) + .filter((o) => typeof o === "string" && o); + if (!opts.length) { + return badRequest(res, "options must contain at least one string"); + } + const space = await resolveSpace(spaceId); + const result = await space.send(spectrumPoll(title, ...opts)); + return ok(res, { messageId: result?.id || null }); + } if (req.url === "/react") { const { spaceId, messageId, emoji } = body || {}; if (!spaceId || !messageId || typeof emoji !== "string" || !emoji) { diff --git a/tests/plugins/platforms/photon/test_poll_clarify.py b/tests/plugins/platforms/photon/test_poll_clarify.py new file mode 100644 index 000000000000..4f831d91e0e3 --- /dev/null +++ b/tests/plugins/platforms/photon/test_poll_clarify.py @@ -0,0 +1,225 @@ +"""Native-poll clarify tests for PhotonAdapter. + +iMessage has a native poll bubble (spectrum-ts `poll()` builder). A +multiple-choice ``clarify`` renders as that poll; the user taps a choice and +the vote streams back inbound as a ``poll_option`` event. These tests cover +both directions without spawning the Node sidecar or binding ports: + + * outbound — ``send_clarify`` with choices POSTs ``/send-poll`` and flips the + clarify into text-capture mode; with no choices it stays plain text; + * inbound — a ``poll_option`` selection is dispatched as a plain-text message + carrying the chosen option (so the gateway clarify-intercept resolves it), + a deselection is dropped, and an empty-title vote is dropped. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType, SendResult +from plugins.platforms.photon.adapter import PhotonAdapter + + +def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter: + monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") + monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") + cfg = PlatformConfig(enabled=True, token="", extra={}) + return PhotonAdapter(cfg) + + +def _capture( + adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch +) -> List[MessageEvent]: + captured: List[MessageEvent] = [] + + async def fake_handle(event: MessageEvent) -> None: + captured.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + return captured + + +def _poll_option_event( + *, title: str, selected: bool = True, msg_id: str = "spc-msg-vote" +) -> Dict[str, Any]: + return { + "messageId": msg_id, + "platform": "iMessage", + "space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"}, + "sender": {"id": "+155****4567"}, + "content": { + "type": "poll_option", + "title": title, + "selected": selected, + "pollTitle": "Pick one", + }, + "timestamp": "2026-05-14T19:06:32.000Z", + } + + +# --------------------------------------------------------------------------- +# Inbound: a poll vote becomes the clarify answer. + + +@pytest.mark.asyncio +async def test_poll_vote_dispatched_as_choice_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A poll selection is forwarded as a plain-text message carrying the + chosen option, so the gateway clarify text-intercept can resolve it.""" + adapter = _make_adapter(monkeypatch) + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound( + _poll_option_event(title="Yes — native tappable buttons") + ) + + assert len(captured) == 1 + ev = captured[0] + assert ev.text == "Yes — native tappable buttons" + assert ev.message_type == MessageType.TEXT + assert ev.source.chat_id == "+155****4567" + + +@pytest.mark.asyncio +async def test_poll_deselection_is_ignored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Un-tapping a choice (selected=false) carries no answer — dropped.""" + adapter = _make_adapter(monkeypatch) + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound( + _poll_option_event(title="Yes", selected=False) + ) + assert captured == [] + + +@pytest.mark.asyncio +async def test_poll_vote_empty_title_is_ignored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound(_poll_option_event(title=" ")) + assert captured == [] + + +# --------------------------------------------------------------------------- +# Outbound: send_clarify renders a native poll for choices. + + +def _stub_sidecar_poll( + adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch, *, ok: bool = True +) -> List[Tuple[str, str, list]]: + calls: List[Tuple[str, str, list]] = [] + + async def fake_send_poll(space_id: str, title: str, options: list): + calls.append((space_id, title, list(options))) + return SendResult( + success=ok, + message_id="spc-msg-poll" if ok else None, + error=None if ok else "boom", + ) + + monkeypatch.setattr(adapter, "_sidecar_send_poll", fake_send_poll) + return calls + + +def _stub_sidecar_text( + adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch +) -> List[Tuple[str, str]]: + sends: List[Tuple[str, str]] = [] + + async def fake_send(space_id: str, text: str): + sends.append((space_id, text)) + return SendResult(success=True, message_id="spc-msg-text") + + monkeypatch.setattr(adapter, "_sidecar_send", fake_send) + return sends + + +@pytest.mark.asyncio +async def test_send_clarify_with_choices_sends_native_poll( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _make_adapter(monkeypatch) + poll_calls = _stub_sidecar_poll(adapter, monkeypatch) + + marked: List[str] = [] + import tools.clarify_gateway as cg + + monkeypatch.setattr(cg, "mark_awaiting_text", lambda cid: marked.append(cid)) + + result = await adapter.send_clarify( + chat_id="+155****4567", + question="Pick one", + choices=["A", "B", "C"], + clarify_id="clar-1", + session_key="sess-1", + ) + + assert result.success + assert len(poll_calls) == 1 + space_id, title, options = poll_calls[0] + assert space_id == "+155****4567" + assert title == "Pick one" + assert options == ["A", "B", "C"] + # The vote returns as text, so text-capture must be enabled. + assert marked == ["clar-1"] + + +@pytest.mark.asyncio +async def test_send_clarify_open_ended_stays_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No choices → plain text question, never a poll.""" + adapter = _make_adapter(monkeypatch) + poll_calls = _stub_sidecar_poll(adapter, monkeypatch) + sends = _stub_sidecar_text(adapter, monkeypatch) + + result = await adapter.send_clarify( + chat_id="+155****4567", + question="What's your name?", + choices=None, + clarify_id="clar-2", + session_key="sess-2", + ) + + assert result.success + assert poll_calls == [] # no poll for open-ended + assert len(sends) == 1 + assert "What's your name?" in sends[0][1] + + +@pytest.mark.asyncio +async def test_send_clarify_falls_back_to_text_when_poll_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An old sidecar without /send-poll (or a send error) degrades to the + numbered-text clarify so the user can still answer.""" + adapter = _make_adapter(monkeypatch) + poll_calls = _stub_sidecar_poll(adapter, monkeypatch, ok=False) + sends = _stub_sidecar_text(adapter, monkeypatch) + + import tools.clarify_gateway as cg + + monkeypatch.setattr(cg, "mark_awaiting_text", lambda cid: None) + + result = await adapter.send_clarify( + chat_id="+155****4567", + question="Pick one", + choices=["A", "B"], + clarify_id="clar-3", + session_key="sess-3", + ) + + assert result.success # text fallback succeeded + assert len(poll_calls) == 1 # poll was attempted + assert len(sends) == 1 # then text list sent + body = sends[0][1] + assert "Pick one" in body + assert "A" in body and "B" in body