Ver Fonte

Stay out of conversations aimed at someone else

With require_mention off the bot reads every message in a channel,
including ones plainly addressed to a named colleague — and answered
them. Acknowledge those with 🫥 and skip the dispatch, so it is visible
that the message was seen but deliberately not taken.

The check runs after the bot-mention test, so a message naming us
alongside someone else is still ours. Broadcast handles (@here, @channel,
@everyone) address the room including the bot and do not trigger it, DMs
are exempt the same way they are exempt from require_mention, and with
reactions switched off the bot simply stays silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul-Dieter Klumpp há 1 semana atrás
pai
commit
78fbe4e7e5
3 ficheiros alterados com 126 adições e 0 exclusões
  1. 39 0
      adapter.py
  2. 11 0
      platform_config.py
  3. 76 0
      test_adapter.py

+ 39 - 0
adapter.py

@@ -493,6 +493,29 @@ class ChattoAdapter(BasePlatformAdapter):
     # WebSocket Realtime Transport
     # ------------------------------------------------------------------ #
 
+    def _mentions_me(self, body: str) -> bool:
+        """Whether the message @-mentions this bot, by login or display name."""
+        if not self.me:
+            return False
+        for handle in (self.me.login, self.me.display_name):
+            if handle and f"@{handle}" in body:
+                return True
+        return False
+
+    def _mentions_someone_else(self, body: str) -> bool:
+        """Whether the message @-mentions a person who is not this bot.
+
+        Broadcast handles are not a person — they address everyone present,
+        the bot included, so they do not count as someone else.
+        """
+        for handle in ChattoConstants.MENTION_RE.findall(body):
+            if handle.lower() in ChattoConstants.BROADCAST_MENTIONS:
+                continue
+            if self.me and handle in (self.me.login, self.me.display_name):
+                continue
+            return True
+        return False
+
     def _check_auth(self, user: User) -> bool:
         """We roll our own auth-systen on the against the chatto_config'ured allowed_users etc.
            because.. Hermes authz_mixin.py IS NOT SANE.
@@ -674,6 +697,22 @@ class ChattoAdapter(BasePlatformAdapter):
               
         logger.info("mentioned: %s", mentioned)
 
+        # 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 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
+
         # Thread anchoring — if the incoming message is inside a thread, we
         # keep that thread by default; otherwise leave thread_id unset so
         # replies land at the root.

+ 11 - 0
platform_config.py

@@ -5,6 +5,7 @@ Chatto Platform Config
 """
 
 import os
+import re
 
 # Put the vendored dependencies for THIS platform on sys.path before importing
 # anything from chattolib. Imported relatively as part of the plugin package and
@@ -62,6 +63,15 @@ class ChattoConstants:
     WS_RECONNECT_INITIAL_BACKOFF = 1.0
     WS_RECONNECT_MAX_BACKOFF = 30.0
 
+    # An @-handle. Deliberately narrow: display names may carry spaces, but a
+    # message naming the bot that way is already matched by substring before
+    # this pattern is consulted.
+    MENTION_RE = re.compile(r"@([A-Za-z0-9_.\-]+)")
+
+    # Handles that address the room rather than a person — the bot is one of
+    # the addressees, so these do not mean "this is for someone else".
+    BROADCAST_MENTIONS = frozenset({"here", "channel", "everyone", "all", "room"})
+
     # Presence is a TTL the server lets lapse — chattolib refuses to set OFFLINE
     # at all ("stop refreshing to go offline"), so staying online means
     # re-announcing ONLINE on this interval for as long as we are connected.
@@ -81,6 +91,7 @@ class ChattoConstants:
         "✅": "white_check_mark",
         "❌": "x",
         "👀": "eyes",
+        "🫥": "dotted_line_face",
         "🎉": "tada",
         "😂": "joy",
         "🚀": "rocket",

+ 76 - 0
test_adapter.py

@@ -205,6 +205,9 @@ class TestEmojiShortcode:
         assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
         assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
         assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
+        # Sent when a message addresses someone else — without the mapping the
+        # raw emoji would go out as a shortcode and the server would reject it.
+        assert _EMOJI_TO_SHORTCODE.get("🫥") == "dotted_line_face"
 
 
 # -- Adapter instantiation and properties --
@@ -849,6 +852,79 @@ class TestPresence:
         client.update_presence.assert_not_called()
 
 
+# -- Mentions of other people --
+
+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."""
+
+    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.reactions.value = True
+        for key, value in overrides.items():
+            getattr(adapter.chatto_config, key).value = value
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        adapter.handle_message = AsyncMock()
+        adapter.add_reaction = AsyncMock(return_value=True)
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        adapter._room_kinds["dm-1"] = RoomKind.DM
+        return adapter
+
+    async def _dispatch(self, adapter, body, room_id="room-1"):
+        payload = _make_posted_payload(room_id=room_id)
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(body=body, room_id=room_id))
+        await adapter._dispatch_message_posted(payload)
+
+    async def test_message_for_someone_else_is_only_acknowledged(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "@bob can you take a look?")
+        adapter.handle_message.assert_not_called()
+        adapter.add_reaction.assert_awaited_once()
+        assert adapter.add_reaction.await_args.args[2] == "🫥"
+
+    async def test_being_mentioned_alongside_someone_else_still_answers(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_broadcast_mentions_address_the_bot_too(self):
+        adapter = self._adapter()
+        for body in ("@here standup in 5", "@channel heads up", "@everyone hi"):
+            adapter.handle_message.reset_mock()
+            await self._dispatch(adapter, body)
+            adapter.handle_message.assert_called_once()
+
+    async def test_plain_message_is_unaffected(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "how do I reset the cache?")
+        adapter.handle_message.assert_called_once()
+
+    async def test_dms_are_answered_even_when_they_name_someone_else(self):
+        adapter = self._adapter()
+        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)
+        await self._dispatch(adapter, "@bob can you take a look?")
+        adapter.handle_message.assert_not_called()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_silence_holds_when_reactions_are_disabled(self):
+        adapter = self._adapter(reactions=False)
+        await self._dispatch(adapter, "@bob can you take a look?")
+        adapter.handle_message.assert_not_called()
+        adapter.add_reaction.assert_not_awaited()
+
+
 # -- require_mention --
 
 class TestRequireMention: