Просмотр исходного кода

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 2 недель назад
Родитель
Сommit
94a103b8c0
5 измененных файлов с 486 добавлено и 136 удалено
  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.
   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`
   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
   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
   `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
   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
   (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
   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
   @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
   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`
   (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
   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
   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
 configuration long after its scope came back — a failure that persists exactly as
 long as nobody restarts the gateway.
 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
 ## One send path, two callers
 
 
 A user cannot tell whether the agent replied on its own or through a tool, so the
 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
 
 
-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
 ### Editing Your Messages
 
 

+ 103 - 59
adapter.py

@@ -32,7 +32,7 @@ import os
 import re
 import re
 import tempfile
 import tempfile
 from collections import deque
 from collections import deque
-from collections.abc import AsyncIterator, Callable
+from collections.abc import AsyncIterator, Callable, Iterable
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import UTC, datetime
 from datetime import UTC, datetime
@@ -70,6 +70,7 @@ try:
         realtime_pb2,
         realtime_pb2,
     )
     )
     from chattolib._pb.chatto.realtime.v1.events_pb2 import (
     from chattolib._pb.chatto.realtime.v1.events_pb2 import (
+        MessageMention,  # type: ignore[import-untyped]
         MessagePostedEvent,  # type: ignore[import-untyped]
         MessagePostedEvent,  # type: ignore[import-untyped]
     )
     )
     from chattolib.client import (
     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
 @dataclass
 class _RoomRoster:
 class _RoomRoster:
     """One announced room's roster projection.
     """One announced room's roster projection.
@@ -696,23 +743,35 @@ class ChattoAdapter(BasePlatformAdapter):
         without_code = re.sub(r"`[^`\n]*`", " ", without_fences)
         without_code = re.sub(r"`[^`\n]*`", " ", without_fences)
         return ChattoConstants.MENTION_RE.findall(without_code)
         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()
         own = self._own_handles()
+        me = False
+        someone_else = False
         for handle in self._mention_candidates(body):
         for handle in self._mention_candidates(body):
             lowered = handle.lower()
             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
         Resolved strictly from local knowledge — the member cache the adapter
         already keeps — never from the admin directory: ``admin_get_member``
         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
         fail-open let the bot answer every @colleague message. The cache is
         filled by the admission pipeline itself (room rosters are fetched for
         filled by the admission pipeline itself (room rosters are fetched for
         every channel turn), presence patching and the edited-message path.
         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(
         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()
             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:
     def _check_auth(self, user: User) -> bool:
         """Whether this Chatto user may talk to the agent.
         """Whether this Chatto user may talk to the agent.
 
 
@@ -1748,6 +1780,9 @@ class ChattoAdapter(BasePlatformAdapter):
             message=message,
             message=message,
             source_message_id=event_id,
             source_message_id=event_id,
             thread_root_event_id=payload.thread_root_event_id or None,
             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:
         if event is None:
             return
             return
@@ -1907,6 +1942,7 @@ class ChattoAdapter(BasePlatformAdapter):
         message: Message,
         message: Message,
         source_message_id: str,
         source_message_id: str,
         thread_root_event_id: str | None,
         thread_root_event_id: str | None,
+        mentions: _Mentions | None = None,
         allow_dm_commands: bool = True,
         allow_dm_commands: bool = True,
     ) -> MessageEvent | None:
     ) -> MessageEvent | None:
         """Run one hydrated inbound message through the admission pipeline.
         """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
         member roster in ``channel_context`` (side effect: once the roster
         was fetched, the thread root is recorded as announced).
         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
         The caller owns dispatching: a non-None result still needs
         ``handle_message()``.
         ``handle_message()``.
         """
         """
@@ -1973,14 +2016,26 @@ class ChattoAdapter(BasePlatformAdapter):
         # the bot is one of many listeners and must be addressed, whereas a DM
         # 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
         # is already addressed at it. Chatto has no group rooms — any
         # multi-participant surface is a channel, and a room whose kind the
         # 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:
         if room_kind != RoomKind.DM:
             policy = self._room_policy(room_id)
             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(
                 logger.debug(
                     "Chatto: dropping unaddressed message from %s (policy %s)",
                     "Chatto: dropping unaddressed message from %s (policy %s)",
                     room_id,
                     room_id,
@@ -1988,23 +2043,12 @@ class ChattoAdapter(BasePlatformAdapter):
                 )
                 )
                 return None
                 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
             # In an open channel we see every message, including ones plainly
             # aimed at a named colleague. Answering those would be barging in,
             # aimed at a named colleague. Answering those would be barging in,
             # so acknowledge that we read it and stay quiet. Checked after the
             # so acknowledge that we read it and stay quiet. Checked after the
             # bot-mention test above, so a message naming us *and* someone
             # bot-mention test above, so a message naming us *and* someone
             # else still counts as ours.
             # 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(
                 logger.info(
                     "Chatto: message addresses someone else, acknowledging only"
                     "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()
 setup_vendor_path()
 
 
+from chattolib._pb.chatto.realtime.v1.events_pb2 import MessageMention
 from chattolib.client import ChattoClient
 from chattolib.client import ChattoClient
 from chattolib.exceptions import ChattoError
 from chattolib.exceptions import ChattoError
 from chattolib.types import (
 from chattolib.types import (
@@ -81,6 +82,7 @@ from adapter import (
     _handle_chatto_list_rooms,
     _handle_chatto_list_rooms,
     _handle_chatto_send_dm,
     _handle_chatto_send_dm,
     _handle_chatto_send_room,
     _handle_chatto_send_room,
+    _Mentions,
     _room_answers_in,
     _room_answers_in,
     chat_type_for_room_kind,
     chat_type_for_room_kind,
     hermes_env_enablement_fn,
     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 = MagicMock()
     payload.room_id = room_id
     payload.room_id = room_id
     payload.message_event_id = message_event_id
     payload.message_event_id = message_event_id
     payload.thread_root_event_id = None
     payload.thread_root_event_id = None
+    payload.mentions = _mentions_me() if mentions is None else list(mentions)
     return payload
     return payload
 
 
 
 
@@ -1245,10 +1295,175 @@ class TestPresence:
 # -- Mentions of other people --
 # -- 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:
 class TestForeignMention:
     """In an open channel (CHATTO_OPTIONAL_MENTION_ROOMS) the bot reads
     """In an open channel (CHATTO_OPTIONAL_MENTION_ROOMS) the bot reads
     everything, so a message aimed at a named colleague would otherwise get an
     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):
     def _adapter(self, **overrides):
         adapter = _make_adapter()
         adapter = _make_adapter()
@@ -1293,22 +1508,31 @@ class TestForeignMention:
         return adapter
         return adapter
 
 
     async def _dispatch(self, adapter, body, room_id="room-1"):
     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()
         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):
     async def test_being_mentioned_alongside_someone_else_still_answers(self):
         adapter = self._adapter()
         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):
     async def test_several_people_addressed_and_none_of_them_us(self):
         adapter = self._adapter()
         adapter = self._adapter()
@@ -1319,85 +1543,84 @@ class TestForeignMention:
                 else {}
                 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):
     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."""
         """The scan must not stop at the first token it cannot resolve."""
         adapter = self._adapter()
         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):
     async def test_being_named_among_several_others_still_answers(self):
         adapter = self._adapter()
         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):
     async def test_broadcast_alongside_a_named_colleague_still_answers(self):
         """@here keeps the bot in the audience; naming bob as well does not
         """@here keeps the bot in the audience; naming bob as well does not
         remove it."""
         remove it."""
         adapter = self._adapter()
         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):
     async def test_broadcast_mentions_address_the_bot_too(self):
         adapter = self._adapter()
         adapter = self._adapter()
         # @here/@all are Chatto's virtual handles (FDR-006) — the bot is in
         # @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):
     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."""
         """A pure @here must not be read as a message aimed at another person."""
         adapter = self._adapter()
         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):
     async def test_trailing_punctuation_still_resolves_the_handle(self):
         """'frag @bob.' must resolve bob — a sentence-ending dot is not part of
         """'frag @bob.' must resolve bob — a sentence-ending dot is not part of
         the handle, mirroring the web frontend's extraction."""
         the handle, mirroring the web frontend's extraction."""
         adapter = self._adapter()
         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):
     async def test_foreign_mention_matching_is_case_insensitive(self):
         adapter = self._adapter()
         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):
     async def test_own_mention_matching_is_case_insensitive(self):
         adapter = self._adapter()
         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):
     async def test_mentions_inside_a_fenced_block_are_ignored(self):
         """Upstream does not resolve mentions in code blocks; quoting '@bob'
         """Upstream does not resolve mentions in code blocks; quoting '@bob'
         there must not silence us."""
         there must not silence us."""
         adapter = self._adapter()
         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):
     async def test_mentions_inside_an_inline_span_are_ignored(self):
         adapter = self._adapter()
         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):
     async def test_an_unterminated_fence_shields_the_rest(self):
         """An unclosed fence runs to the end of the text, like upstream."""
         """An unclosed fence runs to the end of the text, like upstream."""
         adapter = self._adapter()
         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):
     async def test_broadcast_handles_are_shared_across_cases_in_the_cache(self):
         """The mention namespace is case-insensitive (FDR-006): '@BOB' and
         """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 ping")
         await self._dispatch(adapter, "@bob again")
         await self._dispatch(adapter, "@bob again")
         assert adapter._chatto_client.admin_get_member.await_count == 0
         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):
     async def test_talking_about_mentions_is_not_a_mention(self):
         """Verbatim from the field: the instruction to send a mention later must
         """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."""
         not read as a mention now. '@-mention' is not a handle anyone holds."""
         adapter = self._adapter()
         adapter = self._adapter()
