Преглед на файлове

Split long messages in the standalone sender, unify the 9900 threshold

hermes_standalone_sender_fn posted cron output as one message, so
anything past the server's 10000-char limit failed out-of-process even
though send() splits. It now normalises and chunks like send() — via a
shared _normalise_outbound_text helper and BasePlatformAdapter's static
truncate_message — reporting the first chunk ID and a failure when a
later chunk dies mid-batch.

The threshold itself moves to ChattoConstants.SPLIT_THRESHOLD (9900)
and send() uses it too: the class attribute existed but was never read,
so send() actually split at the bare server limit with no headroom —
code now matches what README and AGENTS.md have claimed all along.
Paul Klumpp преди 1 седмица
родител
ревизия
ac0acf59eb
променени са 4 файла, в които са добавени 135 реда и са изтрити 18 реда
  1. 3 1
      AGENTS.md
  2. 45 17
      adapter.py
  3. 4 0
      platform_config.py
  4. 83 0
      test_adapter.py

+ 3 - 1
AGENTS.md

@@ -115,7 +115,9 @@ changes must keep them, or consciously renegotiate the docs.**
   not intercepted outside DMs.
 - **Length handling.** `send()` splits at 9900 chars against the 10000-char
   server limit; `edit_message()` refuses over-long content so callers fall
-  back to `send()` rather than receiving silent truncation.
+  back to `send()` rather than receiving silent truncation. The standalone
+  cron sender splits the same way, so out-of-process delivery cannot die on
+  the per-message limit either.
 
 ### Auth defaults to closed
 

+ 45 - 17
adapter.py

@@ -239,6 +239,24 @@ def _write_file_bytes(path: str, data: bytes) -> None:
         f.write(data)
 
 
