Procházet zdrojové kódy

Align mention detection with the Chatto web frontend (FDR-006)

- MENTION_RE mirrors upstream extraction: dots only as internal
  separators (a sentence-ending 'frag @bob.' resolves bob), leading
  hyphen yields nothing, lookbehind keeps e-mail domains out.
- BROADCAST_MENTIONS reduced to the only virtual handles Chatto
  defines: @all and @here. @channel/@everyone/@room never existed.
- Both gates now share one definition of 'addressed': the require_mention
  path uses _mentions_me() instead of its own substring loop, so @here/
  @all count there too, matching is case-insensitive everywhere, and a
  shared candidate extractor skips fenced code blocks and inline spans,
  like upstream.
- Directory cache keys on the lowercased handle; deviation documented:
  other users match by login only.

README, AGENTS.md behavioural contract and tests follow; new cases pin
trailing punctuation, case-insensitivity, code regions and broadcast
handling.
Paul Klumpp před 1 týdnem
rodič
revize
3cf3343521
5 změnil soubory, kde provedl 147 přidání a 45 odebrání
  1. 7 3
      AGENTS.md
  2. 4 2
      README.md
  3. 56 31
      adapter.py
  4. 11 6
      platform_config.py
  5. 69 3
      test_adapter.py

+ 7 - 3
AGENTS.md

@@ -69,9 +69,13 @@ not "edit these files to begin".
 changes must keep them, or consciously renegotiate the docs.**
 
 - **Mention gating covers channels only.** With `require_mention` enabled,
-  channel-kind rooms answer only messages @-mentioning the bot; group-kind
-  rooms and DMs answer regardless. In channels with the gate off, a message
-  aimed at someone else gets a 🫥 acknowledgement instead of an answer.
+  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
+  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

+ 4 - 2
README.md

@@ -13,7 +13,7 @@ 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 `@mentions`; group-kind rooms always respond. |
+| **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. |
 | **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`. |
@@ -284,7 +284,9 @@ The list gates inbound replies only. DMs always get a response so `/join` stays
 
 ### Mention Detection
 
-With `CHATTO_REQUIRE_MENTION=true`, Hermes answers channel-kind rooms only when the message contains an `@mention` of the bot's login or display name. 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.
+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.
+
+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".
 
 ### Editing Your Messages
 

+ 56 - 31
adapter.py

@@ -28,6 +28,7 @@ import hashlib
 import logging
 import mimetypes
 import os
+import re
 import tempfile
 from datetime import UTC, datetime
 from enum import StrEnum
@@ -568,32 +569,60 @@ class ChattoAdapter(BasePlatformAdapter):
     # WebSocket Realtime Transport
     # ------------------------------------------------------------------ #
 
+    def _own_handles(self) -> set[str]:
+        """The handles that address this bot, lowercased for comparison.
+
+        Chatto resolves mentions case-insensitively (FDR-006), so ``@Hermes_Bot``
+        and ``@hermes_bot`` are the same handle everywhere in our gates.
+        """
+        if not self.me:
+            return set()
+        return {
+            handle.lower() for handle in (self.me.login, self.me.display_name) if handle
+        }
+
+    def _mention_candidates(self, body: str) -> list[str]:
+        """Candidate @-handles in a message body, in order of appearance.
+
+        Mirrors the Chatto web frontend's extraction (apps/frontend/src/lib/
+        mentions.ts upstream): candidates come from ``ChattoConstants.
+        MENTION_RE`` outside code regions. Mentions inside fenced code blocks
+        and inline code spans do not resolve upstream either, so a ``@bob``
+        quoted in a snippet must not gate our behaviour.
+        """
+        without_fences = re.sub(r"(?s)(```|~~~).*?(\1|$)", " ", body)
+        without_code = re.sub(r"`[^`\n]*`", " ", without_fences)
+        return ChattoConstants.MENTION_RE.findall(without_code)
+
     def _mentions_me(self, body: str) -> bool:
         """Whether the message addresses this bot.
 
         By login, by display name, or by a broadcast handle — ``@here`` speaks
         to everyone present and the bot is one of them, so naming a colleague
-        alongside it does not take the bot out of the audience.
+        alongside it does not take the bot out of the audience. Matching is
+        case-insensitive, like every mention resolution in Chatto.
         """
-        if not self.me:
-            return False
-        for handle in (self.me.login, self.me.display_name):
-            if handle and f"@{handle}" in body:
+        own = self._own_handles()
+        for handle in self._mention_candidates(body):
+            lowered = handle.lower()
+            if lowered in own or lowered in ChattoConstants.BROADCAST_MENTIONS:
                 return True
-        return any(
-            handle.lower() in ChattoConstants.BROADCAST_MENTIONS
-            for handle in ChattoConstants.MENTION_RE.findall(body)
-        )
+        return False
 
     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.
