فهرست منبع

Replace require_mention with opt-in mention room lists

- CHATTO_REQUIRE_MENTION_ROOMS / CHATTO_OPTIONAL_MENTION_ROOMS replace
  the require_mention flag and the never-wired free_response_channels;
  rooms on neither list stay silent, DMs always answer
- Drop CHATTO_RESPOND_ROOMS: Chatto knows only DMs and channels
- /join reports a room's mention-list status and hands out ready-to-paste
  env lines; silent rooms are joined without history seeding
- hermes_validate_config rejects list overlap and warns on extra keys
  that near-miss a config field
Paul Klumpp 1 هفته پیش
والد
کامیت
c67865873b
8فایلهای تغییر یافته به همراه504 افزوده شده و 245 حذف شده
  1. 22 15
      AGENTS.md
  2. 24 24
      README.md
  3. 164 50
      adapter.py
  4. 10 6
      after-install.md
  5. 11 17
      platform_config.py
  6. 5 9
      plugin.yaml
  7. 226 123
      test_adapter.py
  8. 42 1
      test_platform_config.py

+ 22 - 15
AGENTS.md

@@ -68,20 +68,22 @@ not "edit these files to begin".
 **These behaviours are promises to users, documented in README.md — adapter
 **These behaviours are promises to users, documented in README.md — adapter
 changes must keep them, or consciously renegotiate the docs.**
 changes must keep them, or consciously renegotiate the docs.**
 
 
-- **Mention gating covers channels only.** With `require_mention` enabled,
-  channel-kind rooms answer only messages addressing the bot — by @-mention or
-  by the broadcast handles `@all`/`@here` (the only virtual handles Chatto
-  defines, FDR-006); group-kind rooms and DMs answer regardless. Both gates
+- **Rooms are opt-in via two mutually exclusive lists.** Chatto has no group
+  rooms like Signal — every non-DM surface is a channel, and a room
+  participates only if it is listed: in `CHATTO_REQUIRE_MENTION_ROOMS` it
+  answers only messages addressing the bot — by @-mention or by the
+  broadcast handles `@all`/`@here` (the only virtual handles Chatto defines,
+  FDR-006); in `CHATTO_OPTIONAL_MENTION_ROOMS` it answers every message,
+  and one aimed at someone else gets a 🫥 acknowledgement instead of an
+  answer. A room on neither list stays silent — read-only: still marked as
+  read, never seeded into context, never answered, no processing reactions;
+  the gate sits at the top of the dispatch paths, before any API call.
+  Unknown room kinds count as channels (a server that never sets `kind`
+  shows up as UNSPECIFIED), so they go through the same lists. Both gates
   share `_mentions_me`, so "addressed" means the same thing everywhere:
   share `_mentions_me`, so "addressed" means the same thing everywhere:
   case-insensitive handle matching outside code regions, per the Chatto web
   case-insensitive handle matching outside code regions, per the Chatto web
-  frontend's extraction. 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 — 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
+  frontend's extraction. DMs answer regardless so `/join` stays reachable;
+  `hermes_validate_config` rejects listing a room on both lists.
   `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for
   `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for
   context-less cron/notification delivery, not an answer destination.
   context-less cron/notification delivery, not an answer destination.
 - **Threads stay threads.** An inbound thread reply keeps its thread context;
 - **Threads stay threads.** An inbound thread reply keeps its thread context;
@@ -103,9 +105,14 @@ 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 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.
+  restarts; the joined list mirrors it on every `_refresh_rooms()`. Silent
+  rooms are joined read-only — no history seeding (same rule as
+  `_refresh_rooms`). A channel-kind `/join` reply reports the room's
+  mention-list status; an unlisted one names both `CHATTO_*_MENTION_ROOMS`
+  lines verbatim, because that reply is where users copy the room ID from —
+  the bot never writes `~/.hermes/.env` itself. 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
 - **Length handling.** `send()` splits at 9900 chars against the 10000-char
   server limit; `edit_message()` refuses over-long content so callers fall
   server limit; `edit_message()` refuses over-long content so callers fall
   back to `send()` rather than receiving silent truncation.
   back to `send()` rather than receiving silent truncation.

+ 24 - 24
README.md

@@ -13,14 +13,14 @@ Before setup, here's the part most people want to know: how Hermes behaves once
 | Context | Behavior |
 | Context | Behavior |
 |---------|----------|
 |---------|----------|
 | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
 | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
