ソースを参照

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 週間 前
コミット
b21be0da60
7 ファイル変更64 行追加63 行削除
  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.
   aimed at someone else gets a 🫥 acknowledgement instead of an answer.
 - **Respond rooms are a positive list, inbound only.** With
 - **Respond rooms are a positive list, inbound only.** With
   `CHATTO_RESPOND_ROOMS` set, the bot reads and answers only in listed rooms;
   `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
   seeded into context, never answered, no processing reactions. The gate sits
   at the top of `_dispatch_message_posted`, before any API call; unknown room
   at the top of `_dispatch_message_posted`, before any API call; unknown room
   kinds fail closed. DMs are exempt so `/join` stays reachable, and
   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.
   setting, never applied to the bot's own events.
 - **Room membership is server-side, commands ride over DMs.** `/join` and
 - **Room membership is server-side, commands ride over DMs.** `/join` and
   `/leave` in a DM call RoomService/JoinRoom/LeaveRoom, so membership survives
   `/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
   configured home channel refuse `/leave`. Commands never reach the agent
   pipeline and are not intercepted outside DMs.
   pipeline and are not intercepted outside DMs.
 - **Length handling.** `send()` splits at 9900 chars against the 10000-char
 - **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.
 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`).
 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`.
 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.
 > **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_LOGIN` | Yes* | — | Chatto username (login) |
 | `CHATTO_PASSWORD` | Yes* | — | Chatto password |
 | `CHATTO_PASSWORD` | Yes* | — | Chatto password |
 | `CHATTO_TOKEN` | No | — | Existing bearer token — alternative to login/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_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_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_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
 ### 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
 ### DM Initiation
 
 
@@ -260,7 +260,7 @@ Presence is a TTL the server lets lapse, not a flag that stays set: `UpdatePrese
 
 
 ### Respond Rooms
 ### 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.
 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)
 - `CHATTO_HOME_CHANNEL` env var (room ID)
 - `config.yaml` → `gateway.platforms.chatto.extra.home_channel`
 - `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
 ### 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`.
 **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
 ### 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
         # Event IDs already processed — chattolib may redeliver events across
         # reconnects, so every inbound event is checked against this list.
         # reconnects, so every inbound event is checked against this list.
         self._seen: list[str] = []
         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
         # 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()
         self._universal_room_ids: set[str] = set()
         # One-shot guard for the unjoined-home-channel warning in _refresh_rooms.
         # One-shot guard for the unjoined-home-channel warning in _refresh_rooms.
         self._home_warning_logged = False
         self._home_warning_logged = False
@@ -702,10 +703,10 @@ class ChattoAdapter(BasePlatformAdapter):
         return None, matches[0][0]
         return None, matches[0][0]
 
 
     async def _run_join(self, client: ChattoClient, state: RoomWithViewerState) -> str:
     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)
         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
         room_obj = state.room
         if room_obj is None:
         if room_obj is None:
@@ -722,15 +723,15 @@ class ChattoAdapter(BasePlatformAdapter):
                 return f"Could not join {label}: {exc}"
                 return f"Could not join {label}: {exc}"
         self._room_names[joined_room.id] = joined_room.name
         self._room_names[joined_room.id] = joined_room.name
         self._room_kinds[joined_room.id] = joined_room.kind
         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)
             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:
         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}."
         return f"Joined {label}."
 
 
     async def _run_leave(self, client: ChattoClient, state: RoomWithViewerState) -> str:
     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
         Two rooms are refused: a DM conversation cannot be left, and leaving
         the configured home channel would silently break cron/notification
         the configured home channel would silently break cron/notification
