Parcourir la source

Announce the channel member roster when a thread opens

Threads run isolated sessions and never see channel_context, so the
agent answered questions inside a thread without knowing who else was
listening - mention gating drops unaddressed channel traffic long
before it could teach it a name. When a thread opens, its channel's
member roster (presence-labeled, capped) is announced once per thread
root, best-effort: a directory failure costs the roster line, never the
turn.
Paul Klumpp il y a 1 semaine
Parent
commit
2438b07217
4 fichiers modifiés avec 312 ajouts et 0 suppressions
  1. 8 0
      README.md
  2. 100 0
      adapter.py
  3. 5 0
      platform_config.py
  4. 199 0
      test_adapter.py

+ 8 - 0
README.md

@@ -15,6 +15,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once
 | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
 | **Rooms** | Opt-in per room: a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` gets answers only when Hermes is addressed (`@mention`, `@all`, `@here`); one listed in `CHATTO_OPTIONAL_MENTION_ROOMS` gets an answer for every message. A room on neither list stays silent — read-only. |
 | **Threads** | If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The bot auto-follows threads it participates in. |
+| **Room awareness** | The first message of a new thread in a room comes with the member roster attached (who is in the room, with presence), so Hermes knows his audience without asking. Later messages in that thread carry no repeat; DMs never carry one. |
 | **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success, ❌ on failure, or 🚫 when processing was cancelled. |
 | **Editing your messages** | An edit within 5 minutes of posting counts as a correction: while Hermes is still working on the original, he restarts with the edited text (🚫 → 👀); if he had ignored or not yet answered the message, the gates are re-checked against the new text — adding a forgotten `@mention` this way works. Edits to messages Hermes has already answered change nothing. Disable with `CHATTO_EDIT_DISPATCH=false`. |
 | **Typing indicators** | Hermes broadcasts persistent typing indicators while it's working, so users know the bot is active. |
@@ -43,6 +44,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | room creation | yes |
 | room join/leave over DM | yes (`/join`, `/leave`) |
 | member directory | yes (cached) |
+| room member context | yes (roster with the first message of each channel thread) |
 | presence broadcasting | yes (online, refreshed) |
 | custom status | yes |
 | read state management | yes |
@@ -204,6 +206,12 @@ Hermes sends messages with full Markdown formatting. Chatto renders Markdown nat
 
 When you reply to a message in Chatto (creating a thread), Hermes responds within that thread. Thread context stays isolated from the parent room — each thread has its own session namespace. With auto-threading enabled (default), fresh room replies open a thread under the incoming message, keeping busy-room timelines clean. The bot auto-follows threads it participates in.
 
+### Room Member Roster
+
+Hermes cannot see who else is in a room: unaddressed channel messages are dropped by the mention gates long before they could teach him a name. So the dispatch that opens a thread in a room carries the member roster alongside the message text — one entry per member as `@login (Display Name, presence)`, e.g. `@alice (Alice, online), @bob (Bob, offline)`. The bot's own account and deleted users are left out; rooms larger than 100 members end with `… and N more`. Presence is the Chatto status (`online`, `away`, `do not disturb`, `offline`).
+
+The roster appears exactly once per thread — follow-up messages in an existing thread and DMs carry none, and editing the thread-starting message never re-announces it. If the member lookup fails, the turn proceeds without the roster; the thread then counts as unannounced and the next dispatch that opens it (e.g. after adding a forgotten `@mention` by editing the root post) tries again.
+
 ### Reactions
 
 Hermes uses emoji reactions for processing lifecycle notifications:

+ 100 - 0
adapter.py

@@ -82,6 +82,7 @@ try:
         ReactionPayload,
     )
     from chattolib.types import (
+        DirectoryMember,
         Message,
         MessageAttachment,
         PresenceStatus,
@@ -262,6 +263,15 @@ def _normalise_outbound_text(content: str) -> str:
 # Adapter
 # --------------------------------------------------------------------------- #
 
+# Presence as the roster block spells it. UNSPECIFIED gets no label: a server
+# that never tracked presence should not have the roster claim anything.
+_PRESENCE_LABELS = {
+    PresenceStatus.ONLINE: "online",
+    PresenceStatus.AWAY: "away",
+    PresenceStatus.DO_NOT_DISTURB: "do not disturb",
+    PresenceStatus.OFFLINE: "offline",
+}
+
 
 def hermes_adapter_factory(config: PlatformConfig):
     """Construct a ChattoAdapter from a PlatformConfig."""
@@ -310,6 +320,10 @@ class ChattoAdapter(BasePlatformAdapter):
         # fresh turn — that is the lock against re-answering settled
         # conversations by editing old messages.
         self._dispatched_ids: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
+        # Thread roots whose member roster has already been announced to the
+        # agent via channel_context — one announcement per thread, never a
+        # second one through an edited root post.
+        self._roster_announced: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
         # session_key -> message ID currently being processed there. Written
         # by on_processing_start, cleared by on_processing_complete; an edit
         # landing on the recorded ID is a mid-run correction.
@@ -1052,6 +1066,78 @@ class ChattoAdapter(BasePlatformAdapter):
             return True
         return self._room_policy(room_id) != RoomPolicy.SILENT
 
+    async def _room_roster_context(self, client: ChattoClient, room_id: str) -> str:
+        """The channel's member roster as one context line, "" on any failure.
+
+        The agent only ever sees its prompt: without this block it cannot know
+        who else is in a channel, because unaddressed messages are dropped by
+        mention gating long before they could teach it a name. Best-effort by
+        design — a directory hiccup must never cost the turn itself.
+        """
+        try:
+            members, page = await client.list_room_members(
+                room_id, limit=ChattoConstants.ROSTER_MEMBER_LIMIT
+            )
+        except Exception:
+            logger.debug(
+                "Chatto: could not list members of room %s", room_id, exc_info=True
+            )
+            return ""
+        entries = [
+            entry
+            for entry in (self._roster_entry(member) for member in members)
+            if entry
+        ]
+        if not entries:
+            return ""
+
+        unfetched = max(0, page.total_count - len(members))
+        more = f" … and {unfetched} more" if unfetched else ""
+        return ", ".join(entries) + more
+
+    def _roster_entry(self, member: DirectoryMember) -> str:
+        """One roster entry, e.g. ``@bob (Bob Example, online)``.
+
+        Skips deleted users and our own account (the agent knows itself);
+        display name and presence are optional parts of the parenthetical.
+        """
+        user = member.user
+        if user is None or user.deleted:
+            return ""
+        if self.me is not None and user.id == self.me.id:
+            return ""
+        entry = f"@{user.login}"
+        details = [
+            part
+            for part in (
+                user.display_name,
+                _PRESENCE_LABELS.get(user.presence_status, ""),
+            )
+            if part
+        ]
+        if details:
+            entry += f" ({', '.join(details)})"
+        return entry
+
+    async def _roster_for_new_thread(
+        self, client: ChattoClient, *, room_id: str, thread_root_event_id: str
+    ) -> str:
+        """Roster context for a dispatch that opens this thread — once only.
+
+        Keyed by the thread root event ID so an edited root post cannot
+        re-announce what the first dispatch already said; bounded like
+        _dispatched_ids. A thread counts as announced only once something was
+        actually said: a failed lookup leaves it unmarked, so the next turn
+        through this gate can still bring the roster.
+        """
+        if thread_root_event_id in self._roster_announced:
+            return ""
+        roster = await self._room_roster_context(client, room_id)
+        if not roster:
+            return ""
+        self._roster_announced.append(thread_root_event_id)
+        return roster
+
     async def _dispatch_message_posted(self, payload: MessagePostedPayload) -> None:
         # Respond-room gate first: read-only memberships must not cost a
         # single API call, so this runs before fetch_message and _get_chatto_client.
@@ -1254,6 +1340,9 @@ class ChattoAdapter(BasePlatformAdapter):
         reach the agent. DM membership commands are executed here (side
         effect) unless ``allow_dm_commands`` is False — edits pass False so
         a corrected command line neither runs twice nor leaks to the agent.
+        A dispatch that opens a channel thread additionally carries the
+        member roster in ``channel_context`` (side effect: once the roster
+        was fetched, the thread root is recorded as announced).
 
         The caller owns dispatching: a non-None result still needs
         ``handle_message()``.
@@ -1333,6 +1422,16 @@ class ChattoAdapter(BasePlatformAdapter):
         if not thread_id and room_kind != RoomKind.DM:
             thread_id = message.id
 
+        # A dispatch that opens the thread is where the agent learns who is in
+        # the channel — later turns ride in that thread and need no repeat. The
+        # gateway prepends channel_context above the message text, so the roster
+        # never mingles with what the user actually wrote.
+        roster = ""
+        if room_kind != RoomKind.DM and thread_root_event_id is None:
+            roster = await self._roster_for_new_thread(
+                client, room_id=room_id, thread_root_event_id=message.id
+            )
+
         source = self.build_source(
             chat_id=room_id,
             chat_name=self._room_names.get(message.room_id),
@@ -1352,6 +1451,7 @@ class ChattoAdapter(BasePlatformAdapter):
             timestamp=message.created_at or datetime.now(UTC),
             raw_message=message,
             reply_to_message_id=message.in_reply_to,
+            channel_context=roster or None,
         )
         if message_event.is_command():
             message_event.message_type = MessageType.COMMAND

+ 5 - 0
platform_config.py

@@ -59,6 +59,11 @@ class ChattoConstants:
     SPLIT_THRESHOLD = 9900
     SEEN_CAP = 500
 
+    # Members fetched per roster announcement (the context block handed to the
+    # agent when a channel thread opens). Larger rooms get an "and N more"
+    # note instead of an unbounded prompt block.
+    ROSTER_MEMBER_LIMIT = 100
+
     # WebSocket reconnect backoff (the realtime transport itself lives in
     # chattolib.realtime, which owns protocol-level constants).
     WS_RECONNECT_INITIAL_BACKOFF = 1.0

+ 199 - 0
test_adapter.py

@@ -1373,6 +1373,205 @@ class TestRoomPolicies:
         adapter.handle_message.assert_called_once()
 
 
+# -- Room roster on channel_context --
+
+
+def _roster_user(user_id, login, presence=PresenceStatus.UNSPECIFIED, deleted=False):
+    """A directory user as list_room_members() would return it."""
+    return User(
+        id=user_id,
+        login=login,
+        display_name=login.replace("_", " ").title(),
+        presence_status=presence,
+        deleted=deleted,
+    )
+
+
+class TestRoomRoster:
+    """The dispatch that opens a channel thread carries the member roster in
+    channel_context — the agent's prompt is the only place it could learn who
+    else is listening. Once per thread: follow-ups and edited roots never
+    re-announce."""
+
+    def _adapter(self):
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = True
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter._user_cache["user-1"] = _roster_user("user-1", "alice")
+        adapter.handle_message = AsyncMock()
+        return adapter
+
+    def _seed_channel(self, adapter, room_id="room-1"):
+        adapter.chatto_config.require_mention_rooms.value = [room_id]
+        adapter._room_kinds[room_id] = RoomKind.CHANNEL
+
+    @staticmethod
+    def _members(*users, total_count=None):
+        members = [DirectoryMember(user=user) for user in users]
+        page = Page(total_count=total_count or len(members))
+        return members, page
+
+    async def _dispatch(
+        self,
+        adapter,
+        body,
+        *,
+        room_id="room-1",
+        message_id="msg-1",
+        thread_root=None,
+    ):
+        payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
+        payload.thread_root_event_id = thread_root
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(
+                body=body, room_id=room_id, message_id=message_id
+            )
+        )
+        await adapter._dispatch_message_posted(payload)
+
+    async def _edit(self, adapter, body, *, message_id="msg-1"):
+        event, payload = _make_edited_event(
+            room_id="room-1", message_event_id=message_id
+        )
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(body=body, message_id=message_id)
+        )
+        await adapter._handle_realtime_event(event)
+
+    def _dispatched_event(self, adapter):
+        return adapter.handle_message.await_args.args[0]
+
+    async def test_new_thread_carries_the_roster(self):
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=self._members(
+                _roster_user("user-1", "alice", PresenceStatus.ONLINE),
+                _roster_user("user-2", "bob", PresenceStatus.OFFLINE),
+                _roster_user("bot-user-id", "hermes_bot"),
+                _roster_user("user-3", "carol", deleted=True),
+            )
+        )
+
+        await self._dispatch(adapter, "@hermes_bot hi")
+
+        context = self._dispatched_event(adapter).channel_context
+        assert "@alice (Alice, online)" in context
+        assert "@bob (Bob, offline)" in context
+        # Our own account and deleted users are nobody the agent must greet.
+        assert "@hermes_bot" not in context
+        assert "carol" not in context
+
+    async def test_thread_follow_up_does_not_repeat_it(self):
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=self._members(_roster_user("user-1", "alice"))
+        )
+
+        await self._dispatch(adapter, "@hermes_bot hi")
+        await self._dispatch(
+            adapter,
+            "@hermes_bot and one more thing",
+            message_id="msg-2",
+            thread_root="msg-1",
+        )
+
+        assert adapter._chatto_client.list_room_members.await_count == 1
+        assert self._dispatched_event(adapter).channel_context is None
+
+    async def test_unspecified_room_kind_still_gets_one(self):
+        """A server that never sets kind counts as a channel — roster too."""
+        adapter = self._adapter()
+        adapter.chatto_config.require_mention_rooms.value = ["room-1"]
+        adapter._room_kinds["room-1"] = RoomKind.UNSPECIFIED
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=self._members(_roster_user("user-1", "alice"))
+        )
+
+        await self._dispatch(adapter, "@hermes_bot hi")
+
+        assert "@alice" in self._dispatched_event(adapter).channel_context
+
+    async def test_dm_never_carries_a_roster(self):
+        adapter = self._adapter()
+        adapter._room_kinds["dm-1"] = RoomKind.DM
+        adapter._chatto_client.list_room_members = AsyncMock()
+
+        await self._dispatch(adapter, "hi there", room_id="dm-1")
+
+        adapter.handle_message.assert_called_once()
+        adapter._chatto_client.list_room_members.assert_not_awaited()
+        assert self._dispatched_event(adapter).channel_context is None
+
+    async def test_directory_failure_still_dispatches(self):
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter._chatto_client.list_room_members = AsyncMock(
+            side_effect=RuntimeError("directory down")
+        )
+
+        await self._dispatch(adapter, "@hermes_bot hi")
+
+        adapter.handle_message.assert_called_once()
+        assert self._dispatched_event(adapter).channel_context is None
+
+    async def test_large_room_notes_the_unfetched_rest(self):
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=self._members(
+                _roster_user("user-1", "alice"),
+                _roster_user("user-2", "bob"),
+                total_count=150,
+            )
+        )
+
+        await self._dispatch(adapter, "@hermes_bot hi")
+
+        assert "… and 148 more" in self._dispatched_event(adapter).channel_context
+
+    async def test_late_mention_edit_announces_exactly_once(self):
+        """A forgotten mention added by edit opens a fresh turn — with the
+        roster; the already-answered lock then keeps further edits out."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=self._members(_roster_user("user-1", "alice"))
+        )
+
+        await self._edit(adapter, "no mention yet")
+        await self._edit(adapter, "@hermes_bot now it counts")
+        await self._edit(adapter, "@hermes_bot once more")
+
+        assert adapter.handle_message.await_count == 1
+        assert adapter._chatto_client.list_room_members.await_count == 1
+
+    async def test_announcement_guard_is_not_rearmed_by_empty_rosters(self):
+        """A failed lookup must not burn the thread's one announcement."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter._chatto_client.list_room_members = AsyncMock(return_value=([], Page()))
+
+        await self._dispatch(adapter, "@hermes_bot hi")
+        assert self._dispatched_event(adapter).channel_context is None
+
+        adapter.handle_message.reset_mock()
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=self._members(_roster_user("user-1", "alice"))
+        )
+        payload = _make_posted_payload(room_id="room-1", message_event_id="msg-9")
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(
+                body="@hermes_bot retry", room_id="room-1", message_id="msg-9"
+            )
+        )
+        await adapter._dispatch_message_posted(payload)
+
+        assert adapter.handle_message.await_count == 1
+        assert "@alice" in self._dispatched_event(adapter).channel_context
+
+
 # -- Inbound edits (edit-dispatch) --