-| **Rooms** | Hermes responds to every room message by default. With `CHATTO_REQUIRE_MENTION=true`, channel-kind rooms answer only when addressed (`@mention`, `@all`, `@here`); group-kind rooms always respond. |
+| **Rooms** | Opt-in per room: a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` gets answers only when Hermes is addressed (`@mention`, `@all`, `@here`); one listed in `CHATTO_OPTIONAL_MENTION_ROOMS` gets an answer for every message. A room on neither list stays silent — read-only. |
 | **Threads** | If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The bot auto-follows threads it participates in. |
 | **Threads** | If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The bot auto-follows threads it participates in. |
 | **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success, ❌ on failure, or 🚫 when processing was cancelled. |
 | **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success, ❌ on failure, or 🚫 when processing was cancelled. |
 | **Editing your messages** | An edit within 5 minutes of posting counts as a correction: while Hermes is still working on the original, he restarts with the edited text (🚫 → 👀); if he had ignored or not yet answered the message, the gates are re-checked against the new text — adding a forgotten `@mention` this way works. Edits to messages Hermes has already answered change nothing. Disable with `CHATTO_EDIT_DISPATCH=false`. |
 | **Editing your messages** | An edit within 5 minutes of posting counts as a correction: while Hermes is still working on the original, he restarts with the edited text (🚫 → 👀); if he had ignored or not yet answered the message, the gates are re-checked against the new text — adding a forgotten `@mention` this way works. Edits to messages Hermes has already answered change nothing. Disable with `CHATTO_EDIT_DISPATCH=false`. |
 | **Typing indicators** | Hermes broadcasts persistent typing indicators while it's working, so users know the bot is active. |
 | **Typing indicators** | Hermes broadcasts persistent typing indicators while it's working, so users know the bot is active. |
 | **Message splitting** | Long responses (>10000 chars) are automatically split into multiple messages. |
 | **Message splitting** | Long responses (>10000 chars) are automatically split into multiple messages. |
 
 
-> **Tip:** By default Hermes answers every message in a room. Set `CHATTO_REQUIRE_MENTION=true` to have him answer only `@mentions` in channel-type rooms.
+> **Tip:** Rooms are silent until you list them. Put a room in `CHATTO_REQUIRE_MENTION_ROOMS` to have Hermes answer only `@mentions` there, or in `CHATTO_OPTIONAL_MENTION_ROOMS` to have him answer every message.
 
 
 ## Capability Matrix
 ## Capability Matrix
 
 
@@ -96,24 +96,20 @@ CHATTO_LOGIN=hermes
 CHATTO_PASSWORD=your-password
 CHATTO_PASSWORD=your-password
 
 
 # Optional: home channel for cron/notification delivery when there is no
 # Optional: home channel for cron/notification delivery when there is no
-# conversation context to reply into — not exempt from CHATTO_RESPOND_ROOMS
+# conversation context to reply into — list it in one of the mention room
+# lists below too if the bot should also converse there
 # CHATTO_HOME_CHANNEL=REljMv5Pgolo6Y9
 # 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)
 # Optional: restrict who can talk to the bot (comma-separated logins)
 # CHATTO_ALLOWED_USERS=alice,bob
 # CHATTO_ALLOWED_USERS=alice,bob
 
 
 # Optional: allow any user to talk to the bot (default: false)
 # Optional: allow any user to talk to the bot (default: false)
 # CHATTO_ALLOW_ALL_USERS=true
 # CHATTO_ALLOW_ALL_USERS=true
 
 
-# Optional: answer only @mentions in channels — group rooms and DMs always respond
-# CHATTO_REQUIRE_MENTION=true
-
-# Optional: rooms that answer without a mention even when require_mention is on
-# CHATTO_FREE_RESPONSE_CHANNELS=REljMv5Pgolo6Y9
+# Rooms the bot participates in — without at least one entry below,
+# every room except DMs stays silent:
+# CHATTO_REQUIRE_MENTION_ROOMS=REljMv5Pgolo6Y9   ← answer only @mentions
+# CHATTO_OPTIONAL_MENTION_ROOMS=REljMv5Pgolo6Y9  ← answer every message
 
 
 # Optional: auto-create threads for replies in rooms (default: true)
 # Optional: auto-create threads for replies in rooms (default: true)
 # CHATTO_AUTO_THREAD=true
 # CHATTO_AUTO_THREAD=true
@@ -146,8 +142,8 @@ gateway:
       extra:
       extra:
         base_url: https://chat.example.com
         base_url: https://chat.example.com
         home_channel: REljMv5Pgolo6Y9
         home_channel: REljMv5Pgolo6Y9
-        respond_rooms: []          # empty = read and answer in every joined room
-        require_mention: false     # only respond to @mentions in channels
+        require_mention_rooms: []     # rooms that answer only @mentions
+        optional_mention_rooms: []    # rooms that answer everything
         allowed_users: []          # empty = deny all (or set allow_all_users)
         allowed_users: []          # empty = deny all (or set allow_all_users)
         allow_all_users: true
         allow_all_users: true
         edit_dispatch: true        # process message edits as corrections
         edit_dispatch: true        # process message edits as corrections
@@ -164,12 +160,11 @@ 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 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_HOME_CHANNEL` | No | First joined room | Default delivery target for cron/notification output when there is no inbound conversation context to reply into. Not exempt from the mention lists. |
 | `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins allowed to talk to the agent |
 | `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins 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`) |
-| `CHATTO_REQUIRE_MENTION` | No | `false` | Only respond to `@mentions` in channels. DMs always get a response. |
-| `CHATTO_FREE_RESPONSE_CHANNELS` | No | — | Room IDs that answer without a mention even when `require_mention` is enabled |
+| `CHATTO_REQUIRE_MENTION_ROOMS` | No | _(silent everywhere)_ | Comma-separated room IDs where Hermes answers only when addressed (`@name`, `@all`, `@here`). A room on neither mention list stays silent. |
+| `CHATTO_OPTIONAL_MENTION_ROOMS` | No | — | Comma-separated room IDs where Hermes answers every message, addressed or not. Must not overlap `CHATTO_REQUIRE_MENTION_ROOMS` (rejected at startup). |
 | `CHATTO_AUTO_THREAD` | No | `true` | Auto-create threads for replies in rooms (`true`/`false`) |
 | `CHATTO_AUTO_THREAD` | No | `true` | Auto-create threads for replies in rooms (`true`/`false`) |
 | `CHATTO_REACTIONS` | No | `true` | 👀/✅/❌/🚫 processing reactions (`true`/`false`) |
 | `CHATTO_REACTIONS` | No | `true` | 👀/✅/❌/🚫 processing reactions (`true`/`false`) |
 | `CHATTO_EDIT_DISPATCH` | No | `true` | Treat edits of chat messages as corrections: a message being processed restarts with the edited text, an ignored message is re-checked against its new text, already-answered messages stay answered (`true`/`false`) |
 | `CHATTO_EDIT_DISPATCH` | No | `true` | Treat edits of chat messages as corrections: a message being processed restarts with the edited text, an ignored message is re-checked against its new text, already-answered messages stay answered (`true`/`false`) |
@@ -276,15 +271,18 @@ Presence is a TTL the server lets lapse, not a flag that stays set: `UpdatePrese
 
 
 ## Usage Notes
 ## Usage Notes
 
 
-### Respond Rooms
+### Room Participation
 
 
-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]`).
+Chatto distinguishes only direct messages and rooms. Every multi-person room is opt-in, configured with two mutually exclusive lists:
 
 
-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.
+- `CHATTO_REQUIRE_MENTION_ROOMS` — Hermes answers a message in these rooms only when it addresses him: by `@login`, by `@display_name`, or with the room-wide handles `@all`/`@here`. Anything else is dropped.
+- `CHATTO_OPTIONAL_MENTION_ROOMS` — every message gets an answer, addressed or not. A message aimed at someone *else* is acknowledged with a 🫥 reaction instead of an answer.
 
 
-### Mention Detection
+A room on neither list stays silent: it remains joined and is marked as read, but its history is never seeded into the agent's context and its messages never reach the agent pipeline — no answers, no processing reactions. This is also how to quiet server-forced rooms that every account is joined to automatically. Listing a room on both lists is rejected as conflicting configuration at startup.
 
 
-With `CHATTO_REQUIRE_MENTION=true`, Hermes answers channel-kind rooms only when the message addresses him: by `@login`, by `@display_name`, or with the room-wide handles `@all`/`@here`. 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.
+The lists gate inbound replies only. DMs always get a response so `/join` stays reachable, and context-less cron/notification delivery via `CHATTO_HOME_CHANNEL` is unaffected.
+
+### Mention Recognition
 
 
 Mention recognition matches the Chatto web frontend (FDR-006): handles are matched case-insensitively (`@Hermes_Bot` and `@hermes_bot` are the same), only `@all` and `@here` are broadcast handles, a sentence-ending punctuation is not part of the handle (`frag @bob.` addresses Bob), mentions inside code blocks and inline code spans do not count, and an `@name` no user holds is treated as plain text — Hermes answers rather than staying silent on such a false positive. Other users are recognized by login only; mentioning them by display name is not detected as "aimed at someone else".
 Mention recognition matches the Chatto web frontend (FDR-006): handles are matched case-insensitively (`@Hermes_Bot` and `@hermes_bot` are the same), only `@all` and `@here` are broadcast handles, a sentence-ending punctuation is not part of the handle (`frag @bob.` addresses Bob), mentions inside code blocks and inline code spans do not count, and an `@name` no user holds is treated as plain text — Hermes answers rather than staying silent on such a false positive. Other users are recognized by login only; mentioning them by display name is not detected as "aimed at someone else".
 
 
@@ -321,7 +319,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 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.
+If unset, the first joined room is used as the default home channel. The home channel follows the same participation rules as any room: a silent home channel still receives cron/notification posts, but the bot converses there only if it is listed in one of the mention lists too.
 
 
 ### Managing Rooms over DM
 ### Managing Rooms over DM
 
 
@@ -333,6 +331,8 @@ The bot can manage its own room memberships when you DM it:
 
 
 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.
 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.
 
 
+A channel `/join` reply reports the room's mention-list status: an unlisted room answers with ready-to-paste `CHATTO_REQUIRE_MENTION_ROOMS` / `CHATTO_OPTIONAL_MENTION_ROOMS` lines for `~/.hermes/.env` — that reply is the place to copy the room ID from, since room IDs are hard to find elsewhere. The bot never edits `.env` itself; a gateway restart applies the new entry. Silent rooms are also joined read-only: their history is not seeded into context.
+
 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`.
 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
 ## Troubleshooting

+ 164 - 50
adapter.py

@@ -31,6 +31,7 @@ import os
 import re
 import re
 import tempfile
 import tempfile
 from datetime import UTC, datetime
 from datetime import UTC, datetime
+from difflib import SequenceMatcher
 from enum import StrEnum
 from enum import StrEnum
 from typing import Any, cast
 from typing import Any, cast
 from urllib.parse import unquote, urlsplit
 from urllib.parse import unquote, urlsplit
@@ -201,6 +202,25 @@ def chat_type_for_room_kind(kind: RoomKind | None) -> HermesChatType:
     return _ROOM_KIND_TO_CHAT_TYPE.get(kind, HermesChatType.GROUP)
     return _ROOM_KIND_TO_CHAT_TYPE.get(kind, HermesChatType.GROUP)
 
 
 
 
+class ChannelPolicy(StrEnum):
+    """How a channel-kind room treats an inbound message.
+
+    Derived per room from ``CHATTO_REQUIRE_MENTION_ROOMS`` /
+    ``CHATTO_OPTIONAL_MENTION_ROOMS`` — see ``_room_policy``. A StrEnum
+    so the values log readably without a formatting dance.
+    """
+
+    # Listed for unaddressed answers: every message is dispatched, and one
+    # aimed at a named colleague gets a 🫥 acknowledgement instead of a reply.
+    OPEN = "open"
+    # Listed for addressed-only participation: dispatches only messages that
+    # mention the bot (@name or a broadcast handle); others are dropped.
+    REQUIRE_MENTION = "require_mention"
+    # Listed in neither config: the room is silent — not dispatched at all,
+    # read-only like any unlisted membership.
+    SILENT = "silent"
+
+
 # Blocking filesystem/network helpers. The adapter runs on the shared gateway
 # Blocking filesystem/network helpers. The adapter runs on the shared gateway
 # event loop, so file reads and HTTP downloads are pushed to a worker thread
 # event loop, so file reads and HTTP downloads are pushed to a worker thread
 # via asyncio.to_thread — a slow disk or dead image URL must not stall every
 # via asyncio.to_thread — a slow disk or dead image URL must not stall every
@@ -766,6 +786,9 @@ class ChattoAdapter(BasePlatformAdapter):
 
 
         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 and added to the list.
         needs no JoinRoom call — it only gets seeded and added to the list.
+        Channel-kind rooms additionally report their mention-list status,
+        because a channel on neither list stays silent and this reply is
+        where users copy the room ID from (see ``_channel_join_hint``).
         """
         """
         room_obj = state.room
         room_obj = state.room
         if room_obj is None:
         if room_obj is None:
@@ -783,8 +806,23 @@ class ChattoAdapter(BasePlatformAdapter):
         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._joined_room_ids:
         if joined_room.id not in self._joined_room_ids:
-            await self._seed_room(joined_room.id)
+            # Same rule as _refresh_rooms: silent rooms are joined read-only
+            # — seeding history nothing will ever answer would be waste.
+            if self._is_respond_room(joined_room.id):
+                await self._seed_room(joined_room.id)
+            else:
+                logger.info(
+                    "Chatto: %s is on neither mention list - joined read-only",
+                    label,
+                )
             self._joined_room_ids.append(joined_room.id)
             self._joined_room_ids.append(joined_room.id)
+        if room_obj.kind != RoomKind.DM:
+            head = (
+                f"Already a member of {label}."
+                if state.viewer_state.is_member
+                else f"Joined {label}."
+            )
+            return f"{head}\n{self._channel_join_hint(room_obj.id)}"
         if state.viewer_state.is_member:
         if state.viewer_state.is_member:
             return f"Already a member of {label} — listening there."
             return f"Already a member of {label} — listening there."
         return f"Joined {label}."
         return f"Joined {label}."
@@ -821,6 +859,32 @@ class ChattoAdapter(BasePlatformAdapter):
             self._joined_room_ids.remove(room_obj.id)
             self._joined_room_ids.remove(room_obj.id)
         return f"Left {label}."
         return f"Left {label}."
 
 
+    def _channel_join_hint(self, room_id: str) -> str:
+        """The mention-list status appended to a channel-kind /join reply.
+
+        Users do not know their room IDs by heart — this reply is where they
+        copy them from, so a silent channel names both env vars verbatim,
+        ready to paste into ~/.hermes/.env. Configuration resolves once at
+        gateway startup, hence the restart note.
+        """
+        policy = self._room_policy(room_id)
+        if policy == ChannelPolicy.REQUIRE_MENTION:
+            return (
+                "This channel answers only @mentions "
+                "(listed in CHATTO_REQUIRE_MENTION_ROOMS)."
+            )
+        if policy == ChannelPolicy.OPEN:
+            return (
+                "This channel answers every message "
+                "(listed in CHATTO_OPTIONAL_MENTION_ROOMS)."
+            )
+        return (
+            "This channel stays silent until you list its ID in"
+            " ~/.hermes/.env (then restart the gateway):\n"
+            f"  CHATTO_REQUIRE_MENTION_ROOMS={room_id}   <- answer only @mentions\n"
+            f"  CHATTO_OPTIONAL_MENTION_ROOMS={room_id}  <- answer every message"
+        )
+
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
     # Inbound attachments
     # Inbound attachments
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
@@ -934,20 +998,33 @@ class ChattoAdapter(BasePlatformAdapter):
             return MessageType.AUDIO
             return MessageType.AUDIO
         return MessageType.TEXT
         return MessageType.TEXT
 
 
+    def _room_policy(self, room_id: str) -> ChannelPolicy:
+        """Which of the mention lists a channel-kind room is on.
+
+        The two lists are mutually exclusive (enforced by
+        ``hermes_validate_config``), so membership decides: optional beats
+        require in the face of contradictory runtime config, and a room on
+        neither list stays silent.
+        """
+        if room_id in self.chatto_config.optional_mention_rooms.value:
+            return ChannelPolicy.OPEN
+        if room_id in self.chatto_config.require_mention_rooms.value:
+            return ChannelPolicy.REQUIRE_MENTION
+        return ChannelPolicy.SILENT
+
     def _is_respond_room(self, room_id: str) -> bool:
     def _is_respond_room(self, room_id: str) -> bool:
         """Whether inbound messages from this room reach the agent pipeline.
         """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.