@@ -757,8 +758,8 @@ class ChattoAdapter(BasePlatformAdapter):
             return f"Could not leave {label}: {exc}"
             return f"Could not leave {label}: {exc}"
         if not left:
         if not left:
             return f"Chatto refused to leave {label}."
             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}."
         return f"Left {label}."
 
 
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
@@ -1162,7 +1163,7 @@ class ChattoAdapter(BasePlatformAdapter):
                 await self._refresh_rooms()
                 await self._refresh_rooms()
                 logger.info(
                 logger.info(
                     "Chatto: starting chattolib event stream with %d rooms",
                     "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):
                 async for event in stream_events(client):
@@ -1242,7 +1243,7 @@ class ChattoAdapter(BasePlatformAdapter):
         if (
         if (
             not home_id
             not home_id
             or home_id in member_ids
             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
             or self._home_warning_logged
         ):
         ):
             return
             return
@@ -1284,17 +1285,17 @@ class ChattoAdapter(BasePlatformAdapter):
                 if not room_with_state.viewer_state.is_member:
                 if not room_with_state.viewer_state.is_member:
                     continue
                     continue
                 member_ids.add(room_obj.id)
                 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)
                     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 = [
             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:
             for rid in stale_room_ids:
-                self._watch_room_ids.remove(rid)
+                self._joined_room_ids.remove(rid)
             if stale_room_ids:
             if stale_room_ids:
                 logger.info(
                 logger.info(
                     "Chatto WS: no longer a member of %d room(s): %s",
                     "Chatto WS: no longer a member of %d room(s): %s",
@@ -1331,25 +1332,25 @@ class ChattoAdapter(BasePlatformAdapter):
                 else:
                 else:
                     logger.info(
                     logger.info(
                         "Chatto WS: %s (%s) is outside CHATTO_RESPOND_ROOMS -"
                         "Chatto WS: %s (%s) is outside CHATTO_RESPOND_ROOMS -"
-                        " watching read-only",
+                        " joined read-only",
                         self._room_names.get(rid, rid),
                         self._room_names.get(rid, 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]
                 name = self._room_names[rid]
                 if not self._is_respond_room(rid):
                 if not self._is_respond_room(rid):
                     name += " [read-only]"
                     name += " [read-only]"
                 if rid in self._universal_room_ids:
                 if rid in self._universal_room_ids:
                     name += " [universal]"
                     name += " [universal]"
-                watch_room_names.append(name + " (" + rid + ")")
+                joined_room_names.append(name + " (" + rid + ")")
 
 
             logger.info(
             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:
         except Exception:
@@ -1358,8 +1359,8 @@ class ChattoAdapter(BasePlatformAdapter):
         # ------------------------------------------------------------------ #
         # ------------------------------------------------------------------ #
         # Read state & notification dismissal (best-effort, Chatto-unique)
         # 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:
             try:
                 await client.mark_room_as_read(room_id=_rid)
                 await client.mark_room_as_read(room_id=_rid)
                 await client.dismiss_all_notifications()
                 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
    conversation context to reply into — the default outbound target, nothing
    more. With `CHATTO_RESPOND_ROOMS` set, list it too if the bot should also
    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
    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
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE

+ 2 - 2
platform_config.py

@@ -309,8 +309,8 @@ class ChattoConfiguration:
         "list", config_key="free_response_channels"
         "list", config_key="free_response_channels"
     )
     )
     # respond_rooms: positive list of room IDs the bot reads and answers in.
     # 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).
     # joined room is a respond room (the behaviour before this knob existed).
     respond_rooms = ConfigField("list")
     respond_rooms = ConfigField("list")
     # Auto-thread: by default, Chatto creates a thread for replies to room
     # 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)"
     prompt: "Existing bearer token (or empty)"
     password: true
     password: true
   - name: CHATTO_HOME_CHANNEL
   - 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)"
     prompt: "Home room ID (or empty)"
     password: false
     password: false
   # Asked before CHATTO_ALLOWED_USERS: a "yes" here makes the user list moot,
   # 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
         assert adapter.send.await_count == 1
         return adapter.send.await_args.kwargs["content"]
         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()
         adapter = self._adapter()
         state = _make_room_state(
         state = _make_room_state(
             _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
             _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
@@ -1355,20 +1355,20 @@ class TestDmRoomCommands:
         await self._dispatch(adapter, "/join #deploy")
         await self._dispatch(adapter, "/join #deploy")
 
 
         adapter._chatto_client.join_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"]
         assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
         assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
 
 
     async def test_join_skips_rpc_when_already_a_member(self):
     async def test_join_skips_rpc_when_already_a_member(self):
         adapter = self._adapter()
         adapter = self._adapter()
         """Natively invited accounts hold membership already — they only need
         """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)
         state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
 
 
         await self._dispatch(adapter, "/join #deploy")
         await self._dispatch(adapter, "/join #deploy")
 
 
         adapter._chatto_client.join_room.assert_not_awaited()
         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)
         assert "Already a member" in self._reply(adapter)
 
 
     async def test_join_unknown_name_reports_without_joining(self):
     async def test_join_unknown_name_reports_without_joining(self):
@@ -1379,7 +1379,7 @@ class TestDmRoomCommands:
 
 
         adapter._chatto_client.join_room.assert_not_awaited()
         adapter._chatto_client.join_room.assert_not_awaited()
         assert "No room named '#nope'" in self._reply(adapter)
         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):
     async def test_ambiguous_name_offers_the_candidate_ids(self):
         adapter = self._adapter()
         adapter = self._adapter()
@@ -1408,18 +1408,18 @@ class TestDmRoomCommands:
 
 
         adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
         adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
         adapter._chatto_client.join_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 = 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)
         state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
 
         await self._dispatch(adapter, "/leave room-7")
         await self._dispatch(adapter, "/leave room-7")
 
 
         adapter._chatto_client.leave_room.assert_awaited_once_with("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)
         assert "Left 'Deploy' (room-7)" in self._reply(adapter)
 
 
     async def test_leave_refuses_direct_messages(self):
     async def test_leave_refuses_direct_messages(self):
@@ -1436,7 +1436,7 @@ class TestDmRoomCommands:
         """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
         """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
         adapter = self._adapter()
         adapter = self._adapter()
         adapter.chatto_config.home_channel.value = "room-7"
         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)
         state = _make_room_state(_make_room("room-7", "Home", RoomKind.CHANNEL), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
 
@@ -1472,8 +1472,8 @@ class TestDmRoomCommands:
             assert self._reply(adapter).startswith("Usage:")
             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):
     def _adapter(self):
         adapter = _make_adapter()
         adapter = _make_adapter()
@@ -1481,15 +1481,15 @@ class TestRoomWatchRefresh:
         client.list_rooms = AsyncMock(return_value=[])
         client.list_rooms = AsyncMock(return_value=[])
         return adapter
         return adapter
 
 
-    async def test_unwatches_rooms_no_longer_joined(self):
+    async def test_drops_rooms_no_longer_joined(self):
         adapter = self._adapter()
         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)
         kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
 
 
         await adapter._refresh_rooms()
         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):
     async def test_warns_once_when_home_channel_is_not_joined(self):
         adapter = self._adapter()
         adapter = self._adapter()
@@ -1596,7 +1596,7 @@ class TestRespondRooms:
 
 
 
 
 class TestRespondRoomRefresh:
 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):
     def _adapter(self, respond_rooms):
         adapter = _make_adapter()
         adapter = _make_adapter()
@@ -1607,7 +1607,7 @@ class TestRespondRoomRefresh:
         adapter._seed_room = AsyncMock()
         adapter._seed_room = AsyncMock()
         return adapter
         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"])
         adapter = self._adapter(["team-1"])
         news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
         news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
         team = _make_room_state(_make_room("team-1", "Team", 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()
         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")
         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"])
         adapter = self._adapter(["team-1"])
         news = Room(id="news-1", name="News", kind=RoomKind.CHANNEL, universal=True)
         news = Room(id="news-1", name="News", kind=RoomKind.CHANNEL, universal=True)
         team = Room(id="team-1", name="Team", kind=RoomKind.CHANNEL, universal=False)
         team = Room(id="team-1", name="Team", kind=RoomKind.CHANNEL, universal=False)
@@ -1629,10 +1629,10 @@ class TestRespondRoomRefresh:
         with caplog.at_level("INFO"):
         with caplog.at_level("INFO"):
             await adapter._refresh_rooms()
             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):
     async def test_warns_when_respond_list_names_unjoined_rooms(self, caplog):
         adapter = self._adapter(["ghost-id"])
         adapter = self._adapter(["ghost-id"])