Parcourir la source

Read mention targets off the event instead of re-deriving them from text

Chatto resolves mentions when a message is created and reports the targets
on the message_posted event. includes_viewer is the server's own answer to
"did this reach me", fixed at post time. Reading it replaces a regex that had
to re-derive what the server already decided, and the web frontend reads the
same field the same way (chatto-client's addressedMessage does
message.mentions.some(m => m.includesViewer)) — so this is the platform's
answer, not a second opinion.

What that fixes on the posted path:

- A @name inside a URL, blockquote, link label or link destination no longer
  counts. The regex read all four; Chatto resolves none of them.
- A @name nobody holds is already handled — the server omits targets it
  resolved to nobody, so "mentioning a non-member leaves the @name as plain
  text" needs no rule of ours, and _handle_belongs_to_a_user's fail-open
  guesswork stops mattering there.
- Role mentions work. @moderator reaches a bot holding the moderator role;
  the regex had no handle to match it against.

Mentions are now one frozen _Mentions both gates read, so "addressed" keeps a
single definition while the two paths differ only in what they were told.

The edited path still reads the body, deliberately: a message_edited event
carries no targets and the hydrated Message has no mention field, so there is
nothing to read. We re-gate anyway because adding a forgotten @mention by
editing is a promise this plugin makes — Chatto treats mentions as post-time
facts and its frontend never re-decides an edit, but Chatto also has no turn
for an edit to open. The cost is that the fallback inherits the regex's blind
spots, on that path only; README says so, CONCEPTS.md says why.

Fixes the ordering the derivation depends on: resolving a handle reads the
member cache, which the roster fetch fills, so the derivation happens after
it rather than at the call site — read earlier, every handle looks unknown.

TestForeignMention now asserts on _Mentions directly instead of driving a
dispatch whose mention source it no longer models; TestServerResolvedMentions
covers the posted path with real MessageMention protos, so a renamed proto
field fails the suite rather than passing silently.
Paul Klumpp il y a 2 semaines
Parent
commit
94a103b8c0
5 fichiers modifiés avec 486 ajouts et 136 suppressions
  1. 18 4
      AGENTS.md
  2. 46 0
      CONCEPTS.md
  3. 10 1
      README.md
  4. 103 59
      adapter.py
  5. 309 72
      test_adapter.py

+ 18 - 4
AGENTS.md

@@ -212,9 +212,16 @@ changes must keep them, or consciously renegotiate the docs.**
   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. DMs answer regardless so `/join` stays reachable;