-        await self._dispatch(
+        mentions = await self._dispatch(
             adapter,
             adapter,
             "Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
             "Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
             'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
             '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):
     async def test_handle_nobody_holds_is_not_a_mention(self):
         """A plausible-looking @token that resolves to no user is not someone
         """A plausible-looking @token that resolves to no user is not someone
         else — answering a false positive beats falling silent on one."""
         else — answering a false positive beats falling silent on one."""
         adapter = self._adapter()
         adapter = self._adapter()
         adapter._chatto_client.admin_get_member = AsyncMock(return_value={})
         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):
     async def test_a_resolvable_handle_is_looked_up_once(self):
         adapter = self._adapter()
         adapter = self._adapter()
         await self._dispatch(adapter, "@bob ping")
         await self._dispatch(adapter, "@bob ping")
         await self._dispatch(adapter, "@bob again")
         await self._dispatch(adapter, "@bob again")
         assert adapter._chatto_client.admin_get_member.await_count == 0
         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):
     async def test_bot_without_admin_rights_resolves_via_local_knowledge(self):
         """From the field: a bot account without admin permissions gets
         """From the field: a bot account without admin permissions gets
@@ -1453,26 +1674,27 @@ class TestForeignMention:
         adapter._chatto_client.batch_get_room_members = AsyncMock(
         adapter._chatto_client.batch_get_room_members = AsyncMock(
             return_value=[DirectoryMember(user=_make_user("user-1", "nickk"))]
             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()
         adapter._chatto_client.admin_get_member.assert_not_awaited()
 
 
     async def test_an_email_address_is_not_a_mention(self):
     async def test_an_email_address_is_not_a_mention(self):
         adapter = self._adapter()
         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()
         adapter._chatto_client.admin_get_member.assert_not_awaited()
 
 
     async def test_plain_message_is_unaffected(self):
     async def test_plain_message_is_unaffected(self):
         adapter = self._adapter()
         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):
     async def test_dms_are_answered_even_when_they_name_someone_else(self):
         adapter = self._adapter()
         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):
     async def test_require_list_keeps_discarding_without_a_reaction(self):
         """The require gate wins: it drops the message before we get here, and
         """The require gate wins: it drops the message before we get here, and
@@ -1508,8 +1730,16 @@ class TestRoomPolicies:
         adapter.handle_message = AsyncMock()
         adapter.handle_message = AsyncMock()
         return adapter
         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))
         _with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
         await adapter._dispatch_message_posted(payload, payload.message_event_id)
         await adapter._dispatch_message_posted(payload, payload.message_event_id)
 
 
@@ -1560,7 +1790,8 @@ class TestRoomPolicies:
         adapter = self._adapter()
         adapter = self._adapter()
         adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         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()
         adapter.handle_message.assert_called_once()
 
 
     async def test_broadcast_mention_counts_as_addressed(self):
     async def test_broadcast_mention_counts_as_addressed(self):
@@ -1569,7 +1800,9 @@ class TestRoomPolicies:
         adapter = self._adapter()
         adapter = self._adapter()
         adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter.chatto_config.require_mention_rooms.value = ["room-1"]
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         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()
         adapter.handle_message.assert_called_once()
 
 
     async def test_optional_list_room_answers_without_a_mention(self):
     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.chatto_config.require_mention_rooms.value = ["chan-1"]
         adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
         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._chatto_client.leave_room.assert_not_awaited()
         adapter.handle_message.assert_not_called()
         adapter.handle_message.assert_not_called()