Kaynağa Gözat

Rename the watch list to joined rooms

_watch_room_ids became _joined_room_ids; logs and docs now say
'currently joined in N room(s)' instead of 'Watching', so the wording
matches what it means: server-side membership, not a separate
listening concept. Respond-room naming is unchanged.
Paul Klumpp 1 hafta önce
ebeveyn
işleme
082c3a5995
7 değiştirilmiş dosya ile 64 ekleme ve 63 silme
  1. 2 2
      AGENTS.md
  2. 6 6
      README.md
  3. 29 28
      adapter.py
  4. 1 1
      after-install.md
  5. 2 2
      platform_config.py
  6. 1 1
      plugin.yaml
  7. 23 23
      test_adapter.py

+ 2 - 2
AGENTS.md

@@ -74,7 +74,7 @@ changes must keep them, or consciously renegotiate the docs.**
   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
+  other joined memberships stay read-only — still 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
@@ -88,7 +88,7 @@ changes must keep them, or consciously renegotiate the docs.**
   setting, never applied to the bot's own events.
 - **Room membership is server-side, commands ride over DMs.** `/join` and
   `/leave` in a DM call RoomService/JoinRoom/LeaveRoom, so membership survives
-  restarts; the watch list mirrors it on every `_refresh_rooms()`. DMs and the
+  restarts; the joined list mirrors it on every `_refresh_rooms()`. DMs and the
   configured home channel refuse `/leave`. Commands never reach the agent
   pipeline and are not intercepted outside DMs.
 - **Length handling.** `send()` splits at 9900 chars against the 10000-char

+ 6 - 6
README.md