+  read one resolved `_Mentions`, so "addressed" is defined once, and the two
+  paths that fill it differ only in what the server told us — never in what
+  counts. A posted message carries the targets Chatto resolved when it was
+  created (`MessagePostedEvent.mentions`, whose `includes_viewer` is the
+  server's own answer to "did this reach me"), so the posted path never
+  re-derives anything and role mentions work; an edit carries none, so it
+  falls back to reading the body, case-insensitive outside code regions per
+  the web frontend's extraction. Broadcast and role targets arrive
+  unexpanded, so neither path needs a roster to recognise them. DMs answer
+  regardless so `/join` stays reachable;
   `hermes_validate_config` rejects listing a room on both lists. The plugin
   tools obey the same lists: `chatto_send_room` refuses a channel on neither
   (a DM room is never gated), because a tool that posted there would break the
@@ -297,7 +304,14 @@ changes must keep them, or consciously renegotiate the docs.**
   follow-up is rewritten in place; a never-dispatched message (a forgotten
   @mention, say) gets a fresh turn if the gates pass now; an already-answered
   message stays answered — the `_dispatched_ids` ring exists so edits cannot
-  re-animate old conversations. Edits older than `CHATTO_EDIT_WINDOW` seconds
+  re-animate old conversations. Re-gating here is a deliberate departure from
+  Chatto, which treats mentions as post-time facts and never re-resolves them
+  on edit (FDR-006 decision 4) — and from the web frontend, whose store
+  reconciles an edited timeline without re-deciding who it addressed. We
+  re-gate because the edit is meant to reach the agent, and Chatto has no turn
+  for an edit to open. The price is that the body-derived fallback is a regex
+  reading, not a resolution: a `@bob` inside a URL or blockquote reads as a
+  mention on this path only. Edits older than `CHATTO_EDIT_WINDOW` seconds
   (default 300) are dropped, as are edited DM-command texts (`/join`, `/leave`
   ran once and must not run again); `CHATTO_EDIT_DISPATCH=false` turns the
   whole behavior off. Own edit echoes (the streaming path) are filtered by

+ 46 - 0
CONCEPTS.md

@@ -153,6 +153,52 @@ later call in a properly scoped context would keep being served the degraded
 configuration long after its scope came back — a failure that persists exactly as
 long as nobody restarts the gateway.
 
+## Mentions: one decision, two sources
+
+`_Mentions` answers one question — did this address us, or somebody else — and both
+admission gates read only that. The interesting part is where the answer comes from,
+because the two inbound paths are not equally informed.
+
+**The posted path asks the server.** Chatto resolves mentions when a message is
+created and reports the targets on the event (`MessagePostedEvent.mentions`).
+`includes_viewer` is the server's own answer to "did this target include me", fixed
+at post time and unaffected by membership since. Three properties make this better
+than anything we could compute:
+
+- Broadcast and role targets arrive **unexpanded** — one `all`/`here`/`role` entry,
+  never one per recipient — so recognising them needs no room roster at all.
+- Targets that resolved to nobody are **omitted** rather than reported as failures.
+  "Mentioning a user who isn't a room member leaves the `@name` as plain text" is
+  therefore not a rule we implement; it is what an empty list already means.
+- Roles work, which the previous regex could not express: `@moderator` reaches a
+  bot that holds the `moderator` role, and there is no handle to match it against.
+
+The web frontend does the same thing in `packages/chatto-client`
+(`addressedMessage`: `message.mentions.some(m => m.includesViewer)`), so this is
+the platform's own answer rather than a second opinion.
+
+**The edited path reads the text.** `MessageEditedEvent` carries only `room_id`
+and `message_event_id`, and the hydrated `Message` has no mention field — the
+mention resolution was deliberately kept out of the message resource. There is no
+payload to read, so the fallback re-derives candidates from the body with the same
+regex as before.
+
+**Why re-gate at all, when Chatto does not?** FDR-006 decision 4 states that
+mentions are post-time facts and that editing never re-resolves them. The frontend
+honours that by treating an edit as a timeline reconciliation — `store.ts` refreshes
+the window and returns, and only `messagePosted` feeds `addressedMessage`. We
+deliberately do not: a user who types a message, forgets the `@mention`, and adds it
+by editing expects an answer, and that is a promise this plugin makes. Chatto's rule
+is uncontroversial in a client that has no turn for an edit to open.
+
+**What that costs.** The fallback is a text reading, not a resolution, so it inherits
+the regex's blind spots: a `@bob` inside a URL, a blockquote, a link label or a link
+destination reads as a mention. The same text posted rather than edited reaches the
+server path and is classified correctly. Two paths that answer the same question
+differently is exactly the drift risk this design usually avoids; the containment is
+that they converge on one type, and the only place that fills it differently is the
+one place where no better information exists.
+
 ## One send path, two callers
 
 A user cannot tell whether the agent replied on its own or through a tool, so the

+ 10 - 1
README.md

@@ -317,7 +317,16 @@ The lists gate inbound replies only. DMs always get a response so `/join` stays
 
 ### 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".
+For a newly posted message, Chatto itself decides who is mentioned and Hermes reads that answer. So the usual cases work without any guessing on our side:
+
+- A `@name` that is not a room member does not count — Hermes answers rather than staying silent.
+- A `@name` inside a link, a URL, a blockquote or a code block does not count.
+- A `@role` reaches Hermes when the bot holds that role.
+- `@all` and `@here` reach every member of the room, the bot included.
+
+An `@name` nobody holds is treated as plain text.
+
+One exception: **edited** messages are read from their text rather than from Chatto's decision, because an edit carries no mention information. There, a `@name` inside a URL, a blockquote or a code block can be mistaken for a real mention. If you edit a message to add a mention, the mention itself is found reliably; the imperfect cases are the ones where a mention is only quoted.
 
 ### Editing Your Messages
 

+ 103 - 59
adapter.py

@@ -32,7 +32,7 @@ import os
 import re
 import tempfile
 from collections import deque
-from collections.abc import AsyncIterator, Callable
+from collections.abc import AsyncIterator, Callable, Iterable
 from contextlib import asynccontextmanager
 from dataclasses import dataclass, field
 from datetime import UTC, datetime
@@ -70,6 +70,7 @@ try:
         realtime_pb2,
     )
     from chattolib._pb.chatto.realtime.v1.events_pb2 import (
+        MessageMention,  # type: ignore[import-untyped]
         MessagePostedEvent,  # type: ignore[import-untyped]
     )
     from chattolib.client import (
@@ -276,6 +277,52 @@ _PRESENCE_LABELS = {
 }
 
 
+@dataclass(frozen=True)
+class _Mentions:
+    """Who one inbound message addresses.
+
+    Both admission gates read this and nothing else, so "addressed" has a
+    single definition. What fills it differs by path, not what counts:
+
+    - :meth:`from_payload` reads the targets the server resolved when the
+      message was posted. It is authoritative and the only source the posted
+      path uses.
+    - ``ChattoAdapter._mentions_from_body`` re-derives them from the text,
+      because an edit event carries no targets — see
+      ``_dispatch_message_edited``.
+
+    Broadcast and role targets arrive unexpanded (one ``all``/``here``/``role``
+    entry, never one per recipient), which is why the payload path needs no
+    room roster to answer "was this meant for me".
+    """
+
+    me: bool = False
+    someone_else: bool = False
+
+    @classmethod
+    def from_payload(
+        cls, mentions: Iterable[MessageMention], my_user_id: str
+    ) -> _Mentions:
+        """Read the server-resolved targets of a ``message_posted`` event.
+
+        ``includes_viewer`` is the server's own answer to "did this target
+        include me", decided when the message was posted and unaffected by
+        membership since. A direct target naming somebody else is the only
+        kind that means "not for us": broadcasts and roles address the room,
+        and we are in it.
+        """
+        me = False
+        someone_else = False
+        for mention in mentions:
+            if mention.includes_viewer:
+                me = True
+            elif mention.WhichOneof("cause") == "direct" and (
+                mention.direct.user_id != my_user_id
+            ):
+                someone_else = True
+        return cls(me=me, someone_else=someone_else)
+
+
 @dataclass
 class _RoomRoster:
     """One announced room's roster projection.
@@ -696,23 +743,35 @@ class ChattoAdapter(BasePlatformAdapter):
         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. Matching is
-        case-insensitive, like every mention resolution in Chatto.
+    def _mentions_from_body(self, body: str) -> _Mentions:
+        """Re-derive the addressed parties from message text.
+
+        Needed only for edited messages: an edit event carries no resolved
+        targets, and re-gating on the new text is a promise we make on purpose
+        (a mention added after posting still reaches the agent) — Chatto itself
+        would not re-resolve, but it also has no turn for an edit to open. So
+        this is the best available reading, not the authoritative one, and it
+        inherits the regex's blind spots: a ``@bob`` inside a URL or blockquote
+        reads as a mention here. The posted path does not have this problem —
+        it reads the server's own resolution.
+
+        Handles no user holds are not mentions: someone writing *about*
+        mentioning ("per @-mention", "@nonexistent") is talking to us, and
+        staying silent on a false positive is worse than answering one.
         """
         own = self._own_handles()
+        me = False
+        someone_else = False
         for handle in self._mention_candidates(body):
             lowered = handle.lower()
-            if lowered in own or lowered in ChattoConstants.BROADCAST_MENTIONS:
-                return True
-        return False
+            if lowered in ChattoConstants.BROADCAST_MENTIONS or lowered in own:
+                me = True
+            elif self._handle_belongs_to_a_user(lowered):
+                someone_else = True
+        return _Mentions(me=me, someone_else=someone_else)
 
-    async def _handle_belongs_to_a_user(self, handle: str) -> bool:
-        """Whether ``handle`` is the login of a real Chatto user.
+    def _handle_belongs_to_a_user(self, lowered_handle: str) -> bool:
+        """Whether ``lowered_handle`` is the login of a real Chatto user.
 
         Resolved strictly from local knowledge — the member cache the adapter
         already keeps — never from the admin directory: ``admin_get_member``
@@ -721,39 +780,12 @@ class ChattoAdapter(BasePlatformAdapter):
         fail-open let the bot answer every @colleague message. The cache is
         filled by the admission pipeline itself (room rosters are fetched for
         every channel turn), presence patching and the edited-message path.
-
-        An unknown handle is "not confirmed", not "confirmed absent": Chatto
-        carries no mention entities (``mention_confirmation_token`` is
-        reserved in the message descriptor), so a @-token stays a candidate
-        we cannot verify — and staying silent on a false positive is worse
-        than answering one.
         """
-        lowered = handle.lower()
         return any(
-            user is not None and (user.login or "").lower() == lowered
+            user is not None and (user.login or "").lower() == lowered_handle
             for user in self._user_cache.values()
         )
 
-    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. 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.
-        """
-        own = self._own_handles()
-        for handle in self._mention_candidates(body):
-            lowered = handle.lower()
-            if lowered in ChattoConstants.BROADCAST_MENTIONS:
-                continue
-            if lowered in own:
-                continue
-            if await self._handle_belongs_to_a_user(handle):
-                return True
-        return False
-
     def _check_auth(self, user: User) -> bool:
         """Whether this Chatto user may talk to the agent.
 
@@ -1748,6 +1780,9 @@ class ChattoAdapter(BasePlatformAdapter):
             message=message,
             source_message_id=event_id,
             thread_root_event_id=payload.thread_root_event_id or None,
+            mentions=_Mentions.from_payload(
+                payload.mentions, self.me.id if self.me else ""
+            ),
         )
         if event is None:
             return
