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
7 changes: 5 additions & 2 deletions plugins/platforms/photon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ All env vars are documented in `plugin.yaml`. The most important:
documents are sent via `space.send(attachment(...))` /
`space.send(voice(...))` through the sidecar's `/send-attachment`
endpoint; a caption is delivered as a separate text bubble after the media.
- **Reactions, message effects, polls** — supported by `spectrum-ts` but not
yet exposed; the sidecar is the natural place to add them.
- **Message effects are supported.** Text can be sent with native iMessage
bubble/screen effects through `spectrum-ts`' iMessage `effect(...)` builder
via the sidecar's `/send-effect` endpoint.
- **Reactions and polls** — supported by `spectrum-ts` but not yet exposed; the
sidecar is the natural place to add them.

[photon]: https://photon.codes/
19 changes: 19 additions & 0 deletions plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,25 @@ async def send_animation(
chat_id, animation_url, caption, reply_to, metadata,
)

async def send_effect(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send_effect() has no caller in the existing delivery surface: send_message dispatches only send/list/react/unreact and its send path invokes adapter.send(). Please wire this through a deliberate supported path (including standalone behavior), or keep it internal and avoid documenting Hermes-level effect support.

self,
chat_id: str,
text: str,
effect: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send text with a native iMessage bubble or screen effect."""
if not text.strip() or not effect.strip():
return SendResult(success=False, error="text and effect are required")
try:
data = await self._sidecar_call(
"/send-effect",
{"spaceId": chat_id, "text": text.strip(), "effect": effect.strip()},
)
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:
try:
await self._sidecar_call(
Expand Down
24 changes: 22 additions & 2 deletions plugins/platforms/photon/sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
// "mimeType": "..." | null, "caption": "..." | null,
// "kind": "attachment" | "voice"}
// - POST /send-effect -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "text": "...", "effect": "confetti" | ...}
// - POST /typing -> {"ok": true}
// body: {"spaceId": "...", "state": "start" | "stop"}
// - POST /shutdown -> {"ok": true}; then process exits
Expand Down Expand Up @@ -70,7 +72,7 @@ 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, spectrumText, spectrumTyping;
let Spectrum, imessage, imessageEffect, attachment, voice, spectrumText, spectrumTyping;
try {
({
Spectrum,
Expand All @@ -79,7 +81,7 @@ try {
text: spectrumText,
typing: spectrumTyping,
} = await import("spectrum-ts"));
({ imessage } = await import("spectrum-ts/providers/imessage"));
({ imessage, effect: imessageEffect } = await import("spectrum-ts/providers/imessage"));
} catch (e) {
console.error(
"photon-sidecar: spectrum-ts is not installed. Run `npm install` " +
Expand All @@ -96,6 +98,8 @@ const app = await Spectrum({
options: { flattenGroups: true },
});

const MESSAGE_EFFECTS = imessage.effect.message;

// ---------------------------------------------------------------------------
// Inbound: forward `app.messages` (gRPC stream) to the Python consumer.

Expand Down Expand Up @@ -486,6 +490,22 @@ const server = http.createServer(async (req, res) => {
}
return ok(res, { messageId: result?.id || null });
}
if (req.url === "/send-effect") {
const { spaceId, text, effect } = body || {};
const effectName = String(effect || "").trim();
const effectId = MESSAGE_EFFECTS[effectName];
if (!spaceId || typeof text !== "string" || !text.trim()) {
return badRequest(res, "spaceId and text are required");
}
if (!effectId) {
return badRequest(res, "unsupported effect");
}
const space = await resolveSpace(spaceId);
const result = await space.send(
imessageEffect(spectrumText(text.trim()), effectId)
);
return ok(res, { messageId: result?.id || null });
}
if (req.url === "/typing") {
const { spaceId, state = "start" } = body || {};
if (!spaceId) return badRequest(res, "spaceId is required");
Expand Down
29 changes: 29 additions & 0 deletions tests/plugins/platforms/photon/test_outbound_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,35 @@ async def _boom(url: str, *a, **k) -> str:
assert "https://example.com/cat.jpg" in calls[0][1]["text"]


@pytest.mark.asyncio
async def test_send_effect_hits_effect_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)

result = await adapter.send_effect("any;-;+1", " Celebrate ", " confetti ")

assert result.success is True
assert result.message_id == "msg-123"
assert calls == [
(
"/send-effect",
{"spaceId": "any;-;+1", "text": "Celebrate", "effect": "confetti"},
)
]


@pytest.mark.asyncio
async def test_send_effect_requires_text_and_effect(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)

result = await adapter.send_effect("any;-;+1", "", "confetti")

assert result.success is False
assert "required" in (result.error or "")
assert calls == []


@pytest.mark.asyncio
async def test_send_attachment_rejects_unsafe_path(
monkeypatch: pytest.MonkeyPatch
Expand Down
3 changes: 3 additions & 0 deletions website/docs/user-guide/messaging/photon.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ Common issues:
`voice()` content builders via the sidecar's `/send-attachment`
endpoint. Captions arrive as a separate iMessage bubble after the
media.
- **Message effects are supported.** Hermes sends text with native iMessage
bubble/screen effects through spectrum-ts' iMessage `effect()` builder
via the sidecar's `/send-effect` 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`.
Expand Down