فهرست منبع

Apply code-review findings across adapter, tests and docs

- Classify send failures uniformly on ChattoError: the chunked text
  path no longer reports our own bugs as retryable, and the standalone
  cron sender carries the flag at all (both now tested).
- Hold _seen/_dispatched_ids in bounded deques; the maxlen evicts the
  oldest ID, so the manual trim loops go away.
- Drop _ensure_token: its fast path returned what _get_chatto_client
  returns anyway, and connect() now makes a single client call.
- Reuse the was_dispatched local in the edit path instead of re-checking
  membership.
- Test hygiene: rescue an orphaned docstring into position, drop two
  dead adapter._token fixture lines.
- Docs/metadata: plugin.yaml no longer claims websockets must be
  installed (it is vendored), pyrightconfig drops the stale trial.py
  exclude, ruff joins the dev group, README notes that
  CHATTO_ALLOWED_USERS matches case-sensitively.
Paul Klumpp 1 هفته پیش
والد
کامیت
e3bd0376f3
7فایلهای تغییر یافته به همراه88 افزوده شده و 31 حذف شده
  1. 1 1
      README.md
  2. 19 23
      adapter.py
  3. 2 2
      plugin.yaml
  4. 1 0
      pyproject.toml
  5. 1 2
      pyrightconfig.json
  6. 37 3
      test_adapter.py
  7. 27 0
      uv.lock

+ 1 - 1
README.md

@@ -161,7 +161,7 @@ gateway:
 | `CHATTO_PASSWORD` | Yes* | — | Chatto password |
 | `CHATTO_TOKEN` | No | — | Existing bearer token — alternative to login/password |
 | `CHATTO_HOME_CHANNEL` | No | First joined room | Default delivery target for cron/notification output when there is no inbound conversation context to reply into. Not exempt from the mention lists. |
-| `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins allowed to talk to the agent |
+| `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins (or user IDs) allowed to talk to the agent. Exact match, case-sensitive. |
 | `CHATTO_ALLOW_ALL_USERS` | No | `false` | Allow any Chatto user to talk to the agent (`true`/`false`) |
 | `CHATTO_REQUIRE_MENTION_ROOMS` | No | _(silent everywhere)_ | Comma-separated room IDs where Hermes answers only when addressed (`@name`, `@all`, `@here`). A room on neither mention list stays silent. |
 | `CHATTO_OPTIONAL_MENTION_ROOMS` | No | — | Comma-separated room IDs where Hermes answers every message, addressed or not. Must not overlap `CHATTO_REQUIRE_MENTION_ROOMS` (rejected at startup). |

+ 19 - 23
adapter.py

@@ -30,6 +30,7 @@ import mimetypes
 import os
 import re
 import tempfile
+from collections import deque
 from datetime import UTC, datetime
 from difflib import SequenceMatcher
 from enum import StrEnum
@@ -302,12 +303,13 @@ class ChattoAdapter(BasePlatformAdapter):
         self._room_kinds: dict[str, RoomKind] = {}
         # Event IDs already processed — chattolib may redeliver events across
         # reconnects, so every inbound event is checked against this list.
-        self._seen: list[str] = []
+        # Bounded deques: appending past the cap drops the oldest ID on its own.
+        self._seen: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
         # Message IDs this adapter has handed to the gateway (posted or
         # edit-re-dispatched). Edits of anything on this list never start a
         # fresh turn — that is the lock against re-answering settled
         # conversations by editing old messages.
-        self._dispatched_ids: list[str] = []
+        self._dispatched_ids: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
         # session_key -> message ID currently being processed there. Written
         # by on_processing_start, cleared by on_processing_complete; an edit
         # landing on the recorded ID is a mid-run correction.
@@ -398,16 +400,6 @@ class ChattoAdapter(BasePlatformAdapter):
             raise RuntimeError("Chatto client unavailable")
         return client
 
-    async def _ensure_token(self) -> bool:
-        """Ensure we have a logged-in Chatto client and token."""
-        if self.chatto_config.token.value and isinstance(
-            self._chatto_client, ChattoClient
-        ):
-            return True
-
-        client = await self._get_chatto_client()
-        return client is not None
-
     # ------------------------------------------------------------------ #
     # Connection
     # ------------------------------------------------------------------ #
