Переглянути джерело

Add CHATTO_RESPOND_ROOMS as a positive list of answer rooms

Server-forced universal rooms (e.g. announcement channels every
account is joined to) ended up watched and answered like any other
membership. With CHATTO_RESPOND_ROOMS set, only listed rooms are read
and answered; other joined memberships become read-only: still watched
and marked as read, but never seeded into context and never answered.
The gate sits at the very top of _dispatch_message_posted, before any
API call; DMs are exempt so /join stays reachable and unknown room
kinds fail closed. The watch log tags [read-only] and [universal] so
forced room IDs are easy to find, and unjoined list entries are warned
about. CHATTO_HOME_CHANNEL is documented as orthogonal: it is the
default outbound target for context-less cron/notification delivery,
not an answer destination.
Paul Klumpp 1 тиждень тому
батько
коміт
fa1a586deb
7 змінених файлів з 225 додано та 9 видалено
  1. 8 0
      AGENTS.md
  2. 17 4
      README.md
  3. 52 2
      adapter.py
  4. 5 3
      after-install.md
  5. 5 0
      platform_config.py
  6. 4 0
      plugin.yaml
  7. 134 0
      test_adapter.py

+ 8 - 0
AGENTS.md

@@ -72,6 +72,14 @@ changes must keep them, or consciously renegotiate the docs.**
   channel-kind rooms answer only messages @-mentioning the bot; group-kind
   rooms and DMs answer regardless. In channels with the gate off, a message
   aimed at someone else gets a 🫥 acknowledgement instead of an answer.
+- **Respond rooms are a positive list, inbound only.** With
+  `CHATTO_RESPOND_ROOMS` set, the bot reads and answers only in listed rooms;
+  other joined memberships stay read-only — watched and marked as read, never
+  seeded into context, never answered, no processing reactions. The gate sits
+  at the top of `_dispatch_message_posted`, before any API call; unknown room
+  kinds fail closed. DMs are exempt so `/join` stays reachable, and
+  `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for
+  context-less cron/notification delivery, not an answer destination.
 - **Threads stay threads.** An inbound thread reply keeps its thread context;
   a fresh room reply opens a thread under the incoming message unless
   `auto_thread` is disabled or the room is a DM.

+ 17 - 4
README.md

@@ -93,9 +93,14 @@ CHATTO_BASE_URL=https://chat.example.com
 CHATTO_LOGIN=hermes
 CHATTO_PASSWORD=your-password
 
-# Optional: home channel for cron/notification delivery
+# Optional: home channel for cron/notification delivery when there is no
+# conversation context to reply into — not exempt from CHATTO_RESPOND_ROOMS
 # CHATTO_HOME_CHANNEL=REljMv5Pgolo6Y9
 
+# Optional: room IDs the bot reads and answers in; other joined rooms stay
+# read-only (marked as read, never seeded into context, never answered)
+# CHATTO_RESPOND_ROOMS=REljMv5Pgolo6Y9
+
 # Optional: restrict who can talk to the bot (comma-separated logins)
 # CHATTO_ALLOWED_USERS=alice,bob
 
@@ -129,6 +134,7 @@ gateway:
       extra:
         base_url: https://chat.example.com
         home_channel: REljMv5Pgolo6Y9
+        respond_rooms: []          # empty = read and answer in every joined room
         require_mention: false     # only respond to @mentions in channels
         allowed_users: []          # empty = deny all (or set allow_all_users)
         allow_all_users: true
@@ -144,7 +150,8 @@ gateway:
 | `CHATTO_LOGIN` | Yes* | — | Chatto username (login) |
 | `CHATTO_PASSWORD` | Yes* | — | Chatto password |
 | `CHATTO_TOKEN` | No | — | Existing bearer token — alternative to login/password |
-| `CHATTO_HOME_CHANNEL` | No | First watched room | Room ID for cron/notification delivery |
+| `CHATTO_HOME_CHANNEL` | No | First watched room | Default delivery target for cron/notification output when there is no inbound conversation context to reply into. Not exempt from `CHATTO_RESPOND_ROOMS`. |
+| `CHATTO_RESPOND_ROOMS` | No | _(every joined room)_ | Comma-separated room IDs the bot reads and answers in; other joined rooms stay read-only — marked as read, never seeded into context, never answered |
 | `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins allowed to talk to the agent |
 | `CHATTO_ALLOW_ALL_USERS` | No | `false` | Allow any Chatto user to talk to the agent (`true`/`false`) |
 | `CHATTO_REQUIRE_MENTION` | No | `false` | Only respond to `@mentions` in channels. DMs always get a response. |
@@ -251,6 +258,12 @@ Presence is a TTL the server lets lapse, not a flag that stays set: `UpdatePrese
 
 ## Usage Notes
 
+### Respond Rooms
+
+With `CHATTO_RESPOND_ROOMS` set to a comma-separated list of room IDs, the bot reads and answers only in those rooms. Other joined rooms stay read-only: they remain watched and are marked as read, but their history is never seeded into the agent's context and their messages never reach the agent pipeline — no answers, no processing reactions. This is the intended way to quiet server-forced rooms that every account is joined to automatically, such as announcement channels (the watch log marks them `[universal]`).
+
+The list gates inbound replies only. DMs always get a response so `/join` stays reachable, and context-less cron/notification delivery via `CHATTO_HOME_CHANNEL` is unaffected. When unset (default), every joined room is a respond room.
+
 ### Mention Detection
 
 With `CHATTO_REQUIRE_MENTION=true`, Hermes answers channel-kind rooms only when the message contains an `@mention` of the bot's login or display name. In group-kind rooms and DMs every message gets a response. A channel message aimed at someone *else* is acknowledged with a 🫥 reaction instead of an answer.
@@ -268,12 +281,12 @@ Setting both at once is rejected as conflicting configuration.
 
 ### Home Channel
 
-The home channel is where the bot sends proactive messages — cron job output, reminders, and notifications. Set it via:
+The home channel is the default outbound target for proactive messages — cron job output, reminders, and notifications when there is no inbound conversation context to reply into. Set it via:
 
 - `CHATTO_HOME_CHANNEL` env var (room ID)
 - `config.yaml` → `gateway.platforms.chatto.extra.home_channel`
 
-If unset, the first watched room is used as the default home channel.
+If unset, the first watched room is used as the default home channel. It is not exempt from `CHATTO_RESPOND_ROOMS`: with a respond list set, the home channel still receives cron/notification posts, but the bot converses there only if the room is listed too.
 
 ### Managing Rooms over DM
 

+ 52 - 2
adapter.py

@@ -276,6 +276,9 @@ class ChattoAdapter(BasePlatformAdapter):
         self._seen: list[str] = []  # Plain RealtimeEvent-id list
         self._resume_cursor: str | None = None
         self._watch_room_ids: list[str] = []
+        # Rooms the server force-joined everyone into (Room.universal) — used
+        # only for [universal] tags in the watch log, never for gating.
+        self._universal_room_ids: set[str] = set()
         # One-shot guard for the unjoined-home-channel warning in _refresh_rooms.
         self._home_warning_logged = False
         self._ws_task: asyncio.Task | None = None
@@ -876,7 +879,30 @@ class ChattoAdapter(BasePlatformAdapter):
             return MessageType.AUDIO
         return MessageType.TEXT
 
+    def _is_respond_room(self, room_id: str) -> bool:
+        """Whether inbound messages from this room reach the agent pipeline.
+
+        With ``CHATTO_RESPOND_ROOMS`` set, every non-DM room is gated against
+        that positive list; rooms outside it stay read-only (marked as read,
+        never seeded or answered). DMs are always respond rooms so ``/join``
+        stays reachable, and unknown room kinds fail closed.
+        """
+        respond_rooms = self.chatto_config.respond_rooms.value
+        if not respond_rooms:
+            return True
+        if self._room_kinds.get(room_id) == RoomKind.DM:
+            return True
+        return room_id in respond_rooms
+
     async def _dispatch_message_posted(self, payload: MessagePostedPayload) -> None:
+        # Respond-room gate first: read-only memberships must not cost a
+        # single API call, so this runs before fetch_message and _require_client.
+        if not self._is_respond_room(payload.room_id):
+            logger.debug(
+                "Chatto: message from read-only room %s ignored", payload.room_id
+            )
+            return
+
         try:
             client = await self._require_client()
         except RuntimeError:
@@ -1239,6 +1265,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
         try:
             rooms_list = await client.list_rooms()
+            respond_rooms = self.chatto_config.respond_rooms.value
             member_ids: set[str] = set()
             new_room_ids: list[str] = []
 
@@ -1252,6 +1279,8 @@ class ChattoAdapter(BasePlatformAdapter):
 
                 self._room_names[room_obj.id] = room_obj.name
                 self._room_kinds[room_obj.id] = room_obj.kind
+                if room_obj.universal:
+                    self._universal_room_ids.add(room_obj.id)
 
                 if not room_with_state.viewer_state.is_member:
                     continue
@@ -1274,6 +1303,14 @@ class ChattoAdapter(BasePlatformAdapter):
                     stale_room_ids,
                 )
 
+            unjoined_listed = [rid for rid in respond_rooms if rid not in member_ids]
+            if unjoined_listed:
+                logger.warning(
+                    "Chatto WS: CHATTO_RESPOND_ROOMS lists room(s) we are not a"
+                    " member of: %s",
+                    unjoined_listed,
+                )
+
             self._warn_if_home_channel_unjoined(member_ids)
 
             if not new_room_ids:
@@ -1290,12 +1327,25 @@ class ChattoAdapter(BasePlatformAdapter):
                     await client.join_room(
                         rid
                     )  # but list_rooms() would not return any if we were not already joined?!
-                await self._seed_room(rid)
+                if self._is_respond_room(rid):
+                    await self._seed_room(rid)
+                else:
+                    logger.info(
+                        "Chatto WS: %s (%s) is outside CHATTO_RESPOND_ROOMS -"
+                        " watching read-only",
+                        self._room_names.get(rid, rid),
+                        rid,
+                    )
                 self._watch_room_ids.append(rid)
 
             watch_room_names: list[str] = []
             for rid in self._watch_room_ids:
-                watch_room_names.append(self._room_names[rid] + " (" + rid + ")")
+                name = self._room_names[rid]
+                if not self._is_respond_room(rid):
+                    name += " [read-only]"
+                if rid in self._universal_room_ids:
+                    name += " [universal]"
+                watch_room_names.append(name + " (" + rid + ")")
 
             logger.info(
                 "Chatto WS: Watching %d room(s): %s",

+ 5 - 3
after-install.md

@@ -34,9 +34,11 @@ can live in either file. Environment variables take precedence over
 
 2. Home channel (optional)
 
-   Where cron job output and notifications get delivered. If unset (the usual
-   case — the installer only asks when you enter one), the first watched room
-   is used:
+   Where cron job output and notifications get delivered when there is no
+   conversation context to reply into — the default outbound target, nothing
+   more. With `CHATTO_RESPOND_ROOMS` set, list it too if the bot should also
+   converse there. If unset (the usual case — the installer only asks when
+   you enter one), the first watched room is used:
 
    ```
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE

+ 5 - 0
platform_config.py

@@ -308,6 +308,11 @@ class ChattoConfiguration:
     free_response_channels_list = ConfigField(
         "list", config_key="free_response_channels"
     )
+    # respond_rooms: positive list of room IDs the bot reads and answers in.
+    # Joined rooms outside the list stay watched and marked as read, but are
+    # never seeded into context or answered; DMs are exempt. Empty means every
+    # joined room is a respond room (the behaviour before this knob existed).
+    respond_rooms = ConfigField("list")
     # Auto-thread: by default, Chatto creates a thread for replies to room
     # messages (not DMs, not already in a thread). This keeps conversations
     # organized in the room. Can be disabled via extra.auto_thread=false.

+ 4 - 0
plugin.yaml

@@ -50,6 +50,10 @@ optional_env:
     description: "Auto-create threads for replies in rooms (default: true). Set false to reply in the room timeline."
     prompt: "Auto-thread replies? (true/false)"
     password: false
+  - name: CHATTO_RESPOND_ROOMS
+    description: "Comma-separated room IDs the bot reads and answers in; other joined rooms stay read-only — marked as read, never seeded into context, never answered. Inbound replies only; context-less cron/notification delivery via CHATTO_HOME_CHANNEL is unaffected. (default: none — every joined room is a respond room)"
+    prompt: "Respond room IDs (comma-separated)"
+    password: false
   - name: CHATTO_FREE_RESPONSE_CHANNELS
     description: "Comma-separated room IDs where the bot responds without being tagged (default: none)"
     prompt: "Free-response room IDs (comma-separated)"

+ 134 - 0
test_adapter.py

@@ -122,12 +122,14 @@ _CHATTO_ENV_KEYS = [
     "CHATTO_BASE_URL",
     "CHATTO_LOGIN",
     "CHATTO_PASSWORD",
+    "CHATTO_TOKEN",
     "CHATTO_HOME_CHANNEL",
     "CHATTO_REQUIRE_MENTION",
     "CHATTO_ALLOWED_USERS",
     "CHATTO_ALLOW_ALL_USERS",
     "CHATTO_AUTO_THREAD",
     "CHATTO_REACTIONS",
+    "CHATTO_RESPOND_ROOMS",
 ]
 
 
@@ -1518,6 +1520,138 @@ class TestRoomWatchRefresh:
         assert not adapter._home_warning_logged
 
 
