|
|
@@ -850,7 +850,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
if not content:
|
|
|
return SendResult(success=False, error="Empty message")
|
|
|
|
|
|
- formatted = self.format_message(content) if hasattr(self, "format_message") else content
|
|
|
+ formatted = self.format_message(content)
|
|
|
chunks = self.truncate_message(formatted, ChattoConstants.MAX_MESSAGE_LENGTH)
|
|
|
|
|
|
# Thread support — resolve thread_id once
|
|
|
@@ -945,6 +945,165 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
return SendResult(success=True, message_id=first_id, raw_response=last_resp)
|
|
|
|
|
|
+ def format_message(self, content: str) -> str:
|
|
|
+ """Normalise outgoing text for Chatto.
|
|
|
+
|
|
|
+ Chatto renders Markdown natively, so there is nothing to escape or
|
|
|
+ translate — the only transformations here are the ones that measurably
|
|
|
+ render wrong: CRLF line endings (which show up as stray blank lines)
|
|
|
+ and runs of more than two blank lines.
|
|
|
+
|
|
|
+ BasePlatformAdapter override
|
|
|
+ """
|
|
|
+ if not content:
|
|
|
+ return content
|
|
|
+ normalised = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
+ while "\n\n\n\n" in normalised:
|
|
|
+ normalised = normalised.replace("\n\n\n\n", "\n\n\n")
|
|
|
+ return normalised
|
|
|
+
|
|
|
+ async def edit_message(
|
|
|
+ self,
|
|
|
+ chat_id: str,
|
|
|
+ message_id: str,
|
|
|
+ content: str,
|
|
|
+ *,
|
|
|
+ finalize: bool = False,
|
|
|
+ ) -> SendResult:
|
|
|
+ """Edit a message we previously sent, via MessageService/UpdateMessage.
|
|
|
+
|
|
|
+ The stream consumer drives streaming replies through this: without the
|
|
|
+ override the base class reports "Not supported" and every incremental
|
|
|
+ update arrives as a *new* message.
|
|
|
+
|
|
|
+ ``finalize`` is a no-op for Chatto — an edit is an edit here, there is
|
|
|
+ no in-progress card state to close out (hence no
|
|
|
+ ``REQUIRES_EDIT_FINALIZE``).
|
|
|
+
|
|
|
+ Content that exceeds the per-message limit is refused rather than
|
|
|
+ silently truncated, so the caller falls back to ``send()``, which
|
|
|
+ splits across messages.
|
|
|
+
|
|
|
+ BasePlatformAdapter override
|
|
|
+ """
|
|
|
+ if not message_id:
|
|
|
+ return SendResult(success=False, error="Chatto: no message id to edit")
|
|
|
+ if not content:
|
|
|
+ return SendResult(success=False, error="Empty message")
|
|
|
+
|
|
|
+ formatted = self.format_message(content)
|
|
|
+ if len(formatted) > ChattoConstants.MAX_MESSAGE_LENGTH:
|
|
|
+ # Refuse instead of truncating: the caller's fallback path splits.
|
|
|
+ return SendResult(
|
|
|
+ success=False,
|
|
|
+ error=(
|
|
|
+ f"Chatto: edit exceeds {ChattoConstants.MAX_MESSAGE_LENGTH} "
|
|
|
+ f"chars ({len(formatted)})"
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ try:
|
|
|
+ client = await self._require_client()
|
|
|
+ except RuntimeError:
|
|
|
+ return SendResult(success=False, error="Chatto client not available", retryable=True)
|
|
|
+
|
|
|
+ try:
|
|
|
+ msg = await client.update_message(
|
|
|
+ room_id=str(chat_id),
|
|
|
+ event_id=str(message_id),
|
|
|
+ body=formatted,
|
|
|
+ )
|
|
|
+ except ChattoError as e:
|
|
|
+ logger.warning("Chatto: UpdateMessage failed for %s: %s", message_id, e)
|
|
|
+ return SendResult(success=False, error=str(e), retryable=True)
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("Chatto: UpdateMessage error for %s: %s", message_id, e)
|
|
|
+ return SendResult(success=False, error=str(e), retryable=False)
|
|
|
+
|
|
|
+ # Our own edit comes back as a message_edited event; mark it seen so it
|
|
|
+ # is never mistaken for inbound traffic.
|
|
|
+ edited_id = getattr(msg, "id", "") or str(message_id)
|
|
|
+ self._mark_seen(edited_id)
|
|
|
+ return SendResult(success=True, message_id=edited_id, raw_response=msg)
|
|
|
+
|
|
|
+ async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
|
|
+ """Delete a message via MessageService/DeleteMessage.
|
|
|
+
|
|
|
+ Used by the stream consumer's fresh-final cleanup (removing a preview
|
|
|
+ message once the completed reply has been sent) and by the ephemeral
|
|
|
+ reply TTL.
|
|
|
+
|
|
|
+ BasePlatformAdapter override
|
|
|
+ """
|
|
|
+ if not chat_id or not message_id:
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ client = await self._require_client()
|
|
|
+ except RuntimeError:
|
|
|
+ logger.warning("Chatto: DeleteMessage — client unavailable")
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ return bool(await client.delete_message(
|
|
|
+ room_id=str(chat_id), event_id=str(message_id),
|
|
|
+ ))
|
|
|
+ except ChattoError as e:
|
|
|
+ logger.warning("Chatto: DeleteMessage failed for %s: %s", message_id, e)
|
|
|
+ return False
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("Chatto: DeleteMessage error for %s: %s", message_id, e)
|
|
|
+ return False
|
|
|
+
|
|
|
+ async def create_handoff_thread(
|
|
|
+ self, parent_chat_id: str, name: str,
|
|
|
+ ) -> Optional[str]:
|
|
|
+ """Anchor a session handoff in a fresh thread under *parent_chat_id*.
|
|
|
+
|
|
|
+ Chatto threads hang off a message, not off the room, so we post a seed
|
|
|
+ message and hand its ID back as the thread root — the same shape the
|
|
|
+ Slack adapter uses. DMs don't support threads, so they get ``None``
|
|
|
+ and the watcher keeps delivering into the DM itself.
|
|
|
+
|
|
|
+ BasePlatformAdapter override
|
|
|
+ """
|
|
|
+ if not parent_chat_id:
|
|
|
+ return None
|
|
|
+ if self._room_kinds.get(parent_chat_id) == RoomKind.DM:
|
|
|
+ logger.debug("Chatto: handoff thread skipped — %s is a DM", parent_chat_id)
|
|
|
+ return None
|
|
|
+
|
|
|
+ try:
|
|
|
+ client = await self._require_client()
|
|
|
+ except RuntimeError:
|
|
|
+ logger.warning("Chatto: handoff thread — client unavailable")
|
|
|
+ return None
|
|
|
+
|
|
|
+ seed_text = f"🧵 Hermes handoff — **{(name or 'session').strip()[:80]}**"
|
|
|
+ try:
|
|
|
+ msg = await client.post_message(room_id=str(parent_chat_id), body=seed_text)
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning(
|
|
|
+ "Chatto: handoff thread seed-post failed for room %s: %s",
|
|
|
+ parent_chat_id, e,
|
|
|
+ )
|
|
|
+ return None
|
|
|
+
|
|
|
+ seed_id = getattr(msg, "id", "") or ""
|
|
|
+ if not seed_id:
|
|
|
+ logger.warning("Chatto: handoff thread seed-post returned no message id")
|
|
|
+ return None
|
|
|
+
|
|
|
+ self._mark_seen(seed_id)
|
|
|
+ self._our_message_ids.add(seed_id)
|
|
|
+ self._our_thread_roots.add(seed_id)
|
|
|
+ try:
|
|
|
+ await client.follow_thread(str(parent_chat_id), seed_id)
|
|
|
+ except Exception:
|
|
|
+ logger.debug(
|
|
|
+ "Chatto: follow_thread failed for handoff %s/%s",
|
|
|
+ parent_chat_id, seed_id, exc_info=True,
|
|
|
+ )
|
|
|
+ return seed_id
|
|
|
+
|
|
|
# Overridden from BaseAdapter:
|
|
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
|
"""Start a persistent typing indicator for a room.
|