@@ -1907,6 +1942,7 @@ class ChattoAdapter(BasePlatformAdapter):
         message: Message,
         source_message_id: str,
         thread_root_event_id: str | None,
+        mentions: _Mentions | None = None,
         allow_dm_commands: bool = True,
     ) -> MessageEvent | None:
         """Run one hydrated inbound message through the admission pipeline.
@@ -1921,6 +1957,13 @@ class ChattoAdapter(BasePlatformAdapter):
         member roster in ``channel_context`` (side effect: once the roster
         was fetched, the thread root is recorded as announced).
 
+        ``mentions`` is the already-resolved answer to "who does this
+        address"; passing None derives it from the message body instead, which
+        is what an edit does because its event carries no targets. The
+        derivation happens down here rather than at the call site because
+        resolving a handle needs the member cache, and the roster fetch below
+        is what fills it — read any earlier, every handle looks unknown.
+
         The caller owns dispatching: a non-None result still needs
         ``handle_message()``.
         """
@@ -1973,14 +2016,26 @@ class ChattoAdapter(BasePlatformAdapter):
         # 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 _answers_in_room). The
-        # shared _mentions_me gate keeps this path and the someone-else check
-        # below on one definition of "addressed", broadcast handles included.
+        # server never set counts as one too (see _answers_in_room). Both gates
+        # read one resolved `mentions`, so "addressed" is defined once and
+        # broadcast/role targets count for us on either path.
         if room_kind != RoomKind.DM:
             policy = self._room_policy(room_id)
-            if policy == RoomPolicy.REQUIRE_MENTION and not self._mentions_me(
-                message_body
-            ):
+
+            # Handle resolution on the body-derived path reads the member
+            # cache, which the roster projection fills. Fetch it before deriving
+            # anything, or every handle looks unknown — the same projection is
+            # then reused for this turn's channel_context further down, one
+            # fetch per room.
+            if policy == RoomPolicy.OPEN:
+                await self._roster_for_room(client, room_id=room_id)
+
+        if mentions is None:
+            mentions = self._mentions_from_body(message_body)
+
+        if room_kind != RoomKind.DM:
+            policy = self._room_policy(room_id)
+            if policy == RoomPolicy.REQUIRE_MENTION and not mentions.me:
                 logger.debug(
                     "Chatto: dropping unaddressed message from %s (policy %s)",
                     room_id,
@@ -1988,23 +2043,12 @@ class ChattoAdapter(BasePlatformAdapter):
                 )
                 return None
 
-            # Handle resolution reads the member cache, which the roster
-            # projection fills. Fetch it now so the someone-else check below
-            # sees the room's members — the same projection is reused for
-            # this turn's channel_context further down, one fetch per room.
-            if policy == RoomPolicy.OPEN:
-                await self._roster_for_room(client, room_id=room_id)
-
             # 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 == RoomPolicy.OPEN
-                and not self._mentions_me(message_body)
-                and await self._mentions_someone_else(message_body)
-            ):
+            if policy == RoomPolicy.OPEN and not mentions.me and mentions.someone_else:
                 logger.info(
                     "Chatto: message addresses someone else, acknowledging only"
                 )

+ 309 - 72
test_adapter.py

@@ -40,6 +40,7 @@ from vendor_path import setup_vendor_path
 
 setup_vendor_path()
 
+from chattolib._pb.chatto.realtime.v1.events_pb2 import MessageMention
 from chattolib.client import ChattoClient
 from chattolib.exceptions import ChattoError
 from chattolib.types import (
@@ -81,6 +82,7 @@ from adapter import (
     _handle_chatto_list_rooms,
     _handle_chatto_send_dm,
     _handle_chatto_send_room,
+    _Mentions,
     _room_answers_in,
     chat_type_for_room_kind,
     hermes_env_enablement_fn,
@@ -282,12 +284,60 @@ def _make_message(
     )
 
 
-def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
-    """A message_posted payload whose get_message() the caller stubs."""
+def _make_mentions(*specs, viewer=False):
+    """Real MessageMention protos, as the server resolves them.
+
+    Each spec is ``(cause, value)`` where cause is one of ``direct`` (value is
+    a user ID), ``role`` (a role name), or ``all``/``here`` (value is ignored).
+    These are the library's own types rather than mocks shaped like them, so a
+    renamed proto field fails here instead of passing silently.
+
+    ``viewer=True`` marks the target as having included this bot, which is the
+    server's own ``includes_viewer`` answer for a target that reached us.
+    """
+    mentions = []
+    for cause, value in specs:
+        mention = MessageMention()
+        if cause == "direct":
+            mention.direct.user_id = value
+        elif cause == "role":
+            mention.role.role_name = value
+        elif cause == "all":
+            mention.all.SetInParent()
+        elif cause == "here":
+            mention.here.SetInParent()
+        else:
+            raise ValueError(f"unknown mention cause: {cause}")
+        mention.includes_viewer = viewer
+        mentions.append(mention)
+    return mentions
+
+
+def _mentions_me(user_id="bot-user-id"):
+    """One direct target naming this bot — the server's "this reached you".
+
+    The default for tests that are about something else (thread context, media,
+    auth) and simply need a message that got through the mention gate.
+    """
+    mention = MessageMention()
+    mention.direct.user_id = user_id
+    mention.includes_viewer = True
+    return [mention]
+
+
+def _make_posted_payload(room_id="room-1", message_event_id="msg-1", mentions=None):
+    """A message_posted payload whose get_message() the caller stubs.
+
+    ``mentions`` are the server-resolved mention targets of the posted event —
+    the same protos the adapter reads off ``payload.mentions`` in production.
+    It defaults to a message that addressed this bot, because that is the case
+    most tests are about; pass ``[]`` for a message that addressed nobody.
+    """
     payload = MagicMock()
     payload.room_id = room_id
     payload.message_event_id = message_event_id
     payload.thread_root_event_id = None
+    payload.mentions = _mentions_me() if mentions is None else list(mentions)
     return payload
 
 
@@ -1245,10 +1295,175 @@ class TestPresence:
 # -- Mentions of other people --
 
 
+class TestServerResolvedMentions:
+    """The server resolves mentions when a message is posted and reports the
+    targets on the event, so the posted path never re-derives them from the
+    body. These tests state the resolved targets directly — that is the whole
+    point of trusting them."""
+
+    def _adapter(self, **overrides):
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = True
+        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
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        for member in (
+            DirectoryMember(user=adapter.me),
+            DirectoryMember(user=_make_user("user-1", "alice")),
+            DirectoryMember(user=_make_user("user-2", "bob")),
+        ):
+            adapter._user_cache[member.user.id] = member.user
+        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
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=(["user-1", "user-2"], Page())
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=[
+                DirectoryMember(user=_make_user("user-1", "alice")),
+                DirectoryMember(user=_make_user("user-2", "bob")),
+            ]
+        )
+        return adapter
+
+    async def _dispatch(
+        self, adapter, body, mentions=(), room_id="room-1", viewer=False
+    ):
+        """Dispatch with the server-resolved mention targets the test states.
+
+        ``mentions`` is a list of ``(cause, value)`` specs; ``viewer`` marks
+        every one of them as having included this bot.
+        """
+        payload = _make_posted_payload(
+            room_id=room_id, mentions=_make_mentions(*mentions, viewer=viewer)
+        )
+        _with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
+        await adapter._dispatch_message_posted(payload, payload.message_event_id)
+
+    async def test_broadcast_target_addresses_the_bot(self):
+        """@all arrives as one `all` target with includes_viewer set — the
+        server does not expand it to the room's members."""
+        adapter = self._adapter()
+        await self._dispatch(
+            adapter, "@all please read", mentions=[("all", None)], viewer=True
+        )
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_here_target_addresses_the_bot(self):
+        adapter = self._adapter()
+        await self._dispatch(
+            adapter, "@here look", mentions=[("here", None)], viewer=True
+        )
+        adapter.handle_message.assert_called_once()
+
+    async def test_role_target_addresses_the_bot(self):
+        """A role mention the bot belongs to reaches it. The body-derived path
+        had no notion of roles at all, so @moderator could never match."""
+        adapter = self._adapter()
+        await self._dispatch(
+            adapter,
+            "@moderator please look",
+            mentions=[("role", "moderator")],
+            viewer=True,
+        )
+        adapter.handle_message.assert_called_once()
+
+    async def test_a_direct_target_on_another_user_is_someone_else(self):
+        adapter = self._adapter()
+        await self._dispatch(
+            adapter, "@bob take a look", mentions=[("direct", "user-2")]
+        )
+        adapter.handle_message.assert_not_called()
+        adapter.add_reaction.assert_awaited_once()
+        assert adapter.add_reaction.await_args.args[2] == "🫥"
+
+    async def test_being_addressed_alongside_another_still_answers(self):
+        """A message naming us and a colleague is ours — the direct target for
+        somebody else does not cancel the target that included us."""
+        adapter = self._adapter()
+        await self._dispatch(
+            adapter,
+            "@bob and @hermes_bot, thoughts?",
+            mentions=[("direct", "user-2"), ("direct", "bot-user-id")],
+            viewer=True,
+        )
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_a_target_without_recipients_is_not_someone_else(self):
+        """The server omits targets it resolved to nobody ("mentioning a user
+        who isn't a room member leaves the @name as plain text"), so an empty
+        mentions list means the message addressed nobody we can see."""
+        adapter = self._adapter()
+        await self._dispatch(adapter, "@nonexistent_person bitte")
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_a_mention_inside_a_url_is_not_a_mention(self):
+        """The body-derived regex read @bob out of a URL; the server resolves
+        nothing there and reports no target."""
+        adapter = self._adapter()
+        await self._dispatch(adapter, "https://social.5f9.de/@bob/1")
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_a_mention_in_a_blockquote_is_not_a_mention(self):
+        adapter = self._adapter()
+        await self._dispatch(adapter, "> @bob take a look")
+        adapter.handle_message.assert_called_once()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_the_directory_is_never_consulted(self):
+        """Nothing about the posted path needs to resolve a handle locally any
+        more — the answer arrives already resolved."""
+        adapter = self._adapter()
+        adapter._chatto_client.admin_get_member = AsyncMock(return_value={})
+        await self._dispatch(adapter, "@bob ping", mentions=[("direct", "user-2")])
+        adapter._chatto_client.admin_get_member.assert_not_awaited()
+
+    async def test_require_list_room_gates_on_the_resolved_target(self):
+        adapter = self._adapter(
+            optional_mention_rooms=[], require_mention_rooms=["room-1"]
+        )
+        await self._dispatch(adapter, "@bob ping", mentions=[("direct", "user-2")])
+        adapter.handle_message.assert_not_called()
+        adapter.add_reaction.assert_not_awaited()
+
+    async def test_require_list_room_answers_on_our_own_target(self):
+        adapter = self._adapter(
+            optional_mention_rooms=[], require_mention_rooms=["room-1"]
+        )
+        await self._dispatch(
+            adapter,
+            "@hermes_bot ping",
+            mentions=[("direct", "bot-user-id")],
+            viewer=True,
+        )
+        adapter.handle_message.assert_called_once()
+
+    async def test_require_list_room_drops_an_unmentioned_message(self):
+        adapter = self._adapter(
+            optional_mention_rooms=[], require_mention_rooms=["room-1"]
+        )
+        await self._dispatch(adapter, "just talking")
+        adapter.handle_message.assert_not_called()
+
+
 class TestForeignMention:
     """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."""
+    unsolicited answer. Acknowledge it with 🫥 and stay out of the conversation.
+
+    These exercise the *body-derived* mentions the edited path falls back to —
+    the posted path reads the server's resolved targets instead (see
+    TestServerResolvedMentions). Each test therefore states a body and the
+    resulting ``_Mentions``, which is exactly what an edit hands the gates.
+    """
 
     def _adapter(self, **overrides):
         adapter = _make_adapter()
@@ -1293,22 +1508,31 @@ class TestForeignMention:
         return adapter
 
     async def _dispatch(self, adapter, body, room_id="room-1"):
-        payload = _make_posted_payload(room_id=room_id)
-        _with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
-        await adapter._dispatch_message_posted(payload, payload.message_event_id)
+        """Run a body through the derivation an edit relies on.
+
+        An edit event carries no resolved targets, so ``_admit_and_build``
+        calls ``_mentions_from_body`` once the roster has filled the member
+        cache. This drives that same call, so the tests below cover the real
+        parsing rules rather than a reimplementation of them.
+        """
+        await self._roster(adapter, room_id)
+        return adapter._mentions_from_body(body)
 
-    async def test_message_for_someone_else_is_only_acknowledged(self):
+    @staticmethod
+    async def _roster(adapter, room_id):
+        """Fill the member cache the way the admission pipeline does."""
+        await adapter._roster_for_room(adapter._chatto_client, room_id=room_id)
+
+    async def test_a_foreign_handle_is_someone_else(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] == "🫥"
+        assert (
+            await self._dispatch(adapter, "@bob can you take a look?")
+        ) == _Mentions(me=False, someone_else=True)
 
     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()
+        mentions = await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
+        assert mentions == _Mentions(me=True, someone_else=True)
 
     async def test_several_people_addressed_and_none_of_them_us(self):
         adapter = self._adapter()
@@ -1319,85 +1543,84 @@ class TestForeignMention:
                 else {}
             )
         )
-        await self._dispatch(adapter, "@bob @carol schaut mal drüber")
-        adapter.handle_message.assert_not_called()
-        adapter.add_reaction.assert_awaited_once()
+        adapter._user_cache["u-bob"] = _make_user("u-bob", "bob")
+        adapter._user_cache["u-carol"] = _make_user("u-carol", "carol")
+        mentions = await self._dispatch(adapter, "@bob @carol schaut mal drüber")
+        assert mentions == _Mentions(me=False, someone_else=True)
 
     async def test_a_real_handle_after_an_unknown_one_still_counts(self):
         """The scan must not stop at the first token it cannot resolve."""
         adapter = self._adapter()
-        await self._dispatch(adapter, "@nonexistent @bob schaut mal drüber")
-        adapter.handle_message.assert_not_called()
-        adapter.add_reaction.assert_awaited_once()
+        mentions = await self._dispatch(adapter, "@nonexistent @bob schaut mal drüber")
+        assert mentions == _Mentions(me=False, someone_else=True)
 
     async def test_being_named_among_several_others_still_answers(self):
         adapter = self._adapter()
-        await self._dispatch(adapter, "@bob @hermes_bot @carol — was meint ihr?")
-        adapter.handle_message.assert_called_once()
-        adapter.add_reaction.assert_not_awaited()
+        mentions = await self._dispatch(
+            adapter, "@bob @hermes_bot @carol — was meint ihr?"
+        )
+        assert mentions.me
 
     async def test_broadcast_alongside_a_named_colleague_still_answers(self):
         """@here keeps the bot in the audience; naming bob as well does not
         remove it."""
         adapter = self._adapter()
-        await self._dispatch(adapter, "@here @bob schaut mal drüber")
-        adapter.handle_message.assert_called_once()
-        adapter.add_reaction.assert_not_awaited()
+        mentions = await self._dispatch(adapter, "@here @bob schaut mal drüber")
+        assert mentions == _Mentions(me=True, someone_else=True)
 
     async def test_broadcast_mentions_address_the_bot_too(self):
         adapter = self._adapter()
         # @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()
+        # their audience. @everyone does not exist as a handle at all: it is an
+        # unknown candidate, and an unknown handle is nobody, so the message
+        # still reaches us rather than being acknowledged away.
+        for body in ("@here standup in 5", "@all heads up"):
+            assert (await self._dispatch(adapter, body)).me, body
+        # @everyone resolves to nobody, so it neither reaches nor excludes us.
+        assert await self._dispatch(adapter, "@everyone hi") == _Mentions(
+            me=False, someone_else=False
+        )
 
     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()
+        assert await self._dispatch(adapter, "@here standup in 5") == _Mentions(
+            me=True, someone_else=False
+        )
 
     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()
+        assert (await self._dispatch(adapter, "frag mal @bob.")).someone_else
 
     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()
+        assert (await self._dispatch(adapter, "@BOB can you take a look?")).someone_else
 
     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()
+        assert (await self._dispatch(adapter, "@HERMES_BOT ping")).me
 
     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()
+        mentions = await self._dispatch(
+            adapter, "so sieht ein ping aus:\n```\n@bob\n```"
+        )
+        assert mentions == _Mentions(me=False, someone_else=False)
 
     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()
+        mentions = await self._dispatch(adapter, "schreib `@bob` mal direkt an")
+        assert mentions == _Mentions(me=False, someone_else=False)
 
     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()
+        mentions = await self._dispatch(adapter, "beispiel:\n``` bash\n@bob schaut")
+        assert mentions == _Mentions(me=False, someone_else=False)
 
     async def test_broadcast_handles_are_shared_across_cases_in_the_cache(self):
         """The mention namespace is case-insensitive (FDR-006): '@BOB' and
