280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Inbound iMessage threaded (swipe) replies for PhotonAdapter (#100663).
|
|
|
|
spectrum-ts 12.x wraps a threaded reply as ``{type: "reply", content, target}``. The
|
|
sidecar normalises it to ``{type: "reply", content, targetMessageId, targetDirection,
|
|
targetText}``; the adapter must unwrap it instead of emitting
|
|
"[Photon content type not handled: reply]", and carry the quoted message as reply
|
|
context. spectrum usually can't hydrate the text of our own outbound bubbles, so the
|
|
adapter records what it sends in ``gateway.rich_sent_store`` and falls back to that.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
from plugins.platforms.photon import adapter as photon_adapter
|
|
from plugins.platforms.photon.adapter import PhotonAdapter
|
|
|
|
PHONE = "+15550001234"
|
|
DM = f"any;-;{PHONE}"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_sent_index(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
# rich_sent_store writes under HERMES_HOME/state; keep each test's index private.
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
|
|
def _make_adapter(monkeypatch: pytest.MonkeyPatch, **extra: Any) -> PhotonAdapter:
|
|
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
|
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
|
return PhotonAdapter(PlatformConfig(enabled=True, token="", extra=dict(extra)))
|
|
|
|
|
|
def _capture_handled(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 _capture_sidecar(adapter: PhotonAdapter, message_id: str = "out-1") -> list[tuple[str, dict[str, Any]]]:
|
|
calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
async def fake_call(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
calls.append((path, body))
|
|
return {"ok": True, "messageId": message_id}
|
|
|
|
adapter._sidecar_call = fake_call # type: ignore[assignment]
|
|
return calls
|
|
|
|
|
|
def _reply_event(inner: dict[str, Any], *, message_id: str = "in-2", target_id: str = "out-0",
|
|
direction: str | None = "outbound", target_text: str | None = "earlier answer") -> dict[str, Any]:
|
|
return {
|
|
"messageId": message_id,
|
|
"space": {"id": DM, "type": "dm", "phone": PHONE},
|
|
"sender": {"id": PHONE},
|
|
"timestamp": "2026-09-25T18:00:00Z",
|
|
"content": {"type": "reply", "content": inner, "targetMessageId": target_id,
|
|
"targetDirection": direction, "targetText": target_text},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_threaded_text_reply_reaches_agent_with_context(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
|
|
await adapter._dispatch_inbound(_reply_event({"type": "text", "text": "yes do it"}))
|
|
|
|
assert len(handled) == 1
|
|
event = handled[0]
|
|
assert event.text == "yes do it"
|
|
assert event.reply_to_message_id == "out-0"
|
|
assert event.reply_to_text == "earlier answer"
|
|
assert event.reply_to_is_own_message is True
|
|
|
|
|
|
# 1x1 transparent PNG (passes the base adapter's image magic check).
|
|
_PNG_1X1_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_threaded_photo_reply_keeps_the_photo(monkeypatch):
|
|
"""The inner content goes through the normal ladder, so a threaded photo is not reduced to text."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
|
|
inner = {"type": "attachment", "name": "pic.png", "mimeType": "image/png", "size": 68,
|
|
"data": _PNG_1X1_B64, "encoding": "base64"}
|
|
await adapter._dispatch_inbound(_reply_event(inner))
|
|
|
|
event = handled[0]
|
|
assert event.message_type == MessageType.PHOTO
|
|
assert event.media_types == ["image/png"]
|
|
assert len(event.media_urls) == 1
|
|
assert event.reply_to_message_id == "out-0"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_to_users_own_message_is_not_marked_own(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
|
|
await adapter._dispatch_inbound(_reply_event({"type": "text", "text": "ctx"}, direction="inbound"))
|
|
|
|
assert handled[0].text == "ctx"
|
|
assert handled[0].reply_to_is_own_message is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_reply_envelope_keeps_fallback_marker(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
|
|
await adapter._dispatch_inbound(_reply_event({"type": "unknown"}))
|
|
|
|
assert handled[0].text == "[Photon content type not handled: reply]"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_to_our_message_hydrates_quoted_text_from_sent_index(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
calls = _capture_sidecar(adapter, message_id="out-1")
|
|
|
|
# We send, the user later swipe-replies to that bubble; spectrum gives no target text.
|
|
await adapter.send(DM, "the plan is A then B")
|
|
assert calls[-1][0] == "/send"
|
|
await adapter._dispatch_inbound(
|
|
_reply_event({"type": "text", "text": "do B first"}, target_id="out-1", target_text=None))
|
|
|
|
assert handled[-1].text == "do B first"
|
|
assert handled[-1].reply_to_message_id == "out-1"
|
|
assert handled[-1].reply_to_text == "the plan is A then B"
|
|
assert handled[-1].reply_to_is_own_message is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_after_restart_is_still_marked_own(monkeypatch):
|
|
"""Cloud targets carry no direction; after a restart the index hit alone proves the bubble was ours."""
|
|
sender = _make_adapter(monkeypatch)
|
|
_capture_sidecar(sender, message_id="out-9")
|
|
await sender.send(DM, "the plan is A then B")
|
|
|
|
restarted = _make_adapter(monkeypatch) # fresh in-memory _sent_message_ids
|
|
handled = _capture_handled(restarted, monkeypatch)
|
|
event = _reply_event({"type": "text", "text": "ok"}, target_id="out-9", direction=None, target_text=None)
|
|
await restarted._dispatch_inbound(event)
|
|
|
|
assert handled[-1].reply_to_text == "the plan is A then B"
|
|
assert handled[-1].reply_to_is_own_message is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_to_bare_phone_is_found_from_dm_guid(monkeypatch):
|
|
"""Cron/standalone sends often target the bare E.164; replies arrive in the DM GUID space."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
_capture_sidecar(adapter, message_id="out-7")
|
|
|
|
await adapter.send(PHONE, "morning briefing")
|
|
await adapter._dispatch_inbound(
|
|
_reply_event({"type": "text", "text": "more on item 2"}, target_id="out-7", target_text=None))
|
|
|
|
assert handled[-1].reply_to_text == "morning briefing"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attachment_send_is_recorded_as_label(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(PhotonAdapter, "validate_media_delivery_path",
|
|
staticmethod(lambda p: p if os.path.exists(p) else None))
|
|
img = tmp_path / "chart.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
_capture_sidecar(adapter, message_id="out-3")
|
|
|
|
await adapter.send_image_file(DM, str(img))
|
|
await adapter._dispatch_inbound(
|
|
_reply_event({"type": "text", "text": "what is this"}, target_id="out-3", target_text=None))
|
|
|
|
assert handled[-1].reply_to_text == "[attachment: chart.png]"
|
|
|
|
|
|
def _fake_standalone_sidecar(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Stand-in for the sidecar HTTP API behind ``_standalone_send``: ids are ``cron-<n>``."""
|
|
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
|
counter = {"n": 0}
|
|
|
|
class _Resp:
|
|
status_code = 200
|
|
|
|
def __init__(self, message_id: str):
|
|
self._id = message_id
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return {"ok": True, "messageId": self._id}
|
|
|
|
class _FakeClient:
|
|
def __init__(self, *a, **k):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
async def post(self, url: str, json: dict[str, Any], headers=None):
|
|
counter["n"] += 1
|
|
return _Resp(f"cron-{counter['n']}")
|
|
|
|
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
|
|
|
|
|
async def _reply_text_for(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch, target_id: str) -> str | None:
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
await adapter._dispatch_inbound(
|
|
_reply_event({"type": "text", "text": "about this"}, target_id=target_id, target_text=None))
|
|
assert handled[-1].text == "about this"
|
|
return handled[-1].reply_to_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_records_text_and_attachments(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(photon_adapter.BasePlatformAdapter, "validate_media_delivery_path",
|
|
staticmethod(lambda p: p if os.path.exists(p) else None))
|
|
chart = tmp_path / "chart.png"
|
|
chart.write_bytes(b"\x89PNG fake")
|
|
_fake_standalone_sidecar(monkeypatch)
|
|
cfg = PlatformConfig(enabled=True, token="", extra={})
|
|
|
|
result = await photon_adapter._standalone_send(cfg, PHONE, "daily digest", media_files=[(str(chart), False)])
|
|
assert result.get("success") is True
|
|
|
|
adapter = _make_adapter(monkeypatch)
|
|
assert await _reply_text_for(adapter, monkeypatch, "cron-1") == "daily digest"
|
|
assert await _reply_text_for(adapter, monkeypatch, "cron-2") == "[attachment: chart.png]"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_clarify_is_recorded_as_its_question(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
calls = _capture_sidecar(adapter, message_id="poll-1")
|
|
|
|
await adapter.send_clarify(DM, "Which plan?", ["A", "B"], "clarify-1", "session-1")
|
|
|
|
assert calls[-1][0] == "/send-poll"
|
|
assert await _reply_text_for(adapter, monkeypatch, "poll-1") == "Which plan?"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plain_fallback_resend_is_recorded(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
_capture_sidecar(adapter, message_id="fallback-1")
|
|
|
|
result = await adapter._send_plain_fallback(DM, "plain retry", reply_to=None, metadata=None)
|
|
|
|
assert result.success
|
|
assert await _reply_text_for(adapter, monkeypatch, "fallback-1") == "plain retry"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_reply_target_leaves_text_empty(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
handled = _capture_handled(adapter, monkeypatch)
|
|
|
|
await adapter._dispatch_inbound(_reply_event({"type": "text", "text": "?"}, target_text=None))
|
|
|
|
assert handled[-1].text == "?"
|
|
assert handled[-1].reply_to_text is None
|