|
@@ -33,6 +33,7 @@ import logging
|
|
|
import mimetypes
|
|
import mimetypes
|
|
|
import os
|
|
import os
|
|
|
from datetime import datetime, timezone
|
|
from datetime import datetime, timezone
|
|
|
|
|
+from enum import StrEnum
|
|
|
from typing import Any, Dict, List, Literal, Optional, Tuple, cast
|
|
from typing import Any, Dict, List, Literal, Optional, Tuple, cast
|
|
|
from urllib.parse import urlsplit
|
|
from urllib.parse import urlsplit
|
|
|
|
|
|
|
@@ -96,6 +97,98 @@ 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.
|
|
|
|
|
+
|
|
|
|
|
+ GROUP vs CHANNEL
|
|
|
|
|
+ ----------------
|
|
|
|
|
+ There is no strict contract between the two, and the adapters disagree in
|
|
|
|
|
+ practice: Slack labels every non-DM conversation ``"group"`` (including real
|
|
|
|
|
+ channels), Discord uses both, and Telegram reserves ``"channel"`` for actual
|
|
|
|
|
+ broadcast channels. The intended reading is ``group`` = ordinary
|
|
|
|
|
+ multi-participant chat, ``channel`` = broadcast surface.
|
|
|
|
|
+
|
|
|
|
|
+ The distinction only changes behaviour in three places:
|
|
|
|
|
+
|
|
|
|
|
+ 1. Authorization (``gateway/authz_mixin.py``) — the only security-relevant
|
|
|
|
|
+ one. The group-scoped env allowlists apply to ``{"group", "forum"}``
|
|
|
|
|
+ ONLY, never to ``"channel"``: ``{PLATFORM}_GROUP_ALLOWED_USERS`` /
|
|
|
|
|
+ ``_GROUP_ALLOWED_CHATS`` (:616), the chat-id allowlist (:708) and the
|
|
|
|
|
+ Telegram legacy shim (:724). The adapter-delegation paths in turn treat
|
|
|
|
|
+ all three alike (:461, :649, :674, :694), where the value only picks
|
|
|
|
|
+ ``group_allow_from`` over ``allow_from`` from ``config.extra``.
|
|
|
|
|
+ For Chatto both choices are equivalent today: those group env maps hold
|
|
|
|
|
+ Telegram and QQBot only (:535-541), and our own allowlist runs through
|
|
|
|
|
+ ``CHATTO_ALLOWED_USERS``, which is chat_type-independent.
|
|
|
|
|
+ 2. What the agent is told — ``SessionSource.description`` renders
|
|
|
|
|
+ ``"group: Name"`` vs ``"channel: Name"`` (session.py:239-246), likewise
|
|
|
|
|
+ the PII-redacted variant (session.py:537-544).
|
|
|
|
|
+ 3. The session key, which embeds the literal (session.py:1192). Changing
|
|
|
|
|
+ the value for a room re-buckets its existing sessions.
|
|
|
|
|
+
|
|
|
|
|
+ Explicitly NOT affected: ``is_shared_multi_user_session`` (session.py:1063)
|
|
|
|
|
+ only looks at ``"dm"`` and ``thread_id``, so sender prefixes, the multi-user
|
|
|
|
|
+ prompt line and ``group_sessions_per_user`` treat group and channel
|
|
|
|
|
+ identically.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ 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"
|
|
|
|
|
+ # Not declared in session.py:161 but real: Telegram forum topics travel as
|
|
|
|
|
+ # "forum", and the authz group allowlists above accept it alongside "group".
|
|
|
|
|
+ # Chatto has no equivalent, so we never emit it.
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# 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.
|
|
|
|
|
+#
|
|
|
|
|
+# CHANNEL for RoomKind.CHANNEL is the descriptive choice and carries no
|
|
|
|
|
+# behavioural cost (see the GROUP vs CHANNEL note above). Switching to GROUP for
|
|
|
|
|
+# Slack parity would be this one line — plus the re-bucketing of existing
|
|
|
|
|
+# sessions that point 3 of that note describes.
|
|
|
|
|
+_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
|
|
# Adapter
|
|
|
# --------------------------------------------------------------------------- #
|
|
# --------------------------------------------------------------------------- #
|
|
@@ -573,7 +666,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
source = self.build_source(
|
|
source = self.build_source(
|
|
|
chat_id=payload.room_id,
|
|
chat_id=payload.room_id,
|
|
|
chat_name=self._room_names.get(message.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_id=message.actor_id,
|
|
|
user_name=user.login, # use login, because display_name is changeable by anyone.
|
|
user_name=user.login, # use login, because display_name is changeable by anyone.
|
|
|
thread_id=thread_id,
|
|
thread_id=thread_id,
|
|
@@ -1158,10 +1251,9 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
"""
|
|
"""
|
|
|
name = self._room_names.get(chat_id, chat_id)
|
|
name = self._room_names.get(chat_id, chat_id)
|
|
|
kind = self._room_kinds.get(chat_id)
|
|
kind = self._room_kinds.get(chat_id)
|
|
|
- chat_type = "dm" if kind == RoomKind.DM else "group"
|
|
|
|
|
return {
|
|
return {
|
|
|
"name": name,
|
|
"name": name,
|
|
|
- "type": chat_type,
|
|
|
|
|
|
|
+ "type": chat_type_for_room_kind(kind).value,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|