@@ -1406,35 +1629,33 @@ class TestForeignMention:
         await self._dispatch(adapter, "@BOB ping")
         await self._dispatch(adapter, "@bob again")
         assert adapter._chatto_client.admin_get_member.await_count == 0
-        assert adapter.handle_message.await_count == 0
 
     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(
+        mentions = 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()
+        assert mentions == _Mentions(me=False, someone_else=False)
 
     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.admin_get_member = AsyncMock(return_value={})
-        await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
-        adapter.handle_message.assert_called_once()
-        adapter.add_reaction.assert_not_awaited()
+        mentions = await self._dispatch(
+            adapter, "gilt das auch für @nonexistent_person?"
+        )
+        assert mentions == _Mentions(me=False, someone_else=False)
 
     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.admin_get_member.await_count == 0
-        assert adapter.handle_message.await_count == 0
 
     async def test_bot_without_admin_rights_resolves_via_local_knowledge(self):
         """From the field: a bot account without admin permissions gets
@@ -1453,26 +1674,27 @@ class TestForeignMention:
         adapter._chatto_client.batch_get_room_members = AsyncMock(
             return_value=[DirectoryMember(user=_make_user("user-1", "nickk"))]
         )
-        await self._dispatch(adapter, "@nickk ein Test wegen Replies")
-        adapter.handle_message.assert_not_called()
-        adapter.add_reaction.assert_awaited_once()
+        mentions = await self._dispatch(adapter, "@nickk ein Test wegen Replies")
+        assert mentions == _Mentions(me=False, someone_else=True)
         adapter._chatto_client.admin_get_member.assert_not_awaited()
 
     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()
+        assert not (
+            await self._dispatch(adapter, "schreib an bob@example.com")
+        ).someone_else
         adapter._chatto_client.admin_get_member.assert_not_awaited()
 
     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()
+        assert await self._dispatch(adapter, "how do I reset the cache?") == _Mentions()
 
     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()
+        mentions = await self._dispatch(
+            adapter, "@bob said the build is red", room_id="dm-1"
+        )
+        assert mentions == _Mentions(me=False, someone_else=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
@@ -1508,8 +1730,16 @@ class TestRoomPolicies:
         adapter.handle_message = AsyncMock()
         return adapter
 
-    async def _dispatch(self, adapter, room_id, body):
-        payload = _make_posted_payload(room_id=room_id)
+    async def _dispatch(self, adapter, room_id, body, mentions=None):
+        """Dispatch a message, defaulting to one that addressed nobody.
+
+        These tests are about room policy, not about who a message addresses,
+        so the default is "no mention targets" and the ones that need a
+        delivered message say so.
+        """
+        payload = _make_posted_payload(
+            room_id=room_id, mentions=[] if mentions is None else mentions
+        )
         _with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
         await adapter._dispatch_message_posted(payload, payload.message_event_id)
 
@@ -1560,7 +1790,8 @@ class TestRoomPolicies:
         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")
+        mentions = _mentions_me()
+        await self._dispatch(adapter, "room-1", "@hermes_bot hi there", mentions)
         adapter.handle_message.assert_called_once()
 
     async def test_broadcast_mention_counts_as_addressed(self):
@@ -1569,7 +1800,9 @@ class TestRoomPolicies:
         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")
+        broadcast = _make_mentions(("here", None))
+        broadcast[0].includes_viewer = True
+        await self._dispatch(adapter, "room-1", "@here standup in 5", broadcast)
         adapter.handle_message.assert_called_once()
 
     async def test_optional_list_room_answers_without_a_mention(self):
@@ -3806,7 +4039,11 @@ class TestDmRoomCommands:
         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")
+        payload = _make_posted_payload(room_id="chan-1", mentions=[])
+        _with_get_message(
+            adapter, payload, _make_message(body="/leave room-7", room_id="chan-1")
+        )
+        await adapter._dispatch_message_posted(payload, payload.message_event_id)
 
         adapter._chatto_client.leave_room.assert_not_awaited()
         adapter.handle_message.assert_not_called()