Parcourir la source

Let admins manage room membership over DM (/join, /leave)

/join and /leave in a direct message call RoomService/JoinRoom and
LeaveRoom, so membership lives on the server and survives restarts.
Commands accept room IDs or #names (ambiguous names list candidates),
never reach the agent pipeline, and are not intercepted outside DMs;
DMs and the configured home channel refuse /leave. _refresh_rooms()
now mirrors the watch list against actual membership — rooms left
natively drop out instead of being re-added — and warns once when
CHATTO_HOME_CHANNEL names an unjoined room, since standalone cron
delivery posts there without join logic of its own.
Paul Klumpp il y a 1 semaine
Parent
commit
c55d82634d
5 fichiers modifiés avec 449 ajouts et 5 suppressions
  1. 5 0
      AGENTS.md
  2. 17 2
      README.md
  3. 186 2
      adapter.py
  4. 8 1
      after-install.md
  5. 233 0
      test_adapter.py

+ 5 - 0
AGENTS.md

@@ -73,6 +73,11 @@ changes must keep them, or consciously renegotiate the docs.**
 - **Processing reactions.** 👀 while working, then ✅ or ❌ — driven by
   `on_processing_start`/`on_processing_complete` and the `reactions` 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
+  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
   server limit; `edit_message()` refuses over-long content so callers fall
   back to `send()` rather than receiving silent truncation.

+ 17 - 2
README.md

@@ -39,6 +39,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | image / video / audio / documents | yes (native attachments) |
 | DM initiation | yes |
 | room creation | yes |
+| room join/leave over DM | yes (`/join`, `/leave`) |
 | member directory | yes (cached) |
 | presence broadcasting | yes (online, refreshed) |
 | custom status | yes |
@@ -49,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. 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 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.
 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.
@@ -64,6 +65,8 @@ hermes plugins install https://gogs.netdome.biz/paul/hermes-chatto-plugin.git
 
 Hermes clones the plugin from that Git URL into its platform-plugin directory and auto-discovers it on gateway startup — no manual registration needed.
 
+> **Note:** During install Hermes reads the plugin's declared details from `plugin.yaml` and prompts you for them — server URL, login, and password. What you enter there is written as the plugin's first configuration; secrets should still live in `~/.hermes/.env` (see [Configuration](#configuration)).
+
 ### Option B: Manual Installation
 
 Copy the plugin files into the Hermes Agent plugin directory so they sit at `~/.hermes/plugins/platforms/chatto/`. Hermes auto-discovers platform plugins on gateway startup — no manual registration needed.
@@ -271,13 +274,25 @@ The home channel is where the bot sends proactive messages — cron job output,
 
 If unset, the first watched room is used as the default home channel.
 
+### Managing Rooms over DM
+
+The bot can manage its own room memberships when you DM it:
+
+- `/join <room-id>` — join a room by ID
+- `/join #name` — join a room by name (case-insensitive; ambiguous names return the candidate IDs)
+- `/leave <room-id or #name>` — leave a room
+
+These commands work only in direct messages with the bot and require you to pass the same user check as normal messages (`CHATTO_ALLOWED_USERS` / `CHATTO_ALLOW_ALL_USERS`). They change membership on the Chatto server itself (RoomService/JoinRoom, RoomService/LeaveRoom), so joined rooms survive gateway restarts and `/leave`d rooms stay left.
+
+Two refusals by design: direct messages cannot be left, and leaving the configured home channel is rejected because cron/notification delivery posts there. If `CHATTO_HOME_CHANNEL` names a room the bot has not joined, the adapter logs a warning at each reconnect — invite the account natively in Chatto or DM it `/join`.
+
 ## Troubleshooting
 
 ### Bot is not responding to messages
 
 **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). 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 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.
 
 ### Connection refused / WebSocket fails to connect
 

+ 186 - 2
adapter.py

@@ -75,7 +75,7 @@ try:
         MessagePostedPayload,
         ReactionPayload,
     )
-    from chattolib.types import PresenceStatus, RoomKind, User
+    from chattolib.types import PresenceStatus, RoomKind, RoomWithViewerState, User
 
 except ImportError as e:
     # Fail loudly: continuing here only defers the failure to a confusing
@@ -239,6 +239,8 @@ class ChattoAdapter(BasePlatformAdapter):
         self._seen: list[str] = []  # Plain RealtimeEvent-id list
         self._resume_cursor: Optional[str] = None
         self._watch_room_ids: List[str] = []
+        # One-shot guard for the unjoined-home-channel warning in _refresh_rooms.
+        self._home_warning_logged = False
         self._ws_task: Optional[asyncio.Task] = None
         self._presence_task: Optional[asyncio.Task] = None
         self._ws_ready: Optional[asyncio.Event] = None
@@ -565,6 +567,137 @@ class ChattoAdapter(BasePlatformAdapter):
         logger.info("Chatto: rejecting message from unauthorized user '%s' (%s)", user.login, user.id)
         return False
 
+    # ------------------------------------------------------------------ #
+    # Room management over DM (/join, /leave)
+    # ------------------------------------------------------------------ #
+
+    _DM_COMMANDS = ("/join", "/leave")
+
+    async def _handle_dm_command(self, room_id: str, body: str) -> bool:
+        """Run a ``/join`` or ``/leave`` admin command sent as a direct message.
+
+        Returns True when ``body`` is one of the commands — whether it
+        succeeded or not — so the caller keeps it out of the agent pipeline.
+        Membership lives on the Chatto server: a joined room reappears in
+        every future ``list_rooms()`` and therefore survives restarts.
+        """
+        verb, _, argument = body.strip().partition(" ")
+        if verb.lower() not in self._DM_COMMANDS:
+            return False
+
+        try:
+            client = await self._require_client()
+        except RuntimeError:
+            await self.send(chat_id=room_id, content="Chatto client is not connected.")
+            return True
+
+        argument = argument.strip()
+        if not argument:
+            await self.send(
+                chat_id=room_id,
+                content="Usage: /join <room-id or #name> | /leave <room-id or #name>",
+            )
+            return True
+
+        error, target = await self._resolve_room_target(client, argument)
+        if error or target is None:
+            await self.send(chat_id=room_id, content=error or "Room lookup failed.")
+            return True
+
+        if verb.lower() == "/join":
+            reply = await self._run_join(client, target)
+        else:
+            reply = await self._run_leave(client, target)
+        await self.send(chat_id=room_id, content=reply)
+        return True
+
+    async def _resolve_room_target(
+        self, client: ChattoClient, argument: str,
+    ) -> tuple[str | None, RoomWithViewerState | None]:
+        """Resolve a /join//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
+        as a room ID and verified via GetRoom. An ambiguous name comes back as
+        an error naming the candidates, so the admin can retry with an ID.
+        """
+        if not argument.startswith("#"):
+            state = await client.get_room(argument)
+            if state is None or state.room is None:
+                return f"No room with ID '{argument}'.", None
+            return None, state
+
+        wanted = argument[1:].strip().casefold()
+        matches: list[RoomWithViewerState] = []
+        for state in await client.list_rooms() or []:
+            room_obj = state.room if state else None
+            if room_obj and (room_obj.name or "").strip().casefold() == wanted:
+                matches.append(state)
+                self._room_names[room_obj.id] = room_obj.name
+                self._room_kinds[room_obj.id] = room_obj.kind
+        if not matches:
+            return f"No room named '{argument}'.", None
+        if len(matches) > 1:
+            candidates = "\n".join(f"• {m.room.name} ({m.room.id})" for m in matches)
+            return (
+                f"Several rooms are named '{argument}' — pick one by ID:\n"
+                f"{candidates}"
+            ), None
+        return None, matches[0]
+
+    async def _run_join(self, client: ChattoClient, state: RoomWithViewerState) -> str:
+        """Join a room via RoomService/JoinRoom and watch it immediately.
+
+        An account that already holds membership (invited natively in Chatto)
+        needs no JoinRoom call — it only gets seeded into the watch list.
+        """
+        room_obj = state.room
+        label = f"'{room_obj.name}' ({room_obj.id})"
+        joined_room = room_obj
+        if not state.viewer_state.is_member:
+            try:
+                joined_room = await client.join_room(room_obj.id) or room_obj
+            except ChattoError as exc:
+                logger.warning("Chatto: /join failed for %s (%s)", room_obj.id, exc)
+                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:
+            await self._seed_room(joined_room.id)
+            self._watch_room_ids.append(joined_room.id)
+        if state.viewer_state.is_member:
+            return f"Already a member of {label} — watching it."
+        return f"Joined {label}."
+
+    async def _run_leave(self, client: ChattoClient, state: RoomWithViewerState) -> str:
+        """Leave a room via RoomService/LeaveRoom and stop watching it.
+
+        Two rooms are refused: a DM conversation cannot be left, and leaving
+        the configured home channel would silently break cron/notification
+        delivery, which posts there through the standalone sender.
+        """
+        room_obj = state.room
+        label = f"'{room_obj.name}' ({room_obj.id})"
+        if room_obj.kind == RoomKind.DM:
+            return "Direct messages cannot be left."
+        home_id = (self.chatto_config.home_channel.value or "").strip()
+        if home_id == room_obj.id:
+            return (
+                f"{label} is the configured home channel "
+                "(CHATTO_HOME_CHANNEL); leaving it would break cron and "
+                "notification delivery. Point CHATTO_HOME_CHANNEL elsewhere first."
+            )
+        try:
+            left = await client.leave_room(room_obj.id)
+        except ChattoError as exc:
+            logger.warning("Chatto: /leave failed for %s (%s)", room_obj.id, exc)
+            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)
+        return f"Left {label}."
+
     # ------------------------------------------------------------------ #
     # Inbound attachments
     # ------------------------------------------------------------------ #
@@ -719,6 +852,14 @@ class ChattoAdapter(BasePlatformAdapter):
 
         logger.info("message_body: %s room_kind: %s", message_body, room_kind)
 
+        # Membership commands ride in over DMs only: they change what the bot
+        # listens to and must never reach the agent pipeline or the mention
+        # gates.
+        if room_kind == RoomKind.DM and await self._handle_dm_command(
+            message.room_id, message_body,
+        ):
+            return
+
         # require_mention deliberately gates channels only: in a channel the bot
         # is one of many listeners and must be addressed, whereas a DM is already
         # addressed at it — so DMs are always answered, mention or not.
@@ -946,6 +1087,29 @@ class ChattoAdapter(BasePlatformAdapter):
             await asyncio.sleep(min(remaining, 0.5))
 
 
+    def _warn_if_home_channel_unjoined(self, member_ids: set[str]) -> None:
+        """Warn once when CHATTO_HOME_CHANNEL names a room the bot is not in.
+
+        Standalone cron delivery posts straight into that room with a fresh
+        client and no join logic of its own — without server-side membership
+        every proactive send fails there.
+        """
+        home_id = (self.chatto_config.home_channel.value or "").strip()
+        if (
+            not home_id
+            or home_id in member_ids
+            or home_id in self._watch_room_ids
+            or self._home_warning_logged
+        ):
+            return
+        self._home_warning_logged = True
+        logger.warning(
+            "Chatto: CHATTO_HOME_CHANNEL '%s' is not a joined room - cron and "
+            "notification delivery will fail until the bot joins it (invite "
+            "the account natively in Chatto, or DM it '/join').",
+            home_id,
+        )
+
     async def _refresh_rooms(self) -> None:
         """Refresh room list via ConnectRPC, join and seed any newly discovered rooms."""
         try:
@@ -956,6 +1120,7 @@ class ChattoAdapter(BasePlatformAdapter):
 
         try:
             rooms_list = await client.list_rooms()
+            member_ids: set[str] = set()
             new_room_ids: List[str] = []
 
             for room_with_state in rooms_list:
@@ -969,9 +1134,28 @@ class ChattoAdapter(BasePlatformAdapter):
                 self._room_names[room_obj.id] = room_obj.name
                 self._room_kinds[room_obj.id] = room_obj.kind
 
-                if room_with_state.viewer_state.is_member and room_obj.id not in self._watch_room_ids:
+                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:
                     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.
+            stale_room_ids = [
+                rid for rid in self._watch_room_ids if rid not in member_ids
+            ]
+            for rid in stale_room_ids:
+                self._watch_room_ids.remove(rid)
+            if stale_room_ids:
+                logger.info(
+                    "Chatto WS: no longer a member of %d room(s): %s",
+                    len(stale_room_ids), stale_room_ids,
+                )
+
+            self._warn_if_home_channel_unjoined(member_ids)
+
             if not new_room_ids:
                 return
 

+ 8 - 1
after-install.md

@@ -28,4 +28,11 @@ Environment Variables will take precedence over config.yaml entries.
    ```
 
 4. Test
-   send a message in a Chatto room mentioning your bot's username.
+   send a message in a Chatto room mentioning your bot's username.
+
+5. Add the bot to more rooms (optional)
+
+   Invite the bot account natively in Chatto, or DM it:
+   ```
+   /join ROOM_ID_HERE
+   ```

+ 233 - 0
test_adapter.py

@@ -8,6 +8,7 @@ Covers:
   - Message sending and reactions
   - User lookup (with caching)
   - Presence and custom status
+  - DM room management (/join, /leave)
 
 All network calls are mocked — no real HTTP or WebSocket connections.
 """
@@ -52,6 +53,8 @@ from chattolib.types import (
     PresenceStatus,
     Room,
     RoomKind,
+    RoomViewerState,
+    RoomWithViewerState,
     User,
 )
 from platform_config import ChattoConstants
@@ -1191,6 +1194,236 @@ class TestRoomOperations:
         assert adapter._room_kinds["dm-123"] == RoomKind.DM
 
 
+# -- DM room management (/join, /leave) --
+
+def _make_room_state(room, is_member):
+    """Build a RoomWithViewerState the way list_rooms()/get_room() return it."""
+    return RoomWithViewerState(
+        room=room, viewer_state=RoomViewerState(is_member=is_member),
+    )
+
+
+class TestDmRoomCommands:
+    """/join and /leave arrive over DMs, change server-side membership and
+    must never reach the agent pipeline."""
+
+
+    def _adapter(self):
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = True
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        client = adapter._chatto_client
+        client.list_rooms = AsyncMock(return_value=[])
+        client.get_room_events = AsyncMock(return_value=MagicMock(events=[]))
+        client.join_room = AsyncMock()
+        client.leave_room = AsyncMock(return_value=True)
+        client.get_room = AsyncMock()
+        adapter._room_kinds["dm-1"] = RoomKind.DM
+        adapter.handle_message = AsyncMock()
+        adapter.send = AsyncMock()
+        return adapter
+
+
+    async def _dispatch(self, adapter, body, room_id="dm-1"):
+        payload = _make_posted_payload(room_id=room_id)
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(body=body, room_id=room_id))
+        await adapter._dispatch_message_posted(payload)
+
+
+    def _reply(self, adapter):
+        assert adapter.send.await_count == 1
+        return adapter.send.await_args.kwargs["content"]
+
+
+    async def test_join_by_name_joins_and_watches(self):
+        adapter = self._adapter()
+        state = _make_room_state(
+            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
+        adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
+
+        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 "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."""
+        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 "Already a member" in self._reply(adapter)
+
+
+    async def test_join_unknown_name_reports_without_joining(self):
+        adapter = self._adapter()
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[])
+
+        await self._dispatch(adapter, "/join #nope")
+
+        adapter._chatto_client.join_room.assert_not_awaited()
+        assert "No room named '#nope'" in self._reply(adapter)
+        assert adapter._watch_room_ids == []
+
+
+    async def test_ambiguous_name_offers_the_candidate_ids(self):
+        adapter = self._adapter()
+        matches = [
+            _make_room_state(_make_room(f"r-{i}", "General", RoomKind.CHANNEL), False)
+            for i in range(2)
+        ]
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=matches)
+        adapter._chatto_client.join_room = AsyncMock()
+
+        await self._dispatch(adapter, "/join #general")
+
+        adapter._chatto_client.join_room.assert_not_awaited()
+        reply = self._reply(adapter)
+        assert "r-0" in reply and "r-1" in reply
+
+
+    async def test_join_by_room_id_verifies_via_get_room(self):
+        adapter = self._adapter()
+        state = _make_room_state(
+            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
+        adapter._chatto_client.get_room = AsyncMock(return_value=state)
+        adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
+
+        await self._dispatch(adapter, "/join room-9")
+
+        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"]
+
+
+    async def test_leave_stops_watching_the_room(self):
+        adapter = self._adapter()
+        adapter._watch_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 "Left 'Deploy' (room-7)" in self._reply(adapter)
+
+
+    async def test_leave_refuses_direct_messages(self):
+        adapter = self._adapter()
+        state = _make_room_state(
+            _make_room("dm-2", "", RoomKind.DM), True)
+        adapter._chatto_client.get_room = AsyncMock(return_value=state)
+
+        await self._dispatch(adapter, "/leave dm-2")
+
+        adapter._chatto_client.leave_room.assert_not_awaited()
+        assert "Direct messages cannot be left" in self._reply(adapter)
+
+
+    async def test_leave_refuses_home_channel(self):
+        """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"]
+        state = _make_room_state(
+            _make_room("room-7", "Home", 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_not_awaited()
+        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.chatto_config.require_mention.value = True
+        adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
+
+        await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
+
+        adapter._chatto_client.leave_room.assert_not_awaited()
+        adapter.handle_message.assert_not_called()
+        adapter.send.assert_not_called()
+
+
+    async def test_non_command_dm_falls_through_to_pipeline(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "/status all good")
+        adapter.handle_message.assert_awaited_once()
+        adapter.send.assert_not_called()
+
+
+    async def test_missing_argument_gets_usage_reply(self):
+        adapter = self._adapter()
+        for body in ("/join", "/leave"):
+            adapter.send.reset_mock()
+            await self._dispatch(adapter, body)
+            assert self._reply(adapter).startswith("Usage:")
+
+
+
+class TestRoomWatchRefresh:
+    """_refresh_rooms mirrors watch-list membership against the server."""
+
+
+    def _adapter(self):
+        adapter = _make_adapter()
+        client = adapter._chatto_client
+        client.list_rooms = AsyncMock(return_value=[])
+        return adapter
+
+
+    async def test_unwatches_rooms_no_longer_joined(self):
+        adapter = self._adapter()
+        adapter._watch_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"]
+
+
+    async def test_warns_once_when_home_channel_is_not_joined(self):
+        adapter = self._adapter()
+        adapter.chatto_config.home_channel.value = "home-x"
+        other = _make_room_state(_make_room("other", "Other", RoomKind.CHANNEL), True)
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[other])
+
+
+        await adapter._refresh_rooms()
+        assert adapter._home_warning_logged
+
+        await adapter._refresh_rooms()
+        assert adapter._home_warning_logged
+
+
+    async def test_no_warning_while_home_channel_is_member(self):
+        adapter = self._adapter()
+        adapter.chatto_config.home_channel.value = "home-x"
+        home = _make_room_state(_make_room("home-x", "Home", RoomKind.CHANNEL), True)
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[home])
+
+        await adapter._refresh_rooms()
+        assert not adapter._home_warning_logged
+
+
 # -- Constants --
 
 class TestConstants: