Explorar o código

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 hai 1 semana
pai
achega
c67865873b
Modificáronse 8 ficheiros con 504 adicións e 245 borrados
  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
 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:
   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
   context-less cron/notification delivery, not an answer destination.
 - **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.
 - **Room membership is server-side, commands ride over DMs.** `/join` and
   `/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
   server limit; `edit_message()` refuses over-long content so callers fall
   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 |
 |---------|----------|
 | **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. |
 | **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`. |
 | **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. |
 
-> **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
 
@@ -96,24 +96,20 @@ CHATTO_LOGIN=hermes
 CHATTO_PASSWORD=your-password
 
 # 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
 
-# Optional: room IDs the bot reads and answers in; other joined rooms stay
-# read-only (marked as read, never seeded into context, never answered)
-# CHATTO_RESPOND_ROOMS=REljMv5Pgolo6Y9
-
 # Optional: restrict who can talk to the bot (comma-separated logins)
 # CHATTO_ALLOWED_USERS=alice,bob
 
 # Optional: allow any user to talk to the bot (default: false)
 # 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)
 # CHATTO_AUTO_THREAD=true
@@ -146,8 +142,8 @@ gateway:
       extra:
         base_url: https://chat.example.com
         home_channel: REljMv5Pgolo6Y9
-        respond_rooms: []          # empty = read and answer in every joined room
-        require_mention: false     # only respond to @mentions in channels
+        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)
         allow_all_users: true
         edit_dispatch: true        # process message edits as corrections
@@ -164,12 +160,11 @@ gateway:
 | `CHATTO_LOGIN` | Yes* | — | Chatto username (login) |
 | `CHATTO_PASSWORD` | Yes* | — | Chatto password |
 | `CHATTO_TOKEN` | No | — | Existing bearer token — alternative to login/password |
-| `CHATTO_HOME_CHANNEL` | No | First 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_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_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`) |
@@ -276,15 +271,18 @@ Presence is a TTL the server lets lapse, not a flag that stays set: `UpdatePrese
 
 ## 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".
 
@@ -321,7 +319,7 @@ The home channel is the default outbound target for proactive messages — cron
 - `CHATTO_HOME_CHANNEL` env var (room ID)
 - `config.yaml` → `gateway.platforms.chatto.extra.home_channel`
 
-If unset, the first 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
 
@@ -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.
 
+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`.
 
 ## Troubleshooting

+ 164 - 50
adapter.py

@@ -31,6 +31,7 @@ import os
 import re
 import tempfile
 from datetime import UTC, datetime
+from difflib import SequenceMatcher
 from enum import StrEnum
 from typing import Any, cast
 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)
 
 
+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
 # 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
@@ -766,6 +786,9 @@ class ChattoAdapter(BasePlatformAdapter):
 
         An account that already holds membership (invited natively in Chatto)
         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
         if room_obj is None:
@@ -783,8 +806,23 @@ class ChattoAdapter(BasePlatformAdapter):
         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._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)
+        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:
             return f"Already a member of {label} — listening there."
         return f"Joined {label}."
@@ -821,6 +859,32 @@ class ChattoAdapter(BasePlatformAdapter):
             self._joined_room_ids.remove(room_obj.id)
         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
     # ------------------------------------------------------------------ #
@@ -934,20 +998,33 @@ class ChattoAdapter(BasePlatformAdapter):
             return MessageType.AUDIO
         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:
         """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:
             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:
         # 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)
                 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
         # 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
         # keep that thread by default; otherwise leave thread_id unset so
@@ -1540,18 +1621,6 @@ class ChattoAdapter(BasePlatformAdapter):
                     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)
 
             if not new_room_ids:
@@ -1571,7 +1640,7 @@ class ChattoAdapter(BasePlatformAdapter):
                     await self._seed_room(rid)
                 else:
                     logger.info(
-                        "Chatto WS: %s (%s) is outside CHATTO_RESPOND_ROOMS -"
+                        "Chatto WS: %s (%s) is on neither mention list -"
                         " joined read-only",
                         self._room_names.get(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:
     """Check whether Chatto Plugin is configured.
 
@@ -2661,6 +2762,7 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
     Takes ``config``. Compare to hermes_is_connected().
     """
     chatto_config = ChattoConfiguration(pconfig=config)
+    _warn_on_unknown_extra_keys(config)
 
     if (
         len(chatto_config.allowed_users.value) > 0
@@ -2671,6 +2773,18 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
         )
         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
     # question is whether any credentials came in.
     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
    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
@@ -64,9 +65,12 @@ can live in either file. Environment variables take precedence over
 
 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
    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
     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_MAX_BACKOFF = 30.0
 
@@ -335,17 +331,15 @@ class ChattoConfiguration:
     password = ConfigField("str")
     home_channel = ConfigField("str")
     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
     # messages (not DMs, not already in a thread). This keeps conversations
     # 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)"
     prompt: "Allowed users (comma-separated)"
     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
   - name: CHATTO_AUTO_THREAD
     description: "Auto-create threads for replies in rooms (default: true). Set false to reply in the room timeline."
     prompt: "Auto-thread replies? (true/false)"
     password: false
-  - name: CHATTO_RESPOND_ROOMS
-    description: "Comma-separated room IDs the bot reads and answers in; other joined rooms stay read-only — marked as read, never seeded into context, never answered. Inbound replies only; context-less cron/notification delivery via CHATTO_HOME_CHANNEL is unaffected. (default: none — every joined room is a respond room)"
-    prompt: "Respond room IDs (comma-separated)"
-    password: false
-  - name: CHATTO_FREE_RESPONSE_CHANNELS
-    description: "Comma-separated room IDs where the bot responds without being tagged (default: none)"
+  - 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)"
     password: false
   - name: CHATTO_REACTIONS

+ 226 - 123
test_adapter.py

@@ -65,6 +65,7 @@ from gateway.platforms.base import (
 )
 
 from adapter import (
+    ChannelPolicy,
     ChattoAdapter,
     HermesChatType,
     _capabilities,
@@ -127,12 +128,12 @@ _CHATTO_ENV_KEYS = [
     "CHATTO_PASSWORD",
     "CHATTO_TOKEN",
     "CHATTO_HOME_CHANNEL",
-    "CHATTO_REQUIRE_MENTION",
+    "CHATTO_REQUIRE_MENTION_ROOMS",
+    "CHATTO_OPTIONAL_MENTION_ROOMS",
     "CHATTO_ALLOWED_USERS",
     "CHATTO_ALLOW_ALL_USERS",
     "CHATTO_AUTO_THREAD",
     "CHATTO_REACTIONS",
-    "CHATTO_RESPOND_ROOMS",
 ]
 
 
@@ -342,6 +343,82 @@ class TestRegistration:
         _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 --
 
 
@@ -848,6 +925,7 @@ class TestChatTypeMapping:
         it is — a raw RoomKind lands in SessionSource.description's else-branch."""
         adapter = _make_adapter()
         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._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._user_cache["user-1"] = _make_user("user-1", "alice")
@@ -935,14 +1013,14 @@ class TestPresence:
 
 
 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):
         adapter = _make_adapter()
         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
         for key, value in overrides.items():
             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")
         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?")
         adapter.handle_message.assert_not_called()
         adapter.add_reaction.assert_not_awaited()
@@ -1140,16 +1221,17 @@ class TestForeignMention:
         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):
         adapter = _make_adapter()
         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._user_cache["user-1"] = _make_user("user-1", "alice")
         adapter.handle_message = AsyncMock()
@@ -1162,28 +1244,83 @@ class TestRequireMention:
         )
         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.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         await self._dispatch(adapter, "room-1", "hi there")
         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.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
         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'
         serves this gate and the someone-else check alike (FDR-006)."""
         adapter = self._adapter()
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         await self._dispatch(adapter, "room-1", "@here standup in 5")
         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):
-        """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._room_kinds["dm-1"] = RoomKind.DM
         await self._dispatch(adapter, "dm-1", "hi there")
@@ -1280,9 +1417,8 @@ class TestEditDispatch:
         adapter.handle_message.assert_not_called()
 
     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.chatto_config.respond_rooms.value = ["other-room"]
 
         fetch = await self._edit(adapter, "corrected")
         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):
         adapter = self._adapter()
         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")
 
@@ -1306,7 +1442,7 @@ class TestEditDispatch:
         """Gates run against the new body — no mention in, no answer out."""
         adapter = self._adapter()
         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")
 
@@ -1664,8 +1800,11 @@ class TestDmRoomCommands:
         assert adapter.send.await_count == 1
         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._seed_room = AsyncMock()
         state = _make_room_state(
             _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")
         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):
         adapter = self._adapter()
         """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)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
 
@@ -1769,7 +1964,7 @@ class TestDmRoomCommands:
         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.chatto_config.require_mention_rooms.value = ["chan-1"]
         adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
 
         await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
@@ -1833,101 +2028,19 @@ class TestJoinedRoomsRefresh:
         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.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.list_rooms = AsyncMock(return_value=[])
         client.join_room = AsyncMock()
         adapter._seed_room = AsyncMock()
         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"])
         news = _make_room_state(_make_room("news-1", "News", 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 "[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 --
 

+ 42 - 1
test_platform_config.py

@@ -4,9 +4,9 @@ import sys
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
 sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
-sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 from platform_config import (
+    ChattoConfiguration,
     _get_env_or_extra_int,
     _get_env_or_extra_list,
     _get_env_or_extra_str,
@@ -121,3 +121,44 @@ class TestPlatformConfigHelpers:
     def test_get_env_or_extra_int_defaults_when_both_missing(self, monkeypatch):
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         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