+def _normalise_outbound_text(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. Shared by the adapter's
+    ``format_message`` and the standalone cron sender, so both paths render
+    identically.
+    """
+    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
+
+
 # --------------------------------------------------------------------------- #
 # Adapter
 # --------------------------------------------------------------------------- #
@@ -255,11 +273,10 @@ class ChattoAdapter(BasePlatformAdapter):
     Receives messages via WebSocket realtime, sends via ConnectRPC.
     """
 
-    _SPLIT_THRESHOLD = 9900
     # Read by BasePlatformAdapter.max_message_length_for_chat(), which the
     # gateway and the stream consumer use to chunk outgoing messages. Without
     # it they fall back to 4096 and split Chatto messages far earlier than
-    # necessary — send() itself already truncates at 10000.
+    # necessary — send() itself already truncates at SPLIT_THRESHOLD.
     MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
     splits_long_messages = True
     supports_code_blocks: bool = True
@@ -1734,8 +1751,8 @@ class ChattoAdapter(BasePlatformAdapter):
         if not content:
             return SendResult(success=False, error="Empty message")
 
-        formatted = self.format_message(content)
-        chunks = self.truncate_message(formatted, ChattoConstants.MAX_MESSAGE_LENGTH)
+        formatted = _normalise_outbound_text(content)
+        chunks = self.truncate_message(formatted, ChattoConstants.SPLIT_THRESHOLD)
 
         thread_id = self._resolve_outbound_thread(chat_id, reply_to, metadata)
         room_kind = self._room_kinds.get(chat_id)
@@ -1825,19 +1842,12 @@ class ChattoAdapter(BasePlatformAdapter):
     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.
+        The transformations live in :func:`_normalise_outbound_text`, shared
+        with the standalone cron sender.
 
         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
+        return _normalise_outbound_text(content)
 
     async def edit_message(
         self,
@@ -2687,7 +2697,9 @@ async def hermes_standalone_sender_fn(
     """Deliver a message to Chatto without a running gateway adapter. Do not modify signature.
 
     Used by cron / scheduled routines that run out-of-process. Creates a
-    short-lived chattolib client, posts, and closes.
+    short-lived chattolib client, posts, and closes. Long messages are
+    normalised and split like ``send()`` does, so cron output cannot die on
+    the server's per-message limit.
     """
     chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig=pconfig)
 
@@ -2723,11 +2735,27 @@ async def hermes_standalone_sender_fn(
             kwargs["thread_root_event_id"] = thread_id
         if media_files and media_files.get("attachment_asset_ids"):
             kwargs["attachment_asset_ids"] = list(media_files["attachment_asset_ids"])
+
+        formatted = _normalise_outbound_text(message)
+        chunks = BasePlatformAdapter.truncate_message(
+            formatted, ChattoConstants.SPLIT_THRESHOLD
+        )
+        message_ids: list[str] = []
         try:
-            posted = await client.post_message(chat_id, message, **kwargs)
+            for chunk in chunks:
+                posted = await client.post_message(chat_id, chunk, **kwargs)
+                message_ids.append(posted.id)
         except Exception as exc:
+            if message_ids:
+                logger.warning(
+                    "Chatto standalone: sent %d/%d chunk(s) to %s before failing: %s",
+                    len(message_ids),
+                    len(chunks),
+                    chat_id,
+                    exc,
+                )
             return SendResult(success=False, error=str(exc))
-        return SendResult(success=True, message_id=posted.id)
+        return SendResult(success=True, message_id=message_ids[0])
     finally:
         try:
             await client.close()

+ 4 - 0
platform_config.py

@@ -53,6 +53,10 @@ class ChattoConstants:
     # so there is exactly one source of truth for them.
 
     MAX_MESSAGE_LENGTH = 10000
+    # Outgoing text is split below this rather than at MAX_MESSAGE_LENGTH, so
+    # a full chunk plus the gateway's multi-chunk "(1/2)" label stays under
+    # the server limit.
+    SPLIT_THRESHOLD = 9900
     SEEN_CAP = 500
 
     # WebSocket reconnect backoff (the realtime transport itself lives in

+ 83 - 0
test_adapter.py

@@ -71,6 +71,7 @@ from adapter import (
     _capabilities,
     chat_type_for_room_kind,
     hermes_env_enablement_fn,
+    hermes_standalone_sender_fn,
     register,
 )
 from adapter import (
@@ -2189,3 +2190,85 @@ class TestOpenClientCredentials:
             client_cls.login.assert_awaited_once_with(
                 "u", "p", base_url="https://chat.example.com"
             )
+
+
+# -- Cron / standalone delivery --
+
+
+class TestStandaloneSender:
+    """hermes_standalone_sender_fn normalises and splits like send() does —
+    cron output must not die on the server's per-message limit."""
+
+    def _posted(self, count):
+        """A client whose posts succeed; returns (client, sent_ids)."""
+        sent_ids = []
+
+        async def post(chat_id, body, **kwargs):
+            msg = MagicMock()
+            msg.id = f"msg-{len(sent_ids)}"
+            sent_ids.append(msg.id)
+            return msg
+
+        client = MagicMock()
+        client.post_message = AsyncMock(side_effect=post)
+        client.close = AsyncMock()
+        return client, sent_ids
+
+    async def _send(self, message, **kwargs):
+        cfg = _make_config(token="test-token")
+        client = MagicMock()
+        client.post_message = AsyncMock(return_value=MagicMock(id="msg-only"))
+        client.close = AsyncMock()
+        with patch("adapter.ChattoClient") as client_cls:
+            client_cls.return_value = client
+            result = await hermes_standalone_sender_fn(cfg, "room-1", message, **kwargs)
+        return result, client
+
+    async def test_short_message_posts_once(self):
+        result, client = await self._send("hello")
+        assert result.success is True
+        assert result.message_id == "msg-only"
+        client.post_message.assert_awaited_once()
+
+    async def test_long_message_is_split(self):
+        """A 12k-char cron job becomes several posts, first ID reported."""
+        cfg = _make_config(token="test-token")
+        client, sent_ids = self._posted(5)
+        with patch("adapter.ChattoClient") as client_cls:
+            client_cls.return_value = client
+            result = await hermes_standalone_sender_fn(cfg, "room-1", "x" * 12000)
+
+        assert result.success is True
+        assert result.message_id == "msg-0"
+        assert len(sent_ids) >= 2
+        for call in client.post_message.await_args_list:
+            assert len(call.args[1]) <= ChattoConstants.MAX_MESSAGE_LENGTH
+
+    async def test_failure_in_a_later_chunk_reports_failure(self):
+        cfg = _make_config(token="test-token")
+        client = MagicMock()
+        first = MagicMock()
+        first.id = "msg-1"
+        client.post_message = AsyncMock(side_effect=[first, RuntimeError("boom")])
+        client.close = AsyncMock()
+        with patch("adapter.ChattoClient") as client_cls:
+            client_cls.return_value = client
+            result = await hermes_standalone_sender_fn(cfg, "room-1", "y" * 12000)
+
+        assert result.success is False
+        assert "boom" in (result.error or "")
+        # The short-lived client is closed even on failure.
+        client.close.assert_awaited_once()
+
+    async def test_thread_id_reaches_every_chunk(self):
+        cfg = _make_config(token="test-token")
+        client, _ids = self._posted(5)
+        with patch("adapter.ChattoClient") as client_cls:
+            client_cls.return_value = client
+            result = await hermes_standalone_sender_fn(
+                cfg, "room-1", "z" * 12000, thread_id="root-9"
+            )
+
+        assert result.success is True
+        for call in client.post_message.await_args_list:
+            assert call.kwargs["thread_root_event_id"] == "root-9"