@@ -50,7 +50,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 
 1. **A running Chatto server** — self-hosted and accessible from the Hermes host. See the [Chatto repository](https://github.com/chattocorp/chatto) for installation instructions.
 2. **A Chatto user account** — the adapter logs in with a username and password (or token). Create a dedicated account for the bot (e.g., `hermes`).
-3. **Room membership** — the bot account must be a member of any room where you want it to respond; the adapter watches every room the account has joined. Membership can be granted natively in Chatto (invite the bot account), or by DMing the bot `/join <room-id or #name>` (see [Managing Rooms over DM](#managing-rooms-over-dm)). For DMs, simply start a direct message with the bot.
+3. **Room membership** — the bot account must be a member of any room where you want it to respond; the adapter picks up every room the account has joined. Membership can be granted natively in Chatto (invite the bot account), or by DMing the bot `/join <room-id or #name>` (see [Managing Rooms over DM](#managing-rooms-over-dm)). For DMs, simply start a direct message with the bot.
 4. **Network access** — the Hermes host must reach the Chatto server URL over HTTPS (or HTTP) and establish a WebSocket connection to `/api/realtime`.
 
 > **Info:** The adapter uses WebSocket protocol v1 (compatible with Chatto v0.4.20+). Ensure your Chatto server is up to date.
@@ -150,7 +150,7 @@ 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 | Default delivery target for cron/notification output when there is no inbound conversation context to reply into. Not exempt from `CHATTO_RESPOND_ROOMS`. |
+| `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 `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`) |
@@ -232,7 +232,7 @@ If the realtime stream drops, the adapter reconnects automatically with exponent
 
 ### Read State Management
 
-After discovering rooms, the adapter marks watched rooms as read and dismisses pending notifications (`MarkRoomAsRead` / `DismissAllNotifications`), keeping the bot's unread indicators clean.
+After discovering rooms, the adapter marks joined rooms as read and dismisses pending notifications (`MarkRoomAsRead` / `DismissAllNotifications`), keeping the bot's unread indicators clean.
 
 ### DM Initiation
 
@@ -260,7 +260,7 @@ Presence is a TTL the server lets lapse, not a flag that stays set: `UpdatePrese
 
 ### 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]`).
+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 joined 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 joined-rooms log line 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.
 
@@ -288,7 +288,7 @@ The home channel is the default outbound target for proactive messages — cron
 - `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. 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.
+If unset, the first joined 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
 
@@ -316,7 +316,7 @@ The Chatto adapter logs each connection attempt, room discovery (`_refresh_rooms
 
 **Cause**: The bot account is not a member of the room, or the user is not in `CHATTO_ALLOWED_USERS`.
 
-**Fix**: Verify the bot is a member of the room (the adapter watches all joined rooms by default; DM the bot `/join <room-id or #name>` to add it). Check that your Chatto login is in `CHATTO_ALLOWED_USERS`, or set `CHATTO_ALLOW_ALL_USERS=true`. Restart the gateway.
+**Fix**: Verify the bot is a member of the room (the adapter picks up all joined rooms by default; DM the bot `/join <room-id or #name>` to add it). Check that your Chatto login is in `CHATTO_ALLOWED_USERS`, or set `CHATTO_ALLOW_ALL_USERS=true`. Restart the gateway.
 
 ### Connection refused / WebSocket fails to connect
 

+ 29 - 28
adapter.py

@@ -275,9 +275,10 @@ class ChattoAdapter(BasePlatformAdapter):
         # Event IDs already processed — chattolib may redeliver events across
         # reconnects, so every inbound event is checked against this list.
         self._seen: list[str] = []
-        self._watch_room_ids: list[str] = []
+        self._joined_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.
+        # only for [universal] tags in the joined-rooms log line, 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
@@ -702,10 +703,10 @@ class ChattoAdapter(BasePlatformAdapter):
         return None, matches[0][0]
 
     async def _run_join(self, client: ChattoClient, state: RoomWithViewerState) -> str:
-        """Join a room via RoomService/JoinRoom and watch it immediately.
+        """Join a room via RoomService/JoinRoom and track it as joined.
 
         An account that already holds membership (invited natively in Chatto)
-        needs no JoinRoom call — it only gets seeded into the watch list.
+        needs no JoinRoom call — it only gets seeded and added to the list.
         """
         room_obj = state.room
         if room_obj is None:
@@ -722,15 +723,15 @@ class ChattoAdapter(BasePlatformAdapter):
                 return f"Could not join {label}: {exc}"
         self._room_names[joined_room.id] = joined_room.name
         self._room_kinds[joined_room.id] = joined_room.kind
-        if joined_room.id not in self._watch_room_ids:
+        if joined_room.id not in self._joined_room_ids:
             await self._seed_room(joined_room.id)
-            self._watch_room_ids.append(joined_room.id)
+            self._joined_room_ids.append(joined_room.id)
         if state.viewer_state.is_member:
-            return f"Already a member of {label} — watching it."
+            return f"Already a member of {label} — listening there."
         return f"Joined {label}."
 
     async def _run_leave(self, client: ChattoClient, state: RoomWithViewerState) -> str:
-        """Leave a room via RoomService/LeaveRoom and stop watching it.
+        """Leave a room via RoomService/LeaveRoom and drop it from the joined list.
 
         Two rooms are refused: a DM conversation cannot be left, and leaving
         the configured home channel would silently break cron/notification
@@ -757,8 +758,8 @@ class ChattoAdapter(BasePlatformAdapter):
             return f"Could not leave {label}: {exc}"
         if not left:
             return f"Chatto refused to leave {label}."
-        if room_obj.id in self._watch_room_ids:
-            self._watch_room_ids.remove(room_obj.id)
+        if room_obj.id in self._joined_room_ids:
+            self._joined_room_ids.remove(room_obj.id)
         return f"Left {label}."
 
     # ------------------------------------------------------------------ #
@@ -1162,7 +1163,7 @@ class ChattoAdapter(BasePlatformAdapter):
                 await self._refresh_rooms()
                 logger.info(
                     "Chatto: starting chattolib event stream with %d rooms",
-                    len(self._watch_room_ids),
+                    len(self._joined_room_ids),
                 )
 
                 async for event in stream_events(client):
@@ -1242,7 +1243,7 @@ class ChattoAdapter(BasePlatformAdapter):
         if (
             not home_id
             or home_id in member_ids
-            or home_id in self._watch_room_ids
+            or home_id in self._joined_room_ids
             or self._home_warning_logged
         ):
             return
@@ -1284,17 +1285,17 @@ class ChattoAdapter(BasePlatformAdapter):
                 if not room_with_state.viewer_state.is_member:
                     continue
                 member_ids.add(room_obj.id)
-                if room_obj.id not in self._watch_room_ids:
+                if room_obj.id not in self._joined_room_ids:
                     new_room_ids.append(room_obj.id)
 
-            # Watched rooms we no longer belong to (left via /leave, kicked,
-            # deleted) drop out here — otherwise the next refresh would
-            # quietly re-add what /leave just removed.
+            # Left via /leave, kicked, deleted: rooms we no longer belong
+            # to drop out here — otherwise the next refresh would quietly
+            # re-add what /leave just removed.
             stale_room_ids = [
-                rid for rid in self._watch_room_ids if rid not in member_ids
+                rid for rid in self._joined_room_ids if rid not in member_ids
             ]
             for rid in stale_room_ids:
-                self._watch_room_ids.remove(rid)
+                self._joined_room_ids.remove(rid)
             if stale_room_ids:
                 logger.info(
                     "Chatto WS: no longer a member of %d room(s): %s",
@@ -1331,25 +1332,25 @@ class ChattoAdapter(BasePlatformAdapter):
                 else:
                     logger.info(
                         "Chatto WS: %s (%s) is outside CHATTO_RESPOND_ROOMS -"
-                        " watching read-only",
+                        " joined read-only",
                         self._room_names.get(rid, rid),
                         rid,
                     )
-                self._watch_room_ids.append(rid)
+                self._joined_room_ids.append(rid)
 
-            watch_room_names: list[str] = []
-            for rid in self._watch_room_ids:
+            joined_room_names: list[str] = []
+            for rid in self._joined_room_ids:
                 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 + ")")
+                joined_room_names.append(name + " (" + rid + ")")
 
             logger.info(
-                "Chatto WS: Watching %d room(s): %s",
-                len(self._watch_room_ids),
-                ", ".join(watch_room_names),
+                "Chatto WS: currently joined in %d room(s): %s",
+                len(self._joined_room_ids),
+                ", ".join(joined_room_names),
             )
 
         except Exception:
@@ -1358,8 +1359,8 @@ class ChattoAdapter(BasePlatformAdapter):
         # ------------------------------------------------------------------ #
         # Read state & notification dismissal (best-effort, Chatto-unique)
         # ------------------------------------------------------------------ #
-        # Best-effort: mark all watched rooms as read (room_id may be undefined here)
-        for _rid in list(self._watch_room_ids):
+        # Best-effort: mark all joined rooms as read (room_id may be undefined here)
+        for _rid in list(self._joined_room_ids):
             try:
                 await client.mark_room_as_read(room_id=_rid)
                 await client.dismiss_all_notifications()

+ 1 - 1
after-install.md

@@ -38,7 +38,7 @@ can live in either file. Environment variables take precedence over
    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:
+   you enter one), the first joined room is used:
 
    ```
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE

+ 2 - 2
platform_config.py

@@ -309,8 +309,8 @@ class ChattoConfiguration:
         "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
+    # Rooms outside the list stay joined 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

+ 1 - 1
plugin.yaml

@@ -29,7 +29,7 @@ optional_env:
     prompt: "Existing bearer token (or empty)"
     password: true
   - name: CHATTO_HOME_CHANNEL
-    description: "Room ID for cron / notification delivery (defaults to the first watched room)"
+    description: "Room ID for cron / notification delivery (defaults to the first joined room)"
     prompt: "Home room ID (or empty)"
     password: false
   # Asked before CHATTO_ALLOWED_USERS: a "yes" here makes the user list moot,

+ 23 - 23
test_adapter.py

@@ -1344,7 +1344,7 @@ class TestDmRoomCommands:
         assert adapter.send.await_count == 1
         return adapter.send.await_args.kwargs["content"]
 
-    async def test_join_by_name_joins_and_watches(self):
+    async def test_join_by_name_joins_and_seeds(self):
         adapter = self._adapter()
         state = _make_room_state(
             _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
@@ -1355,20 +1355,20 @@ class TestDmRoomCommands:
         await self._dispatch(adapter, "/join #deploy")
 
         adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
-        assert adapter._watch_room_ids == ["room-9"]
+        assert adapter._joined_room_ids == ["room-9"]
         assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
 
     async def test_join_skips_rpc_when_already_a_member(self):
         adapter = self._adapter()
         """Natively invited accounts hold membership already — they only need
-        seeding into the watch list."""
+        seeding into the joined list."""
         state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
 
         await self._dispatch(adapter, "/join #deploy")
 
         adapter._chatto_client.join_room.assert_not_awaited()
-        assert adapter._watch_room_ids == ["room-9"]
+        assert adapter._joined_room_ids == ["room-9"]
         assert "Already a member" in self._reply(adapter)
 
     async def test_join_unknown_name_reports_without_joining(self):
@@ -1379,7 +1379,7 @@ class TestDmRoomCommands:
 
         adapter._chatto_client.join_room.assert_not_awaited()
         assert "No room named '#nope'" in self._reply(adapter)
-        assert adapter._watch_room_ids == []
+        assert adapter._joined_room_ids == []
 
     async def test_ambiguous_name_offers_the_candidate_ids(self):
         adapter = self._adapter()
@@ -1408,18 +1408,18 @@ class TestDmRoomCommands:
 
         adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
         adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
-        assert adapter._watch_room_ids == ["room-9"]
+        assert adapter._joined_room_ids == ["room-9"]
 
-    async def test_leave_stops_watching_the_room(self):
+    async def test_leave_drops_the_room_from_the_joined_list(self):
         adapter = self._adapter()
-        adapter._watch_room_ids = ["room-7"]
+        adapter._joined_room_ids = ["room-7"]
         state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
         await self._dispatch(adapter, "/leave room-7")
 
         adapter._chatto_client.leave_room.assert_awaited_once_with("room-7")
-        assert adapter._watch_room_ids == []
+        assert adapter._joined_room_ids == []
         assert "Left 'Deploy' (room-7)" in self._reply(adapter)
 
     async def test_leave_refuses_direct_messages(self):
@@ -1436,7 +1436,7 @@ class TestDmRoomCommands:
         """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
         adapter = self._adapter()
         adapter.chatto_config.home_channel.value = "room-7"
-        adapter._watch_room_ids = ["room-7"]
+        adapter._joined_room_ids = ["room-7"]
         state = _make_room_state(_make_room("room-7", "Home", RoomKind.CHANNEL), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
@@ -1472,8 +1472,8 @@ class TestDmRoomCommands:
             assert self._reply(adapter).startswith("Usage:")
 
 
-class TestRoomWatchRefresh:
-    """_refresh_rooms mirrors watch-list membership against the server."""
+class TestJoinedRoomsRefresh:
+    """_refresh_rooms mirrors the joined list against server-side membership."""
 
     def _adapter(self):
         adapter = _make_adapter()
@@ -1481,15 +1481,15 @@ class TestRoomWatchRefresh:
         client.list_rooms = AsyncMock(return_value=[])
         return adapter
 
-    async def test_unwatches_rooms_no_longer_joined(self):
+    async def test_drops_rooms_no_longer_joined(self):
         adapter = self._adapter()
-        adapter._watch_room_ids = ["gone-1", "kept"]
+        adapter._joined_room_ids = ["gone-1", "kept"]
         kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
 
         await adapter._refresh_rooms()
 
-        assert adapter._watch_room_ids == ["kept"]
+        assert adapter._joined_room_ids == ["kept"]
 
     async def test_warns_once_when_home_channel_is_not_joined(self):
         adapter = self._adapter()
@@ -1596,7 +1596,7 @@ class TestRespondRooms:
 
 
 class TestRespondRoomRefresh:
-    """_refresh_rooms keeps read-only rooms watched but skips seeding them."""
+    """_refresh_rooms keeps read-only rooms joined but skips seeding them."""
 
     def _adapter(self, respond_rooms):
         adapter = _make_adapter()
@@ -1607,7 +1607,7 @@ class TestRespondRoomRefresh:
         adapter._seed_room = AsyncMock()
         return adapter
 
-    async def test_read_only_rooms_are_watched_but_not_seeded(self):
+    async def test_read_only_rooms_are_joined_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)
@@ -1615,10 +1615,10 @@ class TestRespondRoomRefresh:
 
         await adapter._refresh_rooms()
 
-        assert sorted(adapter._watch_room_ids) == ["news-1", "team-1"]
+        assert sorted(adapter._joined_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):
+    async def test_join_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)
@@ -1629,10 +1629,10 @@ class TestRespondRoomRefresh:
         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]
+        joined = [m for m in caplog.messages if "currently joined" in m]
+        assert joined, "expected the joined-rooms summary log line"
+        assert "[read-only]" in joined[-1]
+        assert "[universal]" in joined[-1]
 
     async def test_warns_when_respond_list_names_unjoined_rooms(self, caplog):
         adapter = self._adapter(["ghost-id"])