diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index e19790af80..0976983c75 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -511,10 +511,11 @@ def _normalize_content(content: Dict[str, Any]) -> _Normalized: def _sent_text_key(chat_id: Optional[str]) -> Optional[str]: """DM GUID (``any;-;+1555...``) and bare E.164 address one space; index under the phone.""" - if not chat_id: - return None - match = re.match(r"^any;-;(\+\d{6,})$", chat_id) - return match.group(1) if match else chat_id + return PhotonAdapter._normalize_chat_key(chat_id) if chat_id else None + + +def _attachment_label(kind: str, name: str) -> str: + return f"[{kind}: {name}]" async def _record_sent_text_async(chat_id: Optional[str], message_id: Optional[str], text: Optional[str]) -> None: @@ -1251,11 +1252,7 @@ class PhotonAdapter(BasePlatformAdapter): async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: - formatted = self.format_message(content) - result = await self._sidecar_send(chat_id, formatted) - if result.success: - await _record_sent_text_async(chat_id, result.message_id, formatted) - return result + return await self._sidecar_send(chat_id, self.format_message(content)) 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: @@ -1458,10 +1455,12 @@ class PhotonAdapter(BasePlatformAdapter): return await self._sidecar_send( chat_id, self.format_message(content)[: self.MAX_MESSAGE_LENGTH], richlink=False, markdown=False) - async def _post_send(self, path: str, body: Dict[str, Any], *, structured: bool = False) -> SendResult: + async def _post_send(self, path: str, body: Dict[str, Any], *, structured: bool = False, + sent_text: Optional[str] = None) -> SendResult: """POST a send-like body and wrap the outcome as a SendResult. ``structured`` carries a ``PhotonSidecarError``'s class/retryability so ``_send_with_retry`` can recognise - permanent failures.""" + permanent failures. ``sent_text`` is what a later threaded reply to this bubble quotes; + every outbound path passes through here, so recording here covers them all.""" try: data = await self._sidecar_call(path, body) except PhotonSidecarError as e: @@ -1472,13 +1471,17 @@ class PhotonAdapter(BasePlatformAdapter): except Exception as e: return SendResult(success=False, error=str(e)) self._record_sent_message(data.get("messageId")) + if sent_text: + await _record_sent_text_async(body.get("spaceId"), data.get("messageId"), sent_text) return SendResult(success=True, message_id=data.get("messageId")) async def _sidecar_send(self, space_id: str, text: str, *, richlink: bool = True, markdown: bool = True) -> SendResult: + sent_text = text rich_url = _richlink_candidate(text) if richlink else None if rich_url: - rich_result = await self._post_send("/send-richlink", {"spaceId": space_id, "url": rich_url}) + rich_result = await self._post_send("/send-richlink", {"spaceId": space_id, "url": rich_url}, + sent_text=sent_text) if rich_result.success: return rich_result logger.warning("[photon] rich-link send failed, falling back to plain text: %s", rich_result.error) @@ -1493,7 +1496,7 @@ class PhotonAdapter(BasePlatformAdapter): body: Dict[str, Any] = {"spaceId": space_id, "text": text} if send_markdown: # key omitted when disabled: pre-`format` sidecars still accept body["format"] = "markdown" - return await self._post_send("/send", body, structured=True) + return await self._post_send("/send", body, structured=True, sent_text=sent_text) async def _sidecar_send_poll(self, space_id: str, title: str, options: list) -> SendResult: """POST a native poll to ``/send-poll`` (degrades to a numbered list elsewhere).""" @@ -1503,7 +1506,7 @@ class PhotonAdapter(BasePlatformAdapter): if len(opts) < 2: return SendResult(success=False, error="poll needs at least two options") body = {"spaceId": space_id, "title": title.strip()[: self.MAX_MESSAGE_LENGTH], "options": opts} - return await self._post_send("/send-poll", body) + return await self._post_send("/send-poll", body, sent_text=body["title"]) async def _sidecar_send_attachment(self, space_id: str, path: str, *, name: Optional[str] = None, mime_type: Optional[str] = None, caption: Optional[str] = None, @@ -1515,11 +1518,8 @@ class PhotonAdapter(BasePlatformAdapter): return SendResult(success=False, error=f"unsafe or missing attachment path: {path}") body = _attachment_body( space_id, safe_path, kind=kind, name=name, mime_type=mime_type or _guess_mime(safe_path), caption=caption) - result = await self._post_send("/send-attachment", body, structured=True) - if result.success: - label = caption or f"[{kind}: {name or os.path.basename(safe_path)}]" - await _record_sent_text_async(space_id, result.message_id, label) - return result + label = caption or _attachment_label(kind, name or os.path.basename(safe_path)) + return await self._post_send("/send-attachment", body, structured=True, sent_text=label) async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: if self._http_client is None: @@ -1680,6 +1680,8 @@ async def _standalone_send( if not data: return _standalone_error(resp) last_message_id = data.get("messageId") or last_message_id + await _record_sent_text_async(chat_id, data.get("messageId"), _attachment_label( + "voice" if is_voice else "attachment", os.path.basename(safe_path))) return {"success": True, "message_id": last_message_id} except Exception as e: return send_error(f"Photon standalone send failed: {e}") diff --git a/tests/plugins/platforms/photon/test_threaded_replies.py b/tests/plugins/platforms/photon/test_threaded_replies.py index d97cf0d510..d1ebd1017a 100644 --- a/tests/plugins/platforms/photon/test_threaded_replies.py +++ b/tests/plugins/platforms/photon/test_threaded_replies.py @@ -174,16 +174,19 @@ async def test_attachment_send_is_recorded_as_label(monkeypatch, tmp_path): assert handled[-1].reply_to_text == "[attachment: chart.png]" -@pytest.mark.asyncio -async def test_standalone_send_records_text(monkeypatch): +def _fake_standalone_sidecar(monkeypatch: pytest.MonkeyPatch) -> None: + """Stand-in for the sidecar HTTP API behind ``_standalone_send``: ids are ``cron-``.""" monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok") + counter = {"n": 0} class _Resp: status_code = 200 - @staticmethod - def json() -> Dict[str, Any]: - return {"ok": True, "messageId": "cron-1"} + 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): @@ -196,19 +199,57 @@ async def test_standalone_send_records_text(monkeypatch): return False async def post(self, url: str, json: Dict[str, Any], headers=None): - return _Resp() + 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") + + 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) - handled = _capture_handled(adapter, monkeypatch) - await adapter._dispatch_inbound( - _reply_event({"type": "text", "text": "thanks"}, target_id="cron-1", target_text=None)) + assert await _reply_text_for(adapter, monkeypatch, "cron-1") == "daily digest" + assert await _reply_text_for(adapter, monkeypatch, "cron-2") == "[attachment: chart.png]" - assert handled[-1].reply_to_text == "daily digest" + +@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