浏览代码

Confirm an @-token is a real handle before falling silent

"Bitte schreibe ... per @-mention den Chatto-Nutzer nickk an" got a
dotted-line face: the pattern matched "-mention" and the bot concluded the
message was for someone else. Talking about a mention is not a mention.

Chatto carries no mention entities — mention_confirmation_token is
reserved in the message descriptor — so an @-token can only ever be a
candidate. Resolve each one against the member directory and treat it as
someone else only when a user actually holds it, caching hits and misses
alike. An unresolvable handle now means "answer normally": staying silent
on a false positive costs more than answering one.

The pattern also no longer starts on "-" and skips the domain half of an
e-mail address, so the common cases never reach a lookup at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul-Dieter Klumpp 1 周之前
父节点
当前提交
a36c88aca0
共有 3 个文件被更改,包括 78 次插入8 次删除
  1. 32 4
      adapter.py
  2. 6 4
      platform_config.py
  3. 40 0
      test_adapter.py

+ 32 - 4
adapter.py

@@ -249,6 +249,8 @@ class ChattoAdapter(BasePlatformAdapter):
 
         # Member directory cache: user_id -> user info dict
         self._user_cache: Dict[str, User] = {}
+        # Handle -> does a user hold it. Cached both ways; see _mentions_someone_else.
+        self._known_handles: Dict[str, bool] = {}
 
         # Chattolib client cache and lock for async access.
         self._chatto_client: Optional[ChattoClient] = None
@@ -502,18 +504,44 @@ class ChattoAdapter(BasePlatformAdapter):
                 return True
         return False
 
-    def _mentions_someone_else(self, body: str) -> bool:
+    async def _handle_belongs_to_a_user(self, handle: str) -> bool:
+        """Whether ``handle`` is the login of a real Chatto user.
+
+        The API carries no mention entities — ``mention_confirmation_token`` is
+        reserved in the message descriptor — so an @-token is only a candidate
+        until the directory confirms it. Results are cached both ways, since
+        the same handles recur and a miss is as reusable as a hit.
+        """
+        known = self._known_handles.get(handle)
+        if known is not None:
+            return known
+        try:
+            client = await self._require_client()
+            member = await client.get_user(login=handle)
+        except Exception as exc:
+            # Unresolved means "not confirmed", so the message goes through.
+            logger.debug("Chatto: could not resolve handle @%s: %s", handle, exc)
+            return False
+        exists = member is not None and member.user is not None
+        self._known_handles[handle] = exists
+        return exists
+
+    async 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.
+        the bot included, so they do not count as someone else. A handle no
+        user holds is not a mention at all: someone writing *about* mentioning
+        ("per @-mention", "@nonexistent") is talking to us, and staying silent
+        on a false positive is worse than answering one.
         """
         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
+            if await self._handle_belongs_to_a_user(handle):
+                return True
         return False
 
     def _check_auth(self, user: User) -> bool:
@@ -706,7 +734,7 @@ class ChattoAdapter(BasePlatformAdapter):
             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)
+            and await self._mentions_someone_else(message_body)
         ):
             logger.info("Chatto: message addresses someone else, acknowledging only")
             if self.chatto_config.reactions.value:

+ 6 - 4
platform_config.py

@@ -63,10 +63,12 @@ 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_.\-]+)")
+    # A candidate @-handle, not a confirmed one — every match is resolved
+    # against the member directory before it counts. Must start alphanumeric,
+    # so prose like "per @-mention" yields nothing to look up.
+    # The lookbehind keeps the domain half of an e-mail address from becoming a
+    # candidate and costing a directory lookup.
+    MENTION_RE = re.compile(r"(?<![\w.])@([A-Za-z0-9_][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".

+ 40 - 0
test_adapter.py

@@ -44,6 +44,7 @@ from adapter import (
 from chattolib.realtime_types import ReactionPayload
 from chattolib.types import (
     Asset,
+    DirectoryMember,
     AssetUpload,
     AssetUrl,
     Message,
@@ -872,6 +873,11 @@ class TestForeignMention:
         adapter.add_reaction = AsyncMock(return_value=True)
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["dm-1"] = RoomKind.DM
+        # The directory knows bob and nobody else.
+        adapter._chatto_client.get_user = AsyncMock(side_effect=lambda **kw: (
+            DirectoryMember(user=_make_user("user-2", "bob"))
+            if kw.get("login") == "bob" else None
+        ))
         return adapter
 
     async def _dispatch(self, adapter, body, room_id="room-1"):
@@ -900,6 +906,40 @@ class TestForeignMention:
             await self._dispatch(adapter, body)
             adapter.handle_message.assert_called_once()
 
+    async def test_talking_about_mentions_is_not_a_mention(self):
+        """Verbatim from the field: the instruction to send a mention later must
+        not read as a mention now. '@-mention' is not a handle anyone holds."""
+        adapter = self._adapter()
+        await self._dispatch(
+            adapter,
+            'Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den '
+            'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
+        )
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_handle_nobody_holds_is_not_a_mention(self):
+        """A plausible-looking @token that resolves to no user is not someone
+        else — answering a false positive beats falling silent on one."""
+        adapter = self._adapter()
+        adapter._chatto_client.get_user = AsyncMock(return_value=None)
+        await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_a_resolvable_handle_is_looked_up_once(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "@bob ping")
+        await self._dispatch(adapter, "@bob again")
+        assert adapter._chatto_client.get_user.await_count == 1
+        assert adapter.handle_message.await_count == 0
+
+    async def test_an_email_address_is_not_a_mention(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "schreib an bob@example.com")
+        adapter.handle_message.assert_called_once()
+        adapter._chatto_client.get_user.assert_not_awaited()
+
     async def test_plain_message_is_unaffected(self):
         adapter = self._adapter()
         await self._dispatch(adapter, "how do I reset the cache?")