+        until the directory confirms it. Results are cached both ways under the
+        lowercased handle (the mention namespace is case-insensitive per
+        FDR-006), since the same handles recur and a miss is as reusable as a
+        hit. Only logins are looked up: matching another user's display name,
+        as the web frontend does against its room member list, has no directory
+        equivalent here.
         """
-        known = self._known_handles.get(handle)
+        cache_key = handle.lower()
+        known = self._known_handles.get(cache_key)
         if known is not None:
             return known
         client = await self._get_chatto_client()
@@ -606,7 +635,7 @@ class ChattoAdapter(BasePlatformAdapter):
             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
+        self._known_handles[cache_key] = exists
         return exists
 
     async def _mentions_someone_else(self, body: str) -> bool:
@@ -618,10 +647,12 @@ class ChattoAdapter(BasePlatformAdapter):
         ("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:
+        own = self._own_handles()
+        for handle in self._mention_candidates(body):
+            lowered = handle.lower()
+            if lowered in ChattoConstants.BROADCAST_MENTIONS:
                 continue
-            if self.me and handle in (self.me.login, self.me.display_name):
+            if lowered in own:
                 continue
             if await self._handle_belongs_to_a_user(handle):
                 return True
@@ -1174,25 +1205,19 @@ class ChattoAdapter(BasePlatformAdapter):
 
         # 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.
-        mentioned = False
+        # addressed at it — so DMs are always answered, mention or not. 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 self.me
+            and not self._mentions_me(message_body)
         ):
-            if self.me.login and not mentioned:
-                mentioned = bool(f"@{self.me.login}" in message_body)
-            if self.me.display_name and not mentioned:
-                mentioned = bool(f"@{self.me.display_name}" in message_body)
-            if mentioned is False:
-                logger.debug(
-                    "Discarding message. Bot was not mentionend but require_mention is '%s'.",
-                    self.chatto_config.require_mention.value,
-                )
-                return None
-
-        logger.debug("mentioned: %s", mentioned)
+            logger.debug(
+                "Discarding message. Bot was not mentioned but require_mention is '%s'.",
+                self.chatto_config.require_mention.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

+ 11 - 6
platform_config.py

@@ -65,15 +65,20 @@ class ChattoConstants:
     WS_RECONNECT_MAX_BACKOFF = 30.0
 
     # 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_.\-]*)")
+    # against the member directory before it counts. Pattern mirrors the Chatto
+    # web frontend's mention extraction: dots are internal separators only, so
+    # a sentence-ending "frag @bob." yields the handle "bob", and a leading
+    # hyphen ("per @-mention") yields nothing to look up. The lookbehind keeps
+    # the domain half of an e-mail address from becoming a candidate.
+    MENTION_RE = re.compile(
+        r"(?<![\w.])@([A-Za-z0-9_](?:[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".
-    BROADCAST_MENTIONS = frozenset({"here", "channel", "everyone", "all", "room"})
+    # Chatto defines exactly these two virtual handles (FDR-006); anything
+    # else must resolve to a real user in the directory.
+    BROADCAST_MENTIONS = frozenset({"all", "here"})
 
     # Presence is a TTL the server lets lapse — chattolib refuses to set OFFLINE
     # at all ("stop refreshing to go offline"), so staying online means

+ 69 - 3
test_adapter.py

@@ -952,11 +952,13 @@ 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.
+        # The directory knows bob and nobody else. Resolution is
+        # case-insensitive, as Chatto's single mention namespace guarantees
+        # (FDR-006) — the fake models that at its own boundary.
         adapter._chatto_client.get_user = AsyncMock(
             side_effect=lambda **kw: (
                 DirectoryMember(user=_make_user("user-2", "bob"))
-                if kw.get("login") == "bob"
+                if str(kw.get("login", "")).lower() == "bob"
                 else None
             )
         )
@@ -1018,11 +1020,67 @@ class TestForeignMention:
 
     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"):
+        # @here/@all are Chatto's virtual handles (FDR-006) — the bot is in
+        # their audience. @channel/@everyone do not exist as handles; they are
+        # unknown candidates, and an unknown handle is not someone else either,
+        # so the message still gets answered rather than acknowledged away.
+        for body in ("@here standup in 5", "@all heads up", "@everyone hi"):
             adapter.handle_message.reset_mock()
             await self._dispatch(adapter, body)
             adapter.handle_message.assert_called_once()
 
+    async def test_broadcast_mention_alone_is_not_someone_else(self):
+        """A pure @here must not be read as a message aimed at another person."""
+        adapter = self._adapter()
+        await self._dispatch(adapter, "@here standup in 5")
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_trailing_punctuation_still_resolves_the_handle(self):
+        """'frag @bob.' must resolve bob — a sentence-ending dot is not part of
+        the handle, mirroring the web frontend's extraction."""
+        adapter = self._adapter()
+        await self._dispatch(adapter, "frag mal @bob.")
+        adapter.handle_message.assert_not_called()
+        adapter.add_reaction.assert_awaited_once()
+
+    async def test_foreign_mention_matching_is_case_insensitive(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()
+
+    async def test_own_mention_matching_is_case_insensitive(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "@HERMES_BOT ping")
+        adapter.handle_message.assert_called_once()
+
+    async def test_mentions_inside_a_fenced_block_are_ignored(self):
+        """Upstream does not resolve mentions in code blocks; quoting '@bob'
+        there must not silence us."""
+        adapter = self._adapter()
+        await self._dispatch(adapter, "so sieht ein ping aus:\n```\n@bob\n```")
+        adapter.handle_message.assert_called_once()
+
+    async def test_mentions_inside_an_inline_span_are_ignored(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "schreib `@bob` mal direkt an")
+        adapter.handle_message.assert_called_once()
+
+    async def test_an_unterminated_fence_shields_the_rest(self):
+        """An unclosed fence runs to the end of the text, like upstream."""
+        adapter = self._adapter()
+        await self._dispatch(adapter, "beispiel:\n``` bash\n@bob schaut")
+        adapter.handle_message.assert_called_once()
+
+    async def test_broadcast_handles_are_shared_across_cases_in_the_cache(self):
+        """The directory cache keys on the lowercased handle: one lookup
+        serves '@bob' and '@BOB' alike."""
+        adapter = self._adapter()
+        await self._dispatch(adapter, "@BOB ping")
+        await self._dispatch(adapter, "@bob again")
+        assert adapter._chatto_client.get_user.await_count == 1
+
     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."""
@@ -1116,6 +1174,14 @@ class TestRequireMention:
         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):
+        """@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._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_dm_is_answered_without_a_mention(self):
         """The point of the room_kind check: require_mention must not mute DMs."""
         adapter = self._adapter()