+        Chatto knows only DMs and channels, so the split is ``kind == DM``:
+        a DM is always a respond room (``/join`` must stay reachable), every
+        other room — channel-kind or an unknown kind, which is how servers
+        that never set ``kind`` show up — opts in through the mention lists.
+        A room whose policy is SILENT stays read-only (marked as read, never
+        seeded or answered).
         """
         """
-        respond_rooms = self.chatto_config.respond_rooms.value
-        if not respond_rooms:
-            return True
         if self._room_kinds.get(room_id) == RoomKind.DM:
         if self._room_kinds.get(room_id) == RoomKind.DM:
             return True
             return True
-        return room_id in respond_rooms
+        return self._room_policy(room_id) != ChannelPolicy.SILENT
 
 
     async def _dispatch_message_posted(self, payload: MessagePostedPayload) -> None:
     async def _dispatch_message_posted(self, payload: MessagePostedPayload) -> None:
         # Respond-room gate first: read-only memberships must not cost a
         # Respond-room gate first: read-only memberships must not cost a
@@ -1203,37 +1280,41 @@ class ChattoAdapter(BasePlatformAdapter):
                 logger.debug("Chatto: edited DM command %r not re-run", message_body)
                 logger.debug("Chatto: edited DM command %r not re-run", message_body)
                 return None
                 return None
 
 
-        # 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. The
+        # Mention gating deliberately covers everything but DMs: in a channel
+        # the bot is one of many listeners and must be addressed, whereas a DM
+        # is already addressed at it. Chatto has no group rooms — any
+        # multi-participant surface is a channel, and a room whose kind the
+        # server never set counts as one too (see _is_respond_room). The
         # shared _mentions_me gate keeps this path and the someone-else check
         # shared _mentions_me gate keeps this path and the someone-else check
         # below on one definition of "addressed", broadcast handles included.
         # below on one definition of "addressed", broadcast handles included.
-        if (
-            room_kind == RoomKind.CHANNEL
-            and self.chatto_config.require_mention.value
-            and not self._mentions_me(message_body)
-        ):
-            logger.debug(
-                "Discarding message. Bot was not mentioned but require_mention is '%s'.",
-                self.chatto_config.require_mention.value,
-            )
-            return None
+        if room_kind != RoomKind.DM:
+            policy = self._room_policy(room_id)
+            if policy == ChannelPolicy.REQUIRE_MENTION and not self._mentions_me(
+                message_body
+            ):
+                logger.debug(
+                    "Chatto: dropping unaddressed message from %s (policy %s)",
+                    room_id,
+                    policy.value,
+                )
+                return None
 
 
-        # With require_mention off we see every message in the channel, including
-        # ones plainly aimed at a named colleague. Answering those would be
-        # barging in, so acknowledge that we read it and stay quiet. Checked
-        # after the bot-mention test above, so a message naming us *and* someone
-        # else still counts as ours.
-        if (
-            room_kind == RoomKind.CHANNEL
-            and not self.chatto_config.require_mention.value
-            and not self._mentions_me(message_body)
-            and await self._mentions_someone_else(message_body)
-        ):
-            logger.info("Chatto: message addresses someone else, acknowledging only")
-            if self.chatto_config.reactions.value:
-                await self.add_reaction(message.room_id, message.id, "🫥")
-            return None
+            # In an open channel we see every message, including ones plainly
+            # aimed at a named colleague. Answering those would be barging in,
+            # so acknowledge that we read it and stay quiet. Checked after the
+            # bot-mention test above, so a message naming us *and* someone
+            # else still counts as ours.
+            if (
+                policy == ChannelPolicy.OPEN
+                and not self._mentions_me(message_body)
+                and await self._mentions_someone_else(message_body)
+            ):
+                logger.info(
+                    "Chatto: message addresses someone else, acknowledging only"
+                )
+                if self.chatto_config.reactions.value:
+                    await self.add_reaction(message.room_id, message.id, "🫥")
+                return None
 
 
         # Thread anchoring — if the incoming message is inside a Chatto thread, we
         # Thread anchoring — if the incoming message is inside a Chatto thread, we
         # keep that thread by default; otherwise leave thread_id unset so
         # keep that thread by default; otherwise leave thread_id unset so
@@ -1540,18 +1621,6 @@ class ChattoAdapter(BasePlatformAdapter):
                     stale_room_ids,
                     stale_room_ids,
                 )
                 )
 
 
-            unjoined_listed = [
-                rid
-                for rid in self.chatto_config.respond_rooms.value
-                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)
             self._warn_if_home_channel_unjoined(member_ids)
 
 
             if not new_room_ids:
             if not new_room_ids:
@@ -1571,7 +1640,7 @@ class ChattoAdapter(BasePlatformAdapter):
                     await self._seed_room(rid)
                     await self._seed_room(rid)
                 else:
                 else:
                     logger.info(
                     logger.info(
-                        "Chatto WS: %s (%s) is outside CHATTO_RESPOND_ROOMS -"
+                        "Chatto WS: %s (%s) is on neither mention list -"
                         " joined read-only",
                         " joined read-only",
                         self._room_names.get(rid, rid),
                         self._room_names.get(rid, rid),
                         rid,
                         rid,
@@ -2653,6 +2722,38 @@ async def hermes_standalone_sender_fn(
             )
             )
 
 
 
 
+# How similar an ``extra`` key must be to a declared config_key before the
+# unknown-key check reads it as a likely typo or renamed field.
+_NEAR_MISS_RATIO = 0.75
+
+
+def _warn_on_unknown_extra_keys(config: PlatformConfig) -> None:
+    """Warn about ``extra`` keys that look like misspelled config fields.
+
+    A typo'd key silently resolves to the default otherwise — the warning is
+    the only thing telling the user their ``require_mention_channles`` never
+    reached us. Only near-matches against our declared config_keys are
+    flagged (difflib similarity): keys that resemble nothing of ours are
+    either placed there by the Hermes gateway itself (the shared-key loop in
+    load_gateway_config() bridges reply_in_thread & co. into every platform's
+    extra, hardcoded inline upstream — nothing to import) or are deliberate
+    pass-throughs, and flagging those would just train users to ignore us.
+    Internal markers (``_enabled_explicit``) are skipped outright.
+    """
+    extra: dict[str, Any] = getattr(config, "extra", None) or {}
+    known = [field.config_key for field in ChattoConfiguration.fields()]
+    for key in sorted(extra):
+        if key.startswith("_") or key in known:
+            continue
+        best = max(known, key=lambda k: SequenceMatcher(None, key, k).ratio())
+        if SequenceMatcher(None, key, best).ratio() >= _NEAR_MISS_RATIO:
+            logger.warning(
+                "Chatto: 'extra' key '%s' matches no config field — did you mean '%s'?",
+                key,
+                best,
+            )
+
+
 def hermes_validate_config(config: PlatformConfig) -> bool:
 def hermes_validate_config(config: PlatformConfig) -> bool:
     """Check whether Chatto Plugin is configured.
     """Check whether Chatto Plugin is configured.
 
 
@@ -2661,6 +2762,7 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
     Takes ``config``. Compare to hermes_is_connected().
     Takes ``config``. Compare to hermes_is_connected().
     """
     """
     chatto_config = ChattoConfiguration(pconfig=config)
     chatto_config = ChattoConfiguration(pconfig=config)
+    _warn_on_unknown_extra_keys(config)
 
 
     if (
     if (
         len(chatto_config.allowed_users.value) > 0
         len(chatto_config.allowed_users.value) > 0
@@ -2671,6 +2773,18 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
         )
         )
         return False
         return False
 
 
+    require_channels = set(chatto_config.require_mention_rooms.value)
+    optional_channels = set(chatto_config.optional_mention_rooms.value)
+    overlap = sorted(require_channels & optional_channels)
+    if overlap:
+        logger.error(
+            "Chatto: Conflicting configuration. Room(s) %s are on both "
+            "'require_mention_rooms' and 'optional_mention_rooms' — "
+            "each room must appear in at most one of them.",
+            ", ".join(overlap),
+        )
+        return False
+
     # base_url always resolves (it defaults to ChattoHQ), so the only real
     # base_url always resolves (it defaults to ChattoHQ), so the only real
     # question is whether any credentials came in.
     # question is whether any credentials came in.
     if (chatto_config.token.value is not None) or (
     if (chatto_config.token.value is not None) or (

+ 10 - 6
after-install.md

@@ -36,9 +36,10 @@ can live in either file. Environment variables take precedence over
 
 
    Where cron job output and notifications get delivered when there is no
    Where cron job output and notifications get delivered when there is no
    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
-   converse there. If unset (the usual case — the installer only asks when
-   you enter one), the first joined room is used:
+   more. It follows the same participation rules as any room: a silent room
+   stays quiet, so list it in one of the mention room lists (see step 5) too
+   if the bot should also converse in it. If unset (the usual case — the
+   installer only asks when you enter one), the first joined room is used:
 
 
    ```
    ```
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE
    CHATTO_HOME_CHANNEL=ROOM_ID_HERE
@@ -64,9 +65,12 @@ can live in either file. Environment variables take precedence over
 
 
 5. Test
 5. Test
 
 
-   Send any message in a room the bot has joined. By default it answers every
-   room message; only with `CHATTO_REQUIRE_MENTION=true` do channel-kind rooms
-   wait for an @mention (group rooms and DMs answer regardless).
+   Send any message in a room the bot has joined. DMs answer right away;
+   every other room stays silent until you list it — in
+   `CHATTO_REQUIRE_MENTION_ROOMS` (answers only when the message addresses
+   the bot via `@name`, `@all` or `@here`) or `CHATTO_OPTIONAL_MENTION_ROOMS`
+   (answers every message). The `/join` reply prints both lines ready to
+   paste, with the room ID filled in.
 
 
    Editing works as a correction: fix a typo in your sent message within
    Editing works as a correction: fix a typo in your sent message within
    5 minutes and Hermes restarts his answer with the corrected text. If he had
    5 minutes and Hermes restarts his answer with the corrected text. If he had

+ 11 - 17
platform_config.py

@@ -55,12 +55,8 @@ class ChattoConstants:
     MAX_MESSAGE_LENGTH = 10000
     MAX_MESSAGE_LENGTH = 10000
     SEEN_CAP = 500
     SEEN_CAP = 500
 
 
-    # WebSocket / realtime protocol
-    WS_PATH = "/api/realtime"
-    WS_AUTH_TIMEOUT = 20.0
-    WS_MAX_MESSAGE_BYTES = 4_000_000
-    WS_PING_INTERVAL = 30.0
-    WS_PING_FAILURE_THRESHOLD = 3
+    # WebSocket reconnect backoff (the realtime transport itself lives in
+    # chattolib.realtime, which owns protocol-level constants).
     WS_RECONNECT_INITIAL_BACKOFF = 1.0
     WS_RECONNECT_INITIAL_BACKOFF = 1.0
     WS_RECONNECT_MAX_BACKOFF = 30.0
     WS_RECONNECT_MAX_BACKOFF = 30.0
 
 
@@ -335,17 +331,15 @@ class ChattoConfiguration:
     password = ConfigField("str")
     password = ConfigField("str")
     home_channel = ConfigField("str")
     home_channel = ConfigField("str")
     allowed_users = ConfigField("list")
     allowed_users = ConfigField("list")
-    require_mention = ConfigField("bool", default=False)
-    # free_response_channels: room IDs where the bot responds without being
-    # mentioned via "@botname" even when require_mention is true.
-    free_response_channels_list = ConfigField(
-        "list", config_key="free_response_channels"
-    )
-    # respond_rooms: positive list of room IDs the bot reads and answers in.
-    # 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")
+    # Participation in multi-person rooms is opt-in: require_mention_rooms
+    # lists rooms where the bot answers only when addressed
+    # (@name/@all/@here), optional_mention_rooms lists rooms where it answers
+    # everything. A room in neither list stays silent — read-only, never
+    # seeded into context or answered. Chatto has no group rooms like Signal:
+    # every non-DM surface is a channel-kind room, so these two lists are the
+    # only inbound gate; DMs always answer.
+    require_mention_rooms = ConfigField("list")
+    optional_mention_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
     # messages (not DMs, not already in a thread). This keeps conversations
     # messages (not DMs, not already in a thread). This keeps conversations
     # organized in the room. Can be disabled via extra.auto_thread=false.
     # organized in the room. Can be disabled via extra.auto_thread=false.

+ 5 - 9
plugin.yaml

@@ -42,20 +42,16 @@ optional_env:
     description: "Comma-separated Chatto logins allowed to talk to the agent (default: none — denies everyone unless CHATTO_ALLOW_ALL_USERS=true)"
     description: "Comma-separated Chatto logins allowed to talk to the agent (default: none — denies everyone unless CHATTO_ALLOW_ALL_USERS=true)"
     prompt: "Allowed users (comma-separated)"
     prompt: "Allowed users (comma-separated)"
     password: false
     password: false
-  - name: CHATTO_REQUIRE_MENTION
-    description: "Only respond to @mentions in channels (default: false). Group-kind rooms and DMs always get a response."
-    prompt: "Require mention in rooms? (true/false)"
+  - name: CHATTO_REQUIRE_MENTION_ROOMS
+    description: "Comma-separated room IDs where the bot answers only when addressed (@name, @all or @here); rooms on neither this nor CHATTO_OPTIONAL_MENTION_ROOMS stay silent — read-only, never seeded into context, never answered. DMs always get a response. (default: none — the listed rooms answer only mentions)"
+    prompt: "Mention-required room IDs (comma-separated)"
     password: false
     password: false
   - name: CHATTO_AUTO_THREAD
   - name: CHATTO_AUTO_THREAD
     description: "Auto-create threads for replies in rooms (default: true). Set false to reply in the room timeline."
     description: "Auto-create threads for replies in rooms (default: true). Set false to reply in the room timeline."
     prompt: "Auto-thread replies? (true/false)"
     prompt: "Auto-thread replies? (true/false)"
     password: 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)"
+  - name: CHATTO_OPTIONAL_MENTION_ROOMS
+    description: "Comma-separated room IDs where the bot answers every message, addressed or not; rooms on neither this nor CHATTO_REQUIRE_MENTION_ROOMS stay silent — read-only, never seeded into context, never answered. (default: none — the listed rooms answer everything)"
     prompt: "Free-response room IDs (comma-separated)"
     prompt: "Free-response room IDs (comma-separated)"
     password: false
     password: false
   - name: CHATTO_REACTIONS
   - name: CHATTO_REACTIONS

+ 226 - 123
test_adapter.py

@@ -65,6 +65,7 @@ from gateway.platforms.base import (
 )
 )
 
 
 from adapter import (
 from adapter import (
+    ChannelPolicy,
     ChattoAdapter,
     ChattoAdapter,
     HermesChatType,
     HermesChatType,
     _capabilities,
     _capabilities,
@@ -127,12 +128,12 @@ _CHATTO_ENV_KEYS = [
     "CHATTO_PASSWORD",
     "CHATTO_PASSWORD",
     "CHATTO_TOKEN",
     "CHATTO_TOKEN",
     "CHATTO_HOME_CHANNEL",
     "CHATTO_HOME_CHANNEL",
-    "CHATTO_REQUIRE_MENTION",
+    "CHATTO_REQUIRE_MENTION_ROOMS",
+    "CHATTO_OPTIONAL_MENTION_ROOMS",
     "CHATTO_ALLOWED_USERS",
     "CHATTO_ALLOWED_USERS",
     "CHATTO_ALLOW_ALL_USERS",
     "CHATTO_ALLOW_ALL_USERS",
     "CHATTO_AUTO_THREAD",
     "CHATTO_AUTO_THREAD",
     "CHATTO_REACTIONS",
     "CHATTO_REACTIONS",
-    "CHATTO_RESPOND_ROOMS",
 ]
 ]
 
 
 
 
@@ -342,6 +343,82 @@ class TestRegistration:
         _clear_chatto_env()
         _clear_chatto_env()
 
 
 
 
+# -- Config validation: mention-list conflicts and unknown keys --
+
+
+class TestValidateConfigGates:
+    """hermes_validate_config rejects contradictory mention lists and warns
+    about extra keys that look like misspelled config fields."""
+
+    def _cfg(self, **extra):
+        _clear_chatto_env()
+        base = {
+            "base_url": "https://chat.test",
+            "login": "user",
+            "password": "pass",
+        }
+        base.update(extra)
+        return PlatformConfig(enabled=True, extra=base)
+
+    def test_room_on_both_mention_lists_is_rejected(self):
+        cfg = self._cfg(
+            require_mention_rooms=["room-1", "room-2"],
+            optional_mention_rooms=["room-2", "room-3"],
+        )
+        assert validate_config(cfg) is False
+
+    def test_disjoint_mention_lists_are_accepted(self):
+        cfg = self._cfg(
+            require_mention_rooms=["room-1"],
+            optional_mention_rooms=["room-3"],
+        )
+        assert validate_config(cfg) is True
+
+    @pytest.mark.parametrize("key", ["require_mention_rooms", "optional_mention_rooms"])
+    def test_known_keys_do_not_warn(self, key, caplog):
+        cfg = self._cfg(**{key: ["room-1"]})
+        with caplog.at_level("WARNING"):
+            validate_config(cfg)
+        assert not [m for m in caplog.messages if "matches no config field" in m]
+
+    def test_a_typo_extra_key_warns_with_a_suggestion(self, caplog):
+        """A typo'd key silently resolves to its default otherwise — the
+        warning is the only thing telling the user it never reached us."""
+        cfg = self._cfg(require_mention_channles=["room-1"])
+        with caplog.at_level("WARNING"):
+            validate_config(cfg)
+        warnings = [m for m in caplog.messages if "matches no config field" in m]
+        assert len(warnings) == 1
+        assert "require_mention_channles" in warnings[0]
+        assert "did you mean 'require_mention_rooms'" in warnings[0]
+
+    def test_a_legacy_renamed_key_suggests_its_successor(self, caplog):
+        """Removed fields read as near-misses of their successors."""
+        cfg = self._cfg(require_mention=True)
+        with caplog.at_level("WARNING"):
+            validate_config(cfg)
+        warnings = [m for m in caplog.messages if "matches no config field" in m]
+        assert len(warnings) == 1
+        assert "did you mean 'require_mention_rooms'" in warnings[0]
+
+    @pytest.mark.parametrize(
+        "key",
+        [
+            "_enabled_explicit",
+            "group_sessions_per_user",
+            "reply_in_thread",
+            "gateway_restart_notification",
+        ],
+    )
+    def test_gateway_keys_do_not_warn(self, key, caplog):
+        """The gateway places shared keys into every platform's extra — they
+        resemble nothing of ours, so near-miss matching leaves them alone."""
+        cfg = self._cfg(**{key: True})
+        with caplog.at_level("WARNING"):
+            validate_config(cfg)
+        assert not [m for m in caplog.messages if "matches no config field" in m]
+
+
 # -- Send functionality --
 # -- Send functionality --
 
 
 
 
@@ -848,6 +925,7 @@ class TestChatTypeMapping:
         it is — a raw RoomKind lands in SessionSource.description's else-branch."""
         it is — a raw RoomKind lands in SessionSource.description's else-branch."""
         adapter = _make_adapter()
         adapter = _make_adapter()
         adapter.chatto_config.allow_all_users.value = True
         adapter.chatto_config.allow_all_users.value = True
+        adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
         adapter.me = _make_user("bot-user-id", "hermes_bot")
         adapter.me = _make_user("bot-user-id", "hermes_bot")
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._user_cache["user-1"] = _make_user("user-1", "alice")
         adapter._user_cache["user-1"] = _make_user("user-1", "alice")
@@ -935,14 +1013,14 @@ class TestPresence:
 
 
 
 
 class TestForeignMention:
 class TestForeignMention:
-    """With require_mention off the bot reads everything, so a message aimed at
-    a named colleague would otherwise get an unsolicited answer. Acknowledge it
-    with 🫥 and stay out of the conversation."""
+    """In an open channel (CHATTO_OPTIONAL_MENTION_ROOMS) the bot reads
+    everything, so a message aimed at a named colleague would otherwise get an
+    unsolicited answer. Acknowledge it with 🫥 and stay out of the conversation."""
 
 
     def _adapter(self, **overrides):
     def _adapter(self, **overrides):
         adapter = _make_adapter()
         adapter = _make_adapter()
         adapter.chatto_config.allow_all_users.value = True
         adapter.chatto_config.allow_all_users.value = True
-        adapter.chatto_config.require_mention.value = False
+        adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
         adapter.chatto_config.reactions.value = True
         adapter.chatto_config.reactions.value = True
         for key, value in overrides.items():
         for key, value in overrides.items():
             getattr(adapter.chatto_config, key).value = value
             getattr(adapter.chatto_config, key).value = value
@@ -1125,10 +1203,13 @@ class TestForeignMention:
         await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
         await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
         adapter.handle_message.assert_called_once()
         adapter.handle_message.assert_called_once()
 
 
-    async def test_require_mention_keeps_discarding_without_a_reaction(self):
-        """The older gate wins: it drops the message before we get here, and it
-        deliberately says nothing at all."""
-        adapter = self._adapter(require_mention=True)
+    async def test_require_list_keeps_discarding_without_a_reaction(self):
+        """The require gate wins: it drops the message before we get here, and
+        it deliberately says nothing at all."""
+        adapter = self._adapter(
+            optional_mention_rooms=[],
+            require_mention_rooms=["room-1"],
+        )
         await self._dispatch(adapter, "@bob can you take a look?")
         await self._dispatch(adapter, "@bob can you take a look?")
         adapter.handle_message.assert_not_called()
         adapter.handle_message.assert_not_called()
         adapter.add_reaction.assert_not_awaited()
         adapter.add_reaction.assert_not_awaited()
@@ -1140,16 +1221,17 @@ class TestForeignMention:
         adapter.add_reaction.assert_not_awaited()
         adapter.add_reaction.assert_not_awaited()
 
 
 
 
-# -- require_mention --
+# -- Channel mention policies --
 
 
 
 
-class TestRequireMention:
-    """require_mention gates channels only — a DM is already addressed at the bot."""
+class TestChannelPolicies:
+    """Every non-DM room is opt-in via the two mention lists — Chatto has no
+    group rooms, and an unknown kind counts as a channel too. A DM is already
+    addressed at the bot."""
 
 
     def _adapter(self):
     def _adapter(self):
         adapter = _make_adapter()
         adapter = _make_adapter()
         adapter.chatto_config.allow_all_users.value = True
         adapter.chatto_config.allow_all_users.value = True
-        adapter.chatto_config.require_mention.value = True
         adapter.me = _make_user("bot-user-id", "hermes_bot")
         adapter.me = _make_user("bot-user-id", "hermes_bot")
         adapter._user_cache["user-1"] = _make_user("user-1", "alice")
         adapter._user_cache["user-1"] = _make_user("user-1", "alice")
         adapter.handle_message = AsyncMock()
         adapter.handle_message = AsyncMock()
@@ -1162,28 +1244,83 @@ class TestRequireMention:
         )
         )
         await adapter._dispatch_message_posted(payload)
         await adapter._dispatch_message_posted(payload)
 
 
