feat(photon): add native poll sending

This commit is contained in:
Hermes Agent 2026-06-10 16:50:48 +00:00 • committed by Teknium
parent 95d303138b
commit 077c583c75
6 changed files with 84 additions and 3 deletions

View file

@ -0,0 +1,2 @@
arnoldfrancisca
# PR #43665/#43718 salvage

View file

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

View file

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

View file

@ -31,6 +31,8 @@
// - POST /unreact -> {"ok": true} | 400 soft failure
// body: {"spaceId": "...", "messageId": "<target msg id>",
// "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");

View file

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

View file

@ -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`.