+class TestRespondRooms:
+    """CHATTO_RESPOND_ROOMS gates inbound messages: positive list, DMs exempt."""
+
+    def _dropping_adapter(self, respond_rooms):
+        """An adapter whose every API call explodes — the gate must exit first."""
+        adapter = _make_adapter()
+        adapter.chatto_config.respond_rooms.value = respond_rooms
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter.handle_message = AsyncMock()
+        adapter._require_client = AsyncMock(side_effect=RuntimeError("no client"))
+        return adapter
+
+    async def test_unlisted_room_is_dropped_before_any_api_call(self):
+        adapter = self._dropping_adapter(["listed-1"])
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        payload = _make_posted_payload(room_id="room-1")
+        payload.fetch_message = AsyncMock()
+
+        await adapter._dispatch_message_posted(payload)
+
+        payload.fetch_message.assert_not_awaited()
+        adapter.handle_message.assert_not_called()
+
+    async def test_unknown_room_kind_fails_closed(self):
+        """No cached kind: the allowlist assumes the worst and drops."""
+        adapter = self._dropping_adapter(["listed-1"])
+        payload = _make_posted_payload(room_id="mystery-room")
+        payload.fetch_message = AsyncMock()
+
+        await adapter._dispatch_message_posted(payload)
+
+        payload.fetch_message.assert_not_awaited()
+        adapter.handle_message.assert_not_called()
+
+    async def test_listed_room_reaches_the_pipeline(self):
+        adapter = self._dropping_adapter(["room-1"])
+        adapter.chatto_config.allow_all_users.value = True
+        adapter._require_client = AsyncMock(return_value=adapter._chatto_client)
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        payload = _make_posted_payload(room_id="room-1")
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(body="hi", room_id="room-1")
+        )
+
+        await adapter._dispatch_message_posted(payload)
+
+        adapter.handle_message.assert_called_once()
+
+    async def test_dm_outside_the_list_still_answers(self):
+        """DMs stay respond rooms so /join remains reachable."""
+        adapter = self._dropping_adapter(["listed-1"])
+        adapter.chatto_config.allow_all_users.value = True
+        adapter._require_client = AsyncMock(return_value=adapter._chatto_client)
+        adapter._room_kinds["dm-1"] = RoomKind.DM
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        payload = _make_posted_payload(room_id="dm-1")
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(body="hi", room_id="dm-1")
+        )
+
+        await adapter._dispatch_message_posted(payload)
+
+        adapter.handle_message.assert_called_once()
+
+    async def test_empty_list_answers_everywhere(self):
+        """Unset list keeps the pre-existing behaviour: every room responds."""
+        adapter = self._dropping_adapter([])
+        adapter.chatto_config.allow_all_users.value = True
+        adapter._require_client = AsyncMock(return_value=adapter._chatto_client)
+        adapter._room_kinds["any-room"] = RoomKind.CHANNEL
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        payload = _make_posted_payload(room_id="any-room")
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(body="hi", room_id="any-room")
+        )
+
+        await adapter._dispatch_message_posted(payload)
+
+        adapter.handle_message.assert_called_once()
+
+
+class TestRespondRoomRefresh:
+    """_refresh_rooms keeps read-only rooms watched but skips seeding them."""
+
+    def _adapter(self, respond_rooms):
+        adapter = _make_adapter()
+        adapter.chatto_config.respond_rooms.value = respond_rooms
+        client = adapter._chatto_client
+        client.list_rooms = AsyncMock(return_value=[])
+        client.join_room = AsyncMock()
+        adapter._seed_room = AsyncMock()
+        return adapter
+
+    async def test_read_only_rooms_are_watched_but_not_seeded(self):
+        adapter = self._adapter(["team-1"])
+        news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
+        team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[news, team])
+
+        await adapter._refresh_rooms()
+
+        assert sorted(adapter._watch_room_ids) == ["news-1", "team-1"]
+        adapter._seed_room.assert_awaited_once_with("team-1")
+
+    async def test_watch_log_tags_read_only_and_universal(self, caplog):
+        adapter = self._adapter(["team-1"])
+        news = Room(id="news-1", name="News", kind=RoomKind.CHANNEL, universal=True)
+        team = Room(id="team-1", name="Team", kind=RoomKind.CHANNEL, universal=False)
+        adapter._chatto_client.list_rooms = AsyncMock(
+            return_value=[_make_room_state(news, True), _make_room_state(team, True)]
+        )
+
+        with caplog.at_level("INFO"):
+            await adapter._refresh_rooms()
+
+        watching = [m for m in caplog.messages if "Watching" in m]
+        assert watching, "expected the watch summary log line"
+        assert "[read-only]" in watching[-1]
+        assert "[universal]" in watching[-1]
+
+    async def test_warns_when_respond_list_names_unjoined_rooms(self, caplog):
+        adapter = self._adapter(["ghost-id"])
+        kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
+
+        with caplog.at_level("WARNING"):
+            await adapter._refresh_rooms()
+
+        assert any("ghost-id" in message for message in caplog.messages)
+
+
 # -- Constants --