-    async def test_channel_without_mention_is_discarded(self):
+    async def test_unlisted_room_is_silent(self):
+        """Opt-in by default: an unlisted room is not dispatched at all."""
+        adapter = self._adapter()
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
+        adapter.handle_message.assert_not_called()
+
+    async def test_unlisted_room_is_dropped_before_any_api_call(self):
+        """Silent rooms must not even fetch the message."""
+        adapter = self._adapter()
+        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_follows_the_mention_lists(self):
+        """A server that never sets kind shows up as UNSPECIFIED; Chatto has
+        no group rooms, so it is treated as a channel: silent when unlisted,
+        gated when listed."""
+        adapter = self._adapter()
+        adapter._room_kinds["mystery-room"] = RoomKind.UNSPECIFIED
+        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.chatto_config.optional_mention_rooms.value = ["mystery-room"]
+        await self._dispatch(adapter, "mystery-room", "hi there")
+        adapter.handle_message.assert_called_once()
+
+    async def test_require_list_room_without_mention_is_discarded(self):
         adapter = self._adapter()
         adapter = self._adapter()
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         await self._dispatch(adapter, "room-1", "hi there")
         await self._dispatch(adapter, "room-1", "hi there")
         adapter.handle_message.assert_not_called()
         adapter.handle_message.assert_not_called()
 
 
-    async def test_channel_with_mention_is_answered(self):
+    async def test_require_list_room_with_mention_is_answered(self):
         adapter = self._adapter()
         adapter = self._adapter()
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
         await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
         adapter.handle_message.assert_called_once()
         adapter.handle_message.assert_called_once()
 
 
-    async def test_broadcast_mention_counts_as_addressed_in_channels(self):
+    async def test_broadcast_mention_counts_as_addressed(self):
         """@here/@all address the bot too — one definition of 'addressed'
         """@here/@all address the bot too — one definition of 'addressed'
         serves this gate and the someone-else check alike (FDR-006)."""
         serves this gate and the someone-else check alike (FDR-006)."""
         adapter = self._adapter()
         adapter = self._adapter()
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         await self._dispatch(adapter, "room-1", "@here standup in 5")
         await self._dispatch(adapter, "room-1", "@here standup in 5")
         adapter.handle_message.assert_called_once()
         adapter.handle_message.assert_called_once()
 
 
+    async def test_optional_list_room_answers_without_a_mention(self):
+        adapter = self._adapter()
+        adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        await self._dispatch(adapter, "room-1", "hi there")
+        adapter.handle_message.assert_called_once()
+
+    async def test_optional_list_beats_the_require_list_at_runtime(self):
+        """validate_config rejects the overlap, but if contradictory config
+        reaches a running adapter anyway, answering is safer than silence."""
+        adapter = self._adapter()
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
+        adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        assert adapter._room_policy("room-1") is ChannelPolicy.OPEN
+
     async def test_dm_is_answered_without_a_mention(self):
     async def test_dm_is_answered_without_a_mention(self):
-        """The point of the room_kind check: require_mention must not mute DMs."""
+        """The point of the room_kind check: mention gating must not mute DMs."""
         adapter = self._adapter()
         adapter = self._adapter()
         adapter._room_kinds["dm-1"] = RoomKind.DM
         adapter._room_kinds["dm-1"] = RoomKind.DM
         await self._dispatch(adapter, "dm-1", "hi there")
         await self._dispatch(adapter, "dm-1", "hi there")
@@ -1280,9 +1417,8 @@ class TestEditDispatch:
         adapter.handle_message.assert_not_called()
         adapter.handle_message.assert_not_called()
 
 
     async def test_read_only_room_costs_no_api_call(self):
     async def test_read_only_room_costs_no_api_call(self):
-        """Respond-room gating sits before fetch_message, like for posts."""
+        """Silent-room gating sits before fetch_message, like for posts."""
         adapter = self._adapter()
         adapter = self._adapter()
-        adapter.chatto_config.respond_rooms.value = ["other-room"]
 
 
         fetch = await self._edit(adapter, "corrected")
         fetch = await self._edit(adapter, "corrected")
         fetch.fetch_message.assert_not_awaited()  # type: ignore[union-attr]
         fetch.fetch_message.assert_not_awaited()  # type: ignore[union-attr]
@@ -1291,7 +1427,7 @@ class TestEditDispatch:
     async def test_mention_added_by_edit_starts_a_turn(self):
     async def test_mention_added_by_edit_starts_a_turn(self):
         adapter = self._adapter()
         adapter = self._adapter()
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
-        adapter.chatto_config.require_mention.value = True
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
 
 
         await self._edit(adapter, "@hermes_bot corrected text")
         await self._edit(adapter, "@hermes_bot corrected text")
 
 
@@ -1306,7 +1442,7 @@ class TestEditDispatch:
         """Gates run against the new body — no mention in, no answer out."""
         """Gates run against the new body — no mention in, no answer out."""
         adapter = self._adapter()
         adapter = self._adapter()
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
-        adapter.chatto_config.require_mention.value = True
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
 
 
         await self._edit(adapter, "still no mention")
         await self._edit(adapter, "still no mention")
 
 
@@ -1664,8 +1800,11 @@ 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_seeds(self):
+    async def test_join_silent_channel_names_the_env_lines(self):
+        """Users copy room IDs from this reply — an unlisted channel hands
+        them both mention-list lines verbatim instead of staying quiet."""
         adapter = self._adapter()
         adapter = self._adapter()
