Skip to content
Closed
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
103 changes: 103 additions & 0 deletions plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,37 @@ def _normalize_binary_payload(
# 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
Expand Down Expand Up @@ -894,6 +925,52 @@ async def send(
) -> 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()` /
Expand Down Expand Up @@ -1278,6 +1355,32 @@ async def _sidecar_send(self, space_id: str, text: str) -> SendResult:
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,
Expand Down
52 changes: 51 additions & 1 deletion plugins/platforms/photon/sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
// "reactionId": "..." | null (restart-recovery fallback)}
// - 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
Expand Down Expand Up @@ -116,7 +120,8 @@ let Spectrum,
voice,
spectrumText,
spectrumMarkdown,
spectrumTyping;
spectrumTyping,
spectrumPoll;
try {
({
Spectrum,
Expand All @@ -125,6 +130,7 @@ try {
text: spectrumText,
markdown: spectrumMarkdown,
typing: spectrumTyping,
poll: spectrumPoll,
} = await import("spectrum-ts"));
({ imessage } = await import("spectrum-ts/providers/imessage"));
} catch (e) {
Expand Down Expand Up @@ -314,6 +320,29 @@ async function normalizeContent(content) {
targetDirection: content.target?.direction ?? null,
};
}
// 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" };
}

Expand Down Expand Up @@ -589,6 +618,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) {
Expand Down
Loading