@@ -439,9 +431,6 @@ class ChattoAdapter(BasePlatformAdapter):
         """
         logger.info("Chatto: connecting...")
 
-        if not await self._ensure_token():
-            return False
-
         client = await self._get_chatto_client()
         if client is None:
             self._set_fatal_error(
@@ -598,9 +587,8 @@ class ChattoAdapter(BasePlatformAdapter):
     # ------------------------------------------------------------------ #
 
     def _mark_seen(self, event_id: str) -> None:
+        # The deque's maxlen evicts the oldest ID — no manual trimming.
         self._seen.append(event_id)
-        while len(self._seen) > ChattoConstants.SEEN_CAP:
-            del self._seen[0]
 
     def _is_seen(self, event_id: str) -> bool:
         return event_id in self._seen
@@ -769,7 +757,7 @@ class ChattoAdapter(BasePlatformAdapter):
         client: ChattoClient,
         argument: str,
     ) -> tuple[str | None, RoomWithViewerState | None]:
-        """Resolve a /join//leave argument to a room.
+        """Resolve a ``/join`` or ``/leave`` argument to a room.
 
         ``#name`` is looked up case-insensitively in a fresh directory scan
         (which also refreshes our name/kind caches); anything else is treated
@@ -1110,8 +1098,6 @@ class ChattoAdapter(BasePlatformAdapter):
         if not message_id:
             return
         self._dispatched_ids.append(message_id)
-        while len(self._dispatched_ids) > ChattoConstants.SEEN_CAP:
-            del self._dispatched_ids[0]
 
     def _edit_is_fresh(self, message: Message) -> bool:
         """Whether this edit is young enough to still be processed.
@@ -1224,7 +1210,7 @@ class ChattoAdapter(BasePlatformAdapter):
             await self.cancel_session_processing(
                 session_key, release_guard=True, discard_pending=False
             )
-        elif payload.message_event_id not in self._dispatched_ids:
+        elif not was_dispatched:
             logger.info(
                 "Chatto: msg %s was never dispatched - edit starts a fresh turn",
                 payload.message_event_id,
@@ -1844,8 +1830,10 @@ class ChattoAdapter(BasePlatformAdapter):
             except Exception as e:
                 # ChattoError included: both read as "this chunk did not go
                 # out" and stop the batch — the SendResult carries the reason.
+                # Only server-side failures count as retryable; a bug in our
+                # own code must not read as a transient network blip.
                 last_error = str(e)
-                retryable = True
+                retryable = isinstance(e, ChattoError)
                 break
 
             self._mark_seen(msg_obj.id)
@@ -2435,6 +2423,8 @@ class ChattoAdapter(BasePlatformAdapter):
             self._mark_seen(msg.id)
             return SendResult(success=True, message_id=msg.id)
         except ChattoError as e:
+            # Same classification as the chunked text path: server-side
+            # failures retry, anything else is ours and must not loop.
             return SendResult(success=False, error=str(e), retryable=True)
         except Exception as e:
             return SendResult(success=False, error=str(e), retryable=False)
@@ -2811,7 +2801,13 @@ async def hermes_standalone_sender_fn(
                     chat_id,
                     exc,
                 )
-            return SendResult(success=False, error=str(exc))
+            # Same classification as the adapter's send paths: only
+            # server-side failures read as retryable.
+            return SendResult(
+                success=False,
+                error=str(exc),
+                retryable=isinstance(exc, ChattoError),
+            )
         return SendResult(success=True, message_id=message_ids[0])
     finally:
         try:

+ 2 - 2
plugin.yaml

@@ -7,8 +7,8 @@ description: >
   Connects to a Chatto server (cloud-hosted or self-hosted team chat) and relays messages
   between rooms/DMs and the Hermes agent. Uses the Chatto ConnectRPC API
   (JSON over HTTP) for outbound messages and the Chatto realtime WebSocket
-  protocol (binary protobuf) for inbound events.  No Python packages
-  required beyond websockets.
+  protocol (binary protobuf) for inbound events.  Nothing to install:
+  chattolib[realtime] and its dependencies are vendored into the plugin.
 author: Chatto community guys
 requires_env:
   - name: CHATTO_BASE_URL

+ 1 - 0
pyproject.toml

@@ -21,6 +21,7 @@ dev = [
     "pytest>=9.0",
     "pytest-asyncio>=0.24",
     "basedpyright>=1.29",
+    "ruff>=0.9",
 ]
 
 [tool.setuptools.packages.find]

+ 1 - 2
pyrightconfig.json

@@ -11,8 +11,7 @@
     "dist",
     ".venv",
     "test_adapter.py",
-    "test_platform_config.py",
-    "trial.py"
+    "test_platform_config.py"
   ],
   "typeCheckingMode": "standard",
   "pythonVersion": "3.11",

+ 37 - 3
test_adapter.py

@@ -39,6 +39,7 @@ from vendor_path import setup_vendor_path
 setup_vendor_path()
 
 from chattolib.client import ChattoClient
+from chattolib.exceptions import ChattoError
 from chattolib.realtime_types import ReactionPayload
 from chattolib.types import (
     Asset,
@@ -466,6 +467,19 @@ class TestSend:
         assert result.success is True
         assert not result.raw_response or isinstance(result.raw_response, dict)
 
+    async def test_send_server_error_is_retryable(self, adapter):
+        adapter._chatto_client.post_message.side_effect = ChattoError("server says no")
+        result = await adapter.send("room-1", "Hello world")
+        assert result.success is False
+        assert result.retryable is True
+
+    async def test_send_unexpected_error_is_not_retryable(self, adapter):
+        """A bug in our own code must not read as a transient network blip."""
+        adapter._chatto_client.post_message.side_effect = RuntimeError("bug")
+        result = await adapter.send("room-1", "Hello world")
+        assert result.success is False
+        assert result.retryable is False
+
 
 # -- Reactions --
 
@@ -535,7 +549,6 @@ class TestMessageEditing:
         adapter._chatto_client = MagicMock()
         adapter._chatto_client.update_message = AsyncMock()
         adapter._chatto_client.delete_message = AsyncMock(return_value=True)
-        adapter._token = "test-token"
         return adapter
 
     async def test_edit_message(self, adapter):
@@ -1763,7 +1776,6 @@ class TestRoomOperations:
         # swallow into a None return.
         adapter._chatto_client.create_room = AsyncMock()
         adapter._chatto_client.start_dm = AsyncMock()
-        adapter._token = "test-token"
         adapter._room_names = {}
         adapter._room_kinds = {}
         return adapter
@@ -1994,9 +2006,9 @@ class TestDmRoomCommands:
         assert "home channel" in self._reply(adapter)
 
     async def test_commands_outside_dms_are_ignored(self):
-        adapter = self._adapter()
         """In a channel the text is just a message — mention gating applies,
         no command runs, nothing is sent."""
+        adapter = self._adapter()
         adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
         adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
 
@@ -2304,6 +2316,28 @@ class TestStandaloneSender:
         # The short-lived client is closed even on failure.
         client.close.assert_awaited_once()
 
+    @pytest.mark.parametrize(
+        ("error", "expected_retryable"),
+        [
+            (ChattoError("boom"), True),
+            (RuntimeError("boom"), False),
+        ],
+    )
+    async def test_failure_classification_matches_the_send_paths(
+        self, error, expected_retryable
+    ):
+        """Server-side failures retry; our own bugs must not loop."""
+        cfg = _make_config(token="test-token")
+        client = MagicMock()
+        client.post_message = AsyncMock(side_effect=error)
+        client.close = AsyncMock()
+        with patch("adapter.ChattoClient") as client_cls:
+            client_cls.return_value = client
+            result = await hermes_standalone_sender_fn(cfg, "room-1", "hello")
+
+        assert result.success is False
+        assert result.retryable is expected_retryable
+
     async def test_thread_id_reaches_every_chunk(self):
         cfg = _make_config(token="test-token")
         client, _ids = self._posted(5)

+ 27 - 0
uv.lock

@@ -33,6 +33,7 @@ dev = [
     { name = "basedpyright" },
     { name = "pytest" },
     { name = "pytest-asyncio" },
+    { name = "ruff" },
 ]
 
 [package.metadata]
@@ -42,6 +43,7 @@ dev = [
     { name = "basedpyright", specifier = ">=1.29" },
     { name = "pytest", specifier = ">=9.0" },
     { name = "pytest-asyncio", specifier = ">=0.24" },
+    { name = "ruff", specifier = ">=0.9" },
 ]
 
 [[package]]
@@ -125,6 +127,31 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
 ]
 
+[[package]]
+name = "ruff"
+version = "0.16.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
+    { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
+    { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
+    { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
+    { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
+]
+
 [[package]]
 name = "typing-extensions"
 version = "4.16.0"