+        adapter._seed_room = AsyncMock()
         state = _make_room_state(
         state = _make_room_state(
             _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
             _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
         )
         )
@@ -1676,12 +1815,68 @@ class TestDmRoomCommands:
 
 
         adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
         adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
         assert adapter._joined_room_ids == ["room-9"]
         assert adapter._joined_room_ids == ["room-9"]
-        assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
+        reply = self._reply(adapter)
+        assert "Joined 'Deploy' (room-9)" in reply
+        assert "CHATTO_REQUIRE_MENTION_ROOMS=room-9" in reply
+        assert "CHATTO_OPTIONAL_MENTION_ROOMS=room-9" in reply
+
+    async def test_join_silent_channel_does_not_seed(self):
+        """Same rule as _refresh_rooms: history nothing will ever answer is
+        not pushed into context."""
+        adapter = self._adapter()
+        adapter._seed_room = AsyncMock()
+        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._seed_room.assert_not_awaited()
+
+    async def test_join_listed_channel_reports_policy_and_seeds(self):
+        adapter = self._adapter()
+        adapter.chatto_config.require_mention_rooms.value = ["room-9"]
+        adapter._seed_room = AsyncMock()
+        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._seed_room.assert_awaited_once_with("room-9")
+        reply = self._reply(adapter)
+        assert "answers only @mentions" in reply
+        assert "CHATTO_OPTIONAL_MENTION_ROOMS=" not in reply
+
+    async def test_join_room_with_unknown_kind_gets_the_channel_hint(self):
+        """A server that never sets kind counts as a channel too — the reply
+        carries the mention-list hint and nothing is seeded."""
+        adapter = self._adapter()
+        adapter._seed_room = AsyncMock()
+        state = _make_room_state(
+            _make_room("grp-9", "Deploy", RoomKind.UNSPECIFIED), 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("grp-9")
+        assert adapter._joined_room_ids == ["grp-9"]
+        adapter._seed_room.assert_not_awaited()
+        reply = self._reply(adapter)
+        assert "Joined 'Deploy' (grp-9)" in reply
+        assert "CHATTO_REQUIRE_MENTION_ROOMS=grp-9" in reply
 
 
     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 joined list."""
+        seeding into the joined list (which silent channels skip)."""
+        adapter._seed_room = AsyncMock()
         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])
 
 
@@ -1769,7 +1964,7 @@ class TestDmRoomCommands:
         adapter = self._adapter()
         adapter = self._adapter()
         """In a channel the text is just a message — mention gating applies,
         """In a channel the text is just a message — mention gating applies,
         no command runs, nothing is sent."""
         no command runs, nothing is sent."""
-        adapter.chatto_config.require_mention.value = True
+        adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
         adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
         adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
 
 
         await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
         await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
@@ -1833,101 +2028,19 @@ class TestJoinedRoomsRefresh:
         assert not adapter._home_warning_logged
         assert not adapter._home_warning_logged
 
 
 
 
-class TestRespondRooms:
-    """CHATTO_RESPOND_ROOMS gates inbound messages: positive list, DMs exempt."""
+class TestSilentRoomRefresh:
+    """_refresh_rooms keeps silent rooms joined but skips seeding them."""
 
 
-    def _dropping_adapter(self, respond_rooms):
-        """An adapter whose every API call explodes — the gate must exit first."""
+    def _adapter(self, optional_mention_rooms):
         adapter = _make_adapter()
         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._get_chatto_client = AsyncMock(return_value=None)
-        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._get_chatto_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._get_chatto_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._get_chatto_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 joined but skips seeding them."""
-
-    def _adapter(self, respond_rooms):
-        adapter = _make_adapter()
-        adapter.chatto_config.respond_rooms.value = respond_rooms
+        adapter.chatto_config.optional_mention_rooms.value = optional_mention_rooms
         client = adapter._chatto_client
         client = adapter._chatto_client
         client.list_rooms = AsyncMock(return_value=[])
         client.list_rooms = AsyncMock(return_value=[])
         client.join_room = AsyncMock()
         client.join_room = AsyncMock()
         adapter._seed_room = AsyncMock()
         adapter._seed_room = AsyncMock()
         return adapter
         return adapter
 
 
-    async def test_read_only_rooms_are_joined_but_not_seeded(self):
+    async def test_silent_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)
@@ -1954,16 +2067,6 @@ class TestRespondRoomRefresh:
         assert "[read-only]" in joined[-1]
         assert "[read-only]" in joined[-1]
         assert "[universal]" 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"])
-        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 --
 # -- Constants --
 
 

+ 42 - 1
test_platform_config.py

@@ -4,9 +4,9 @@ import sys
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
 sys.path.insert(0, PLUGIN_ROOT)
 sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
 sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
-sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 
 from platform_config import (
 from platform_config import (
+    ChattoConfiguration,
     _get_env_or_extra_int,
     _get_env_or_extra_int,
     _get_env_or_extra_list,
     _get_env_or_extra_list,
     _get_env_or_extra_str,
     _get_env_or_extra_str,
@@ -121,3 +121,44 @@ class TestPlatformConfigHelpers:
     def test_get_env_or_extra_int_defaults_when_both_missing(self, monkeypatch):
     def test_get_env_or_extra_int_defaults_when_both_missing(self, monkeypatch):
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_int("CHATTO_TEST", None, default=300) == 300
         assert _get_env_or_extra_int("CHATTO_TEST", None, default=300) == 300
+
+
+class TestMentionRoomFields:
+    """The mention lists derive env names from their config keys and resolve
+    to empty lists when nothing is set — channels are opt-in."""
+
+    def test_env_names_derive_from_the_config_keys(self):
+        assert (
+            ChattoConfiguration.require_mention_rooms.env_name
+            == "CHATTO_REQUIRE_MENTION_ROOMS"
+        )
+        assert (
+            ChattoConfiguration.optional_mention_rooms.env_name
+            == "CHATTO_OPTIONAL_MENTION_ROOMS"
+        )
+
+    def test_lists_default_to_empty_without_any_configuration(self, monkeypatch):
+        for var in (
+            "CHATTO_REQUIRE_MENTION_ROOMS",
+            "CHATTO_OPTIONAL_MENTION_ROOMS",
+        ):
+            monkeypatch.delenv(var, raising=False)
+        cfg = ChattoConfiguration(
+            pconfig=PlatformConfigStub(extra={"base_url": "https://chat.test"})
+        )
+        assert cfg.require_mention_rooms.value == []
+        assert cfg.optional_mention_rooms.value == []
+
+    def test_lists_resolve_from_env_comma_separated(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_REQUIRE_MENTION_ROOMS", " room-1 , room-2 ")
+        cfg = ChattoConfiguration(
+            pconfig=PlatformConfigStub(extra={"base_url": "https://chat.test"})
+        )
+        assert cfg.require_mention_rooms.value == ["room-1", "room-2"]
+
+
+class PlatformConfigStub:
+    """Just the attribute ChattoConfiguration reads off a PlatformConfig."""
+
+    def __init__(self, extra):
+        self.extra = extra