fix(photon): record sent text at the _post_send choke point
Polls (clarify), the plain-text fallback resend and standalone attachment sends reached the sidecar without recording what they sent, so a threaded reply to those bubbles lost its quoted context. Record once in _post_send (plus the standalone attachment leg, which has its own HTTP client) and reuse _normalize_chat_key instead of a duplicate regex.
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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-<n>``."""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user