Ver Fonte

Tag joined-rooms summary with policy and DM kind

The summary now logs on every refresh with [dm]/[on-mention]/
[every-message]/[read-only] per room. ChannelPolicy -> RoomPolicy,
_channel_join_hint -> _room_join_hint. The read-mark sweep after
discovery no longer starves when a refresh finds no new rooms.
Paul Klumpp há 1 semana atrás
pai
commit
d5c230421a
2 ficheiros alterados com 102 adições e 63 exclusões
  1. 72 52
      adapter.py
  2. 30 11
      test_adapter.py

+ 72 - 52
adapter.py

@@ -202,7 +202,7 @@ def chat_type_for_room_kind(kind: RoomKind | None) -> HermesChatType:
     return _ROOM_KIND_TO_CHAT_TYPE.get(kind, HermesChatType.GROUP)
 
 
-class ChannelPolicy(StrEnum):
+class RoomPolicy(StrEnum):
     """How a channel-kind room treats an inbound message.
 
     Derived per room from ``CHATTO_REQUIRE_MENTION_ROOMS`` /
@@ -805,7 +805,7 @@ class ChattoAdapter(BasePlatformAdapter):
         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``).
+        where users copy the room ID from (see ``_room_join_hint``).
         """
         room_obj = state.room
         if room_obj is None:
@@ -839,7 +839,7 @@ class ChattoAdapter(BasePlatformAdapter):
                 if state.viewer_state.is_member
                 else f"Joined {label}."
             )
-            return f"{head}\n{self._channel_join_hint(room_obj.id)}"
+            return f"{head}\n{self._room_join_hint(room_obj.id)}"
         if state.viewer_state.is_member:
             return f"Already a member of {label} — listening there."
         return f"Joined {label}."
@@ -876,7 +876,7 @@ class ChattoAdapter(BasePlatformAdapter):
             self._joined_room_ids.remove(room_obj.id)
         return f"Left {label}."
 
-    def _channel_join_hint(self, room_id: str) -> str:
+    def _room_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
@@ -885,12 +885,12 @@ class ChattoAdapter(BasePlatformAdapter):
         gateway startup, hence the restart note.
         """
         policy = self._room_policy(room_id)
-        if policy == ChannelPolicy.REQUIRE_MENTION:
+        if policy == RoomPolicy.REQUIRE_MENTION:
             return (
                 "This channel answers only @mentions "
                 "(listed in CHATTO_REQUIRE_MENTION_ROOMS)."
             )
-        if policy == ChannelPolicy.OPEN:
+        if policy == RoomPolicy.OPEN:
             return (
                 "This channel answers every message "
                 "(listed in CHATTO_OPTIONAL_MENTION_ROOMS)."
@@ -1033,7 +1033,7 @@ class ChattoAdapter(BasePlatformAdapter):
         self._room_kinds[room_id] = kind
         return kind
 
-    def _room_policy(self, room_id: str) -> ChannelPolicy:
+    def _room_policy(self, room_id: str) -> RoomPolicy:
         """Which of the mention lists a channel-kind room is on.
 
         The two lists are mutually exclusive (enforced by
@@ -1042,10 +1042,10 @@ class ChattoAdapter(BasePlatformAdapter):
         neither list stays silent.
         """
         if room_id in self.chatto_config.optional_mention_rooms.value:
-            return ChannelPolicy.OPEN
+            return RoomPolicy.OPEN
         if room_id in self.chatto_config.require_mention_rooms.value:
-            return ChannelPolicy.REQUIRE_MENTION
-        return ChannelPolicy.SILENT
+            return RoomPolicy.REQUIRE_MENTION
+        return RoomPolicy.SILENT
 
     def _answers_in_room(self, room_id: str) -> bool:
         """Whether inbound messages from this room reach the agent pipeline.
@@ -1059,7 +1059,7 @@ class ChattoAdapter(BasePlatformAdapter):
         """
         if self._room_kinds.get(room_id) == RoomKind.DM:
             return True
-        return self._room_policy(room_id) != ChannelPolicy.SILENT
+        return self._room_policy(room_id) != RoomPolicy.SILENT
 
     async def _dispatch_message_posted(self, payload: MessagePostedPayload) -> None:
         # Respond-room gate first: read-only memberships must not cost a
@@ -1308,7 +1308,7 @@ class ChattoAdapter(BasePlatformAdapter):
         # below on one definition of "addressed", broadcast handles included.
         if room_kind != RoomKind.DM:
             policy = self._room_policy(room_id)
-            if policy == ChannelPolicy.REQUIRE_MENTION and not self._mentions_me(
+            if policy == RoomPolicy.REQUIRE_MENTION and not self._mentions_me(
                 message_body
             ):
                 logger.debug(
@@ -1324,7 +1324,7 @@ class ChattoAdapter(BasePlatformAdapter):
             # bot-mention test above, so a message naming us *and* someone
             # else still counts as ours.
             if (
-                policy == ChannelPolicy.OPEN
+                policy == RoomPolicy.OPEN
                 and not self._mentions_me(message_body)
                 and await self._mentions_someone_else(message_body)
             ):
@@ -1594,6 +1594,37 @@ class ChattoAdapter(BasePlatformAdapter):
             home_id,
         )
 
+    def _joined_room_label(self, room_id: str) -> str:
+        """One entry for the joined-rooms summary, with how-we-answer tags.
+
+        The tag names the room's participation mode at a glance: DMs answer
+        unconditionally, listed channels answer on mentions or every message,
+        and an unlisted channel is read-only.
+        """
+        name = self._room_names.get(room_id, room_id)
+        if self._room_kinds.get(room_id) == RoomKind.DM:
+            tags = "[dm]"
+        else:
+            policy = self._room_policy(room_id)
+            if policy == RoomPolicy.REQUIRE_MENTION:
+                tags = "[on-mention]"
+            elif policy == RoomPolicy.OPEN:
+                tags = "[every-message]"
+            else:
+                tags = "[read-only]"
+        if room_id in self._universal_room_ids:
+            tags += " [universal]"
+        return f"{name} ({room_id}) {tags}"
+
+    def _log_joined_rooms(self) -> None:
+        """Log the joined rooms and how the bot answers in each of them."""
+        labels = [self._joined_room_label(rid) for rid in self._joined_room_ids]
+        logger.info(
+            "Chatto WS: currently joined in %d room(s): %s",
+            len(labels),
+            ", ".join(labels),
+        )
+
     async def _refresh_rooms(self) -> None:
         """Refresh room list via ConnectRPC, join and seed any newly discovered rooms."""
         client = await self._get_chatto_client()
@@ -1642,44 +1673,29 @@ class ChattoAdapter(BasePlatformAdapter):
 
             self._warn_if_home_channel_unjoined(member_ids)
 
-            if not new_room_ids:
-                return
-
-            logger.info(
-                "Chatto WS: discovered %d new room(s): %s",
-                len(new_room_ids),
-                new_room_ids,
-            )
-
-            for rid in new_room_ids:
-                # Membership came straight from the directory scan
-                # (viewer_state.is_member); natively invited rooms need no
-                # JoinRoom call — same rule as _run_join.
-                if self._answers_in_room(rid):
-                    await self._seed_room(rid)
-                else:
-                    logger.info(
-                        "Chatto WS: %s (%s) is on neither mention list -"
-                        " joined read-only",
-                        self._room_names.get(rid, rid),
-                        rid,
-                    )
-                self._joined_room_ids.append(rid)
+            if new_room_ids:
+                logger.info(
+                    "Chatto WS: discovered %d new room(s): %s",
+                    len(new_room_ids),
+                    new_room_ids,
+                )
 
-            joined_room_names: list[str] = []
-            for rid in self._joined_room_ids:
-                name = self._room_names[rid]
-                if not self._answers_in_room(rid):
-                    name += " [read-only]"
-                if rid in self._universal_room_ids:
-                    name += " [universal]"
-                joined_room_names.append(name + " (" + rid + ")")
+                for rid in new_room_ids:
+                    # Membership came straight from the directory scan
+                    # (viewer_state.is_member); natively invited rooms need no
+                    # JoinRoom call — same rule as _run_join.
+                    if self._answers_in_room(rid):
+                        await self._seed_room(rid)
+                    else:
+                        logger.info(
+                            "Chatto WS: %s (%s) is on neither mention list -"
+                            " joined read-only",
+                            self._room_names.get(rid, rid),
+                            rid,
+                        )
+                    self._joined_room_ids.append(rid)
 
-            logger.info(
-                "Chatto WS: currently joined in %d room(s): %s",
-                len(self._joined_room_ids),
-                ", ".join(joined_room_names),
-            )
+            self._log_joined_rooms()
 
         except Exception:
             logger.warning("Chatto WS: _refresh_rooms failed", exc_info=True)
@@ -1687,6 +1703,10 @@ class ChattoAdapter(BasePlatformAdapter):
         # ------------------------------------------------------------------ #
         # Read state & notification dismissal (best-effort, Chatto-unique)
         # ------------------------------------------------------------------ #
+        # Deliberately outside the try block above: this sweep runs on every
+        # refresh — including refreshes that discovered no new rooms. The
+        # old early return starved it to "only when something changed",
+        # leaving silent rooms unmarked for whole connection lifetimes.
         # Best-effort: mark all joined rooms as read (room_id may be undefined here)
         for _rid in list(self._joined_room_ids):
             try:
@@ -2817,9 +2837,9 @@ 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)
+    require_rooms = set(chatto_config.require_mention_rooms.value)
+    optional_rooms = set(chatto_config.optional_mention_rooms.value)
+    overlap = sorted(require_rooms & optional_rooms)
     if overlap:
         logger.error(
             "Chatto: Conflicting configuration. Room(s) %s are on both "

+ 30 - 11
test_adapter.py

@@ -65,9 +65,9 @@ from gateway.platforms.base import (
 )
 
 from adapter import (
-    ChannelPolicy,
     ChattoAdapter,
     HermesChatType,
+    RoomPolicy,
     _capabilities,
     chat_type_for_room_kind,
     hermes_env_enablement_fn,
@@ -1253,10 +1253,10 @@ class TestForeignMention:
         adapter.add_reaction.assert_not_awaited()
 
 
-# -- Channel mention policies --
+# -- Room mention policies --
 
 
-class TestChannelPolicies:
+class TestRoomPolicies:
     """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."""
@@ -1349,7 +1349,7 @@ class TestChannelPolicies:
         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
+        assert adapter._room_policy("room-1") is RoomPolicy.OPEN
 
     async def test_dm_is_answered_without_a_mention(self):
         """The point of the room_kind check: mention gating must not mute DMs."""
@@ -2083,21 +2083,40 @@ class TestSilentRoomRefresh:
         assert sorted(adapter._joined_room_ids) == ["news-1", "team-1"]
         adapter._seed_room.assert_awaited_once_with("team-1")
 
-    async def test_join_log_tags_read_only_and_universal(self, caplog):
+    async def test_join_log_tags_policies_kinds_and_universal(self, caplog):
         adapter = self._adapter(["team-1"])
+        adapter.chatto_config.require_mention_rooms.value = ["dep-1"]
         news = Room(id="news-1", name="News", kind=RoomKind.CHANNEL, universal=True)
-        team = Room(id="team-1", name="Team", kind=RoomKind.CHANNEL, universal=False)
-        adapter._chatto_client.list_rooms = AsyncMock(
-            return_value=[_make_room_state(news, True), _make_room_state(team, True)]
-        )
+        states = [
+            _make_room_state(news, True),
+            _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True),
+            _make_room_state(_make_room("dep-1", "Deploy", RoomKind.CHANNEL), True),
+            _make_room_state(_make_room("dm-1", "Paula", RoomKind.DM), True),
+        ]
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=states)
 
         with caplog.at_level("INFO"):
             await adapter._refresh_rooms()
 
         joined = [m for m in caplog.messages if "currently joined" in m]
         assert joined, "expected the joined-rooms summary log line"
-        assert "[read-only]" in joined[-1]
-        assert "[universal]" in joined[-1]
+        summary = joined[-1]
+        assert "News (news-1) [read-only] [universal]" in summary
+        assert "Team (team-1)" in summary and "[every-message]" in summary
+        assert "Deploy (dep-1) [on-mention]" in summary
+        assert "Paula (dm-1) [dm]" in summary
+
+    async def test_join_log_appears_on_every_refresh(self, caplog):
+        adapter = self._adapter(["team-1"])
+        team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[team])
+
+        with caplog.at_level("INFO"):
+            await adapter._refresh_rooms()
+            await adapter._refresh_rooms()
+
+        joined = [m for m in caplog.messages if "currently joined" in m]
+        assert len(joined) == 2
 
 
 # -- Constants --