浏览代码

Map RoomKind onto the gateway's chat_type vocabulary

build_source() was handed the raw chattolib RoomKind, so a channel arrived at
the gateway as chat_type="ROOM_KIND_CHANNEL". Nothing rejects that — it just
misses every branch that matters: SessionSource.description (session.py:239)
and the PII-redacted description in build_session_context_prompt
(session.py:537) both fall through to a nameless generic case, so the agent
was told 'Source: Chatto-platform (Some Room)' instead of '(channel: Some
Room)'. The value also goes verbatim into the session key.

HermesChatType is a StrEnum so it stays a drop-in str at those call sites.
UNSPECIFIED and a missing kind map to GROUP, never to DM: that one value
drives session isolation (is_shared_multi_user_session, session.py:1063) and
would quietly turn a room into a private conversation.

get_chat_info() reports the mapped value too, instead of collapsing every
non-DM room to 'group'.
Paul Klumpp 1 周之前
父节点
当前提交
941bc4a86b
共有 2 个文件被更改,包括 111 次插入3 次删除
  1. 56 3
      adapter.py
  2. 55 0
      test_adapter.py

+ 56 - 3
adapter.py

@@ -33,6 +33,7 @@ import logging
 import mimetypes
 import os
 from datetime import datetime, timezone
+from enum import StrEnum
 from typing import Any, Dict, List, Literal, Optional, Tuple, cast
 from urllib.parse import urlsplit
 
@@ -96,6 +97,59 @@ except ImportError:  # pragma: no cover - loaded as a top-level module (tests)
     )
 
 
+# --------------------------------------------------------------------------- #
+# Chat types
+# --------------------------------------------------------------------------- #
+
+class HermesChatType(StrEnum):
+    """The ``chat_type`` vocabulary the Hermes gateway understands.
+
+    Declared in ``gateway/session.py:161`` as ``"dm", "group", "channel",
+    "thread"`` and consumed as a bare string all over the gateway:
+    ``SessionSource.description`` (session.py:239) and the PII-redacting
+    description in ``build_session_context_prompt`` (session.py:537) both
+    branch on these exact values and fall back to a nameless generic case for
+    anything else, and ``build_session_key`` puts the value straight into the
+    session key.  Passing a chattolib ``RoomKind`` (``"ROOM_KIND_CHANNEL"``)
+    therefore does not fail loudly — it just quietly degrades what the agent is
+    told about where it is.
+
+    A StrEnum so it stays a drop-in ``str`` at every one of those call sites.
+    """
+
+    DM = "dm"
+    GROUP = "group"
+    CHANNEL = "channel"
+    # Emitted by adapters whose thread events are their own chat type (Slack,
+    # Discord). We don't: a Chatto thread keeps its room's chat_type and is
+    # identified by ``thread_id`` on the source instead. Listed for the record,
+    # because build_session_key rewrites the slot to "thread" itself
+    # (session.py:1190).
+    THREAD = "thread"
+
+
+# Chatto only distinguishes DMs from channels. UNSPECIFIED means the server
+# sent a kind this vendored chattolib doesn't know: map it to the generic
+# multi-user bucket rather than guessing "channel", and never to "dm" — that
+# value drives session isolation (is_shared_multi_user_session, session.py:1063)
+# and would silently turn a room into a private conversation.
+_ROOM_KIND_TO_CHAT_TYPE: Dict[RoomKind, HermesChatType] = {
+    RoomKind.DM: HermesChatType.DM,
+    RoomKind.CHANNEL: HermesChatType.CHANNEL,
+    RoomKind.UNSPECIFIED: HermesChatType.GROUP,
+}
+
+
+def chat_type_for_room_kind(kind: Optional[RoomKind]) -> HermesChatType:
+    """Map a chattolib RoomKind onto the gateway's chat_type vocabulary.
+
+    An unknown or missing kind becomes ``GROUP`` — see ``_ROOM_KIND_TO_CHAT_TYPE``.
+    """
+    if kind is None:
+        return HermesChatType.GROUP
+    return _ROOM_KIND_TO_CHAT_TYPE.get(kind, HermesChatType.GROUP)
+
+
 # --------------------------------------------------------------------------- #
 # Adapter
 # --------------------------------------------------------------------------- #
@@ -573,7 +627,7 @@ class ChattoAdapter(BasePlatformAdapter):
         source = self.build_source(
             chat_id=payload.room_id,
             chat_name=self._room_names.get(message.room_id),
-            chat_type="dm" if room_kind == RoomKind.DM else room_kind or RoomKind.UNSPECIFIED, # Only "dm" seems to be a reserved keyword from Base adapter class.,
+            chat_type=chat_type_for_room_kind(room_kind),
             user_id=message.actor_id,
             user_name=user.login, # use login, because display_name is changeable by anyone.
             thread_id=thread_id,
@@ -1158,10 +1212,9 @@ class ChattoAdapter(BasePlatformAdapter):
         """
         name = self._room_names.get(chat_id, chat_id)
         kind = self._room_kinds.get(chat_id)
-        chat_type = "dm" if kind == RoomKind.DM else "group"
         return {
             "name": name,
-            "type": chat_type,
+            "type": chat_type_for_room_kind(kind).value,
         }
 
     # ------------------------------------------------------------------ #

+ 55 - 0
test_adapter.py

@@ -34,6 +34,8 @@ sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 from adapter import (
     ChattoAdapter,
+    HermesChatType,
+    chat_type_for_room_kind,
     hermes_check_fn as check_requirements,
     hermes_validate_config as validate_config,
     register,
@@ -658,6 +660,59 @@ class TestReactionForwarding:
         await adapter._handle_realtime_event(self._event("reaction_added"))
 
 
+# -- chat_type mapping --
+
+class TestChatTypeMapping:
+    """RoomKind -> the gateway's chat_type vocabulary."""
+
+    def test_maps_known_kinds(self):
+        assert chat_type_for_room_kind(RoomKind.DM) is HermesChatType.DM
+        assert chat_type_for_room_kind(RoomKind.CHANNEL) is HermesChatType.CHANNEL
+
+    def test_unknown_kind_is_group_never_dm(self):
+        """'dm' drives session isolation — never guess it for an unknown kind."""
+        assert chat_type_for_room_kind(RoomKind.UNSPECIFIED) is HermesChatType.GROUP
+        assert chat_type_for_room_kind(None) is HermesChatType.GROUP
+
+    def test_values_match_the_gateway_vocabulary(self):
+        """session.py:161 declares exactly these strings; SessionSource.description
+        and the PII-redacted context prompt branch on them."""
+        assert [t.value for t in HermesChatType] == ["dm", "group", "channel", "thread"]
+
+    def test_is_a_plain_str_at_call_sites(self):
+        assert HermesChatType.CHANNEL == "channel"
+        assert f"{HermesChatType.DM}" == "dm"
+
+    async def test_get_chat_info_reports_channel(self):
+        adapter = _make_adapter()
+        adapter._room_names["room-1"] = "Team"
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        info = await adapter.get_chat_info("room-1")
+        assert info == {"name": "Team", "type": "channel"}
+
+    async def test_get_chat_info_reports_dm(self):
+        adapter = _make_adapter()
+        adapter._room_kinds["dm-1"] = RoomKind.DM
+        assert (await adapter.get_chat_info("dm-1"))["type"] == "dm"
+
+    async def test_dispatch_stamps_the_mapped_chat_type(self):
+        """The value reaching build_source decides how the agent is told where
+        it is — a raw RoomKind lands in SessionSource.description's else-branch."""
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = True
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter._room_kinds["room-1"] = RoomKind.CHANNEL
+        adapter._user_cache["user-1"] = _make_user("user-1", "alice")
+        adapter.handle_message = AsyncMock()
+        payload = _make_posted_payload()
+        payload.fetch_message = AsyncMock(return_value=_make_message(body="hi"))
+
+        await adapter._dispatch_message_posted(payload)
+
+        event = adapter.handle_message.call_args.args[0]
+        assert event.source.chat_type == "channel"
+
+
 # -- Inbound attachments --
 
 class TestInboundAttachments: