| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196 |
- """
- Chatto Platform Adapter for Hermes Agent.
- A plugin-based gateway adapter that connects to a Chatto server
- (self-hosted team chat) and relays messages to/from the Hermes agent.
- The adapter uses the chattolib library for all Chatto API interactions,
- including both outbound messaging and realtime WebSocket connections.
- """
- from __future__ import annotations
- import random
- from gateway.platforms.helpers import MessageDeduplicator
- # Put the vendored dependencies for THIS platform on sys.path before importing
- # anything from chattolib. Imported relatively as part of the plugin package and
- # absolutely when this module is loaded standalone (e.g. by the tests).
- try:
- from .vendor_path import setup_vendor_path
- except ImportError: # pragma: no cover - depends on how the module is loaded
- from vendor_path import setup_vendor_path
- setup_vendor_path()
- import asyncio
- import hashlib
- 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
- logger = logging.getLogger(__name__)
- from gateway.platforms.base import (
- BasePlatformAdapter,
- SendResult,
- MessageEvent,
- MessageType,
- ProcessingOutcome,
- cache_media_bytes,
- get_inbound_media_max_bytes,
- validate_inbound_media_size,
- )
- from gateway.config import Platform, PlatformConfig
- # Chattolib imports (vendored)
- # Using vendored chattolib from vendor/chattolib/
- # See vendor_chattolib.sh for how to update the vendored copy
- # Absolute imports — vendor/ is on sys.path (see above) and chattolib's own
- # modules import each other absolutely. Mixing in relative ".vendor.chattolib"
- # imports would load a second, distinct copy of every module, so isinstance()
- # checks across the two copies would silently fail.
- try:
- from chattolib.client import (
- ChattoClient,
- )
- from chattolib.exceptions import (
- ChattoAuthError,
- ChattoError,
- )
- from chattolib.realtime import (
- ChattoRealtimeError,
- ChattoRealtimeCloseError, RealtimeEvent,
- stream_events
- )
- from chattolib.realtime_types import (
- MessagePostedPayload,
- ReactionPayload,
- )
- from chattolib.types import (
- PresenceStatus, RoomKind, User
- )
- except ImportError as e:
- # Fail loudly: continuing here only defers the failure to a confusing
- # NameError somewhere deep in the adapter.
- logger.error("Chatto: failed to import vendored chattolib: %s", e)
- raise
- try:
- from .platform_config import (
- ChattoConfiguration, ChattoConstants,
- )
- except ImportError: # pragma: no cover - loaded as a top-level module (tests)
- from platform_config import (
- ChattoConfiguration, ChattoConstants,
- )
- # --------------------------------------------------------------------------- #
- # 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
- # --------------------------------------------------------------------------- #
- def hermes_adapter_factory(config: PlatformConfig):
- """Factory wrapper that constructs ChattoAdapter from a PlatformConfig."""
- return ChattoAdapter(config)
- class ChattoAdapter(BasePlatformAdapter):
- """Chatto platform adapter — receives messages via WebSocket realtime,
- sends via ConnectRPC."""
-
- _SPLIT_THRESHOLD = 9900
- # Read by BasePlatformAdapter.max_message_length_for_chat(), which the
- # gateway and the stream consumer use to chunk outgoing messages. Without
- # it they fall back to 4096 and split Chatto messages far earlier than
- # necessary — send() itself already truncates at 10000.
- MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
- splits_long_messages = True
- supports_code_blocks: bool = True
- supports_status_text: bool = True # client.update_custom_status
- def __init__(self, pconfig: PlatformConfig):
- """Signature needs to be compatible with BasePlatformAdapter.__init__ """
- super().__init__(config=pconfig, platform=Platform(ChattoConstants.PLATFORM_NAME))
- # "extra" has been pre-processed by Hermes-Framework to be a dict of extra config values insode PlatformConfig.
- # --- Configuration from our configuration data class with some logic ---
- self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
- # ------ State -------
- # SDK runtime handle (injected by Hermes); annotate for Pylance
- self.sdk: Any = getattr(self, "sdk", None)
- # Our own user, filled in by connect(). Events arriving before connect()
- # completes must not blow up on an undefined attribute.
- self.me: Optional[User] = None
- # --- Runtime state ---
- self._user_id: str = ""
- self._user_display: str = ""
- self._room_names: Dict[str, str] = {}
- self._room_kinds: Dict[str, RoomKind] = {}
- self._our_thread_roots: set = set() # thread root event IDs we created
- self._our_message_ids: set = set() # message IDs we sent (for thread root detection)
- self._seen: list[str] = [] # Plain RealtimeEvent-id list
- self._resume_cursor: Optional[str] = None
- self._watch_room_ids: List[str] = []
- self._ws_task: Optional[asyncio.Task] = None
- self._presence_task: Optional[asyncio.Task] = None
- self._ws_ready: Optional[asyncio.Event] = None
- self._ws_active = False
- self._ws_ref = None # reference to open websocket for dynamic resubscribe
- # Persistent typing indicator loops per room
- self._typing_tasks: Dict[str, asyncio.Task] = {}
- # Member directory cache: user_id -> user info dict
- self._user_cache: Dict[str, User] = {}
- # Handle -> does a user hold it. Cached both ways; see _mentions_someone_else.
- self._known_handles: Dict[str, bool] = {}
- # Chattolib client cache and lock for async access.
- self._chatto_client: Optional[ChattoClient] = None
- self._chatto_client_lock: asyncio.Lock = asyncio.Lock()
- # Dedup — chattolib may redeliver events across reconnects.
- self._dedup = MessageDeduplicator()
- # ------------------------------------------------------------------ #
- # Auth
- # ------------------------------------------------------------------ #
- async def _get_chatto_client(self: ChattoAdapter) -> Optional[ChattoClient]:
- """Get or create a ChattoClient instance."""
- if self._chatto_client is not None:
- return self._chatto_client
- async with self._chatto_client_lock:
- if self._chatto_client is not None:
- return self._chatto_client
- try:
- assert(self.chatto_config.login.value) # now we can assume, _login is available.
- client = await self._open_client(
- base_url=self.chatto_config.base_url.value,
- login=self.chatto_config.login.value,
- password=self.chatto_config.password.value,
- token=self.chatto_config.token.value,
- )
- self._chatto_client = client
- self._token = client.token
- logger.info("Chatto: logged in as '%s' via chattolib", self.chatto_config.login.value)
- return client
- except ChattoAuthError as e:
- logger.error("Chatto: authentication failed: %s", e)
- return None
- except (ChattoError, ValueError) as e:
- logger.error("Chatto: failed to create client: %s", e)
- return None
- async def _require_client(self) -> ChattoClient:
- """Return a ChattoClient or raise RuntimeError if unavailable.
- Use this helper when the caller expects a client to exist and
- wants a single canonical failure path. Methods that prefer a
- soft-fail can catch RuntimeError and return gracefully.
- """
- client = await self._get_chatto_client()
- if client is None:
- raise RuntimeError("Chatto client unavailable")
- return client
- async def _ensure_token(self) -> bool:
- """Ensure we have a logged-in Chatto client and token."""
- if self.chatto_config.token.value and isinstance(self._chatto_client, ChattoClient):
- return True
- client = await self._get_chatto_client()
- return client is not None
- # ------------------------------------------------------------------ #
- # Connection
- # ------------------------------------------------------------------ #
- async def _open_client(
- self,
- *,
- base_url: str,
- login: str,
- password: str,
- token: Optional[str] = None,
- ) -> ChattoClient:
- """Return a connected ``ChattoClient`` using token or login/password."""
- if token:
- return ChattoClient(token=token, base_url=base_url)
- return await ChattoClient.login(login, password, base_url=base_url)
-
- async def connect(self, *, is_reconnect: bool = False) -> bool:
- """Connect to Chatto and start the realtime event stream.
- BasePlatformAdapter override
- """
- logger.info("Chatto: connecting...")
-
- if not await self._ensure_token():
- return False
- try:
- client = await self._require_client()
- except RuntimeError:
- self._set_fatal_error("connect_failed", "Chatto client not available", retryable=True)
- return False
- # Get our first own user info
- try:
- self.me = await client.me()
- except Exception as exc:
- logger.error("Chatto: failed to get user info: %s", exc)
- self._set_fatal_error(
- "chatto_auth_failed",
- f"Chatto auth failed: {exc}",
- retryable=False,
- )
- try:
- assert(self._chatto_client)
- await self._chatto_client.close()
- finally:
- self._chatto_client = None
- return False
- # Announce online presence so the bot appears online in the member list.
- # The server treats this as a TTL, so _presence_refresh_loop below has to
- # keep re-announcing it — a single call here lapses back to offline.
- await self._announce_online()
- self._closing = False
- # Start background realtime WS event stream loop.
- self._ws_ready = asyncio.Event()
- self._ws_task = asyncio.create_task(
- self._chattolib_event_loop(), name="chatto-event-stream",
- )
- self._presence_task = asyncio.create_task(
- self._presence_refresh_loop(), name="chatto-presence-refresh",
- )
- self._mark_connected()
- logger.info(
- "Chatto: connected and authentiated to %s using login:'%s' (display_name:'%s' id: '%s')",
- self.chatto_config.base_url.value,
- self.me.login, self.me.display_name, self.me.id
- )
- return True
- async def _announce_online(self) -> bool:
- """Tell the server we are online. Returns whether the call got through.
- Logged at warning level on failure: a silently dropped presence call is
- indistinguishable from a bot that is simply not running.
- """
- try:
- client = await self._require_client()
- await client.update_presence(status=PresenceStatus.ONLINE)
- return True
- except Exception as exc:
- logger.warning("Chatto: presence refresh failed, bot may appear offline: %s", exc)
- return False
- async def _presence_refresh_loop(self) -> None:
- """Re-announce ONLINE until disconnect, since presence expires server-side.
- Failures are not fatal — the next tick tries again, so a blip in the
- presence endpoint costs at most one interval of visible offline time.
- """
- while not self._closing:
- await self._sleep_interruptible(ChattoConstants.PRESENCE_REFRESH_INTERVAL)
- if self._closing:
- return
- await self._announce_online()
- async def disconnect(self) -> None:
- """Stop WebSocket, presence refresh, typing tasks, and clear state.
- BasePlatformAdapter override
- """
- # No explicit offline broadcast: chattolib rejects OFFLINE outright
- # ("stop refreshing to go offline"), so cancelling the refresh loop
- # below is what actually takes the bot offline.
- self._ws_active = False
- self._closing = True
- # Cancel all typing tasks
- for chat_id in list(self._typing_tasks.keys()):
- await self.stop_typing(chat_id)
- if self._ws_task and not self._ws_task.done():
- self._ws_task.cancel()
- try:
- await self._ws_task
- except (asyncio.CancelledError, Exception):
- pass
- self._ws_task = None
- if self._presence_task and not self._presence_task.done():
- self._presence_task.cancel()
- try:
- await self._presence_task
- except (asyncio.CancelledError, Exception):
- pass
- self._presence_task = None
- if self._chatto_client:
- try:
- await self._chatto_client.close()
- except Exception:
- logger.exception("Chatto: error closing client")
- finally:
- self._chatto_client = None
- self._token = None
- logger.info("Chatto: disconnected")
- self._mark_disconnected()
- async def _seed_room(self, room_id: str) -> None:
- """Seed high-water mark from the newest events so a restart doesn't replay history."""
- try:
- try:
- client = await self._require_client()
- timeline_page = await client.get_room_events(room_id)
- except RuntimeError:
- logger.debug("Chatto: _seed_room aborted - no client available for %s", room_id)
- return
-
- for ev in timeline_page.events:
- if ev.id:
- self._mark_seen(ev.id)
- logger.debug("Chatto: seeded room %s with %d events", room_id, len(timeline_page.events))
- except Exception as e:
- logger.debug("Chatto: get room events failed for %s: %s", room_id, e)
- # ------------------------------------------------------------------ #
- # Realtime Event List
- # ------------------------------------------------------------------ #
- def _mark_seen(self, event_id: str) -> None:
- self._seen.append(event_id)
- while len(self._seen) > ChattoConstants.SEEN_CAP:
- self._seen.remove(self._seen[0]) # fastest removal of first item in a list.
- def _is_seen(self, event_id: str) -> bool:
- return event_id in self._seen
- # ------------------------------------------------------------------ #
- # WebSocket Realtime Transport
- # ------------------------------------------------------------------ #
- def _mentions_me(self, body: str) -> bool:
- """Whether the message @-mentions this bot, by login or display name."""
- if not self.me:
- return False
- for handle in (self.me.login, self.me.display_name):
- if handle and f"@{handle}" in body:
- return True
- return False
- async def _handle_belongs_to_a_user(self, handle: str) -> bool:
- """Whether ``handle`` is the login of a real Chatto user.
- The API carries no mention entities — ``mention_confirmation_token`` is
- reserved in the message descriptor — so an @-token is only a candidate
- until the directory confirms it. Results are cached both ways, since
- the same handles recur and a miss is as reusable as a hit.
- """
- known = self._known_handles.get(handle)
- if known is not None:
- return known
- try:
- client = await self._require_client()
- member = await client.get_user(login=handle)
- except Exception as exc:
- # Unresolved means "not confirmed", so the message goes through.
- logger.debug("Chatto: could not resolve handle @%s: %s", handle, exc)
- return False
- exists = member is not None and member.user is not None
- self._known_handles[handle] = exists
- return exists
- 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.
- """
- for handle in ChattoConstants.MENTION_RE.findall(body):
- if handle.lower() in ChattoConstants.BROADCAST_MENTIONS:
- continue
- if self.me and handle in (self.me.login, self.me.display_name):
- continue
- if await self._handle_belongs_to_a_user(handle):
- return True
- return False
- def _check_auth(self, user: User) -> bool:
- """We roll our own auth-systen on the against the chatto_config'ured allowed_users etc.
- because.. Hermes authz_mixin.py IS NOT SANE.
- """
- if self.chatto_config.allow_all_users.value:
- return True
- if user.login in self.chatto_config.allowed_users.value:
- return True
- if user.id in self.chatto_config.allowed_users.value:
- return True
- logger.info("Chatto: rejecting message from unauthorized user '%s' (%s)", user.login, user.id)
- return False
- # ------------------------------------------------------------------ #
- # Inbound attachments
- # ------------------------------------------------------------------ #
- async def _download_attachment_bytes(self, url: str) -> bytes:
- """Download an attachment, refusing to buffer more than the gateway cap.
- The Content-Length header is checked first so an oversized asset is
- rejected before a single chunk is read; the running total is re-checked
- as chunks arrive, because a missing or lying header must not smuggle an
- unbounded body past the cap.
- """
- import httpx
- max_bytes = get_inbound_media_max_bytes()
- chunks: List[bytes] = []
- total = 0
- async with httpx.AsyncClient(
- timeout=ChattoConstants.HTTP_TIMEOUT, follow_redirects=True,
- ) as http:
- async with http.stream("GET", url) as resp:
- resp.raise_for_status()
- declared = resp.headers.get("content-length")
- if declared:
- try:
- declared_size = int(declared)
- except ValueError:
- logger.debug("Chatto: ignoring invalid Content-Length %r", declared)
- else:
- validate_inbound_media_size(
- declared_size, media_type="attachment", max_bytes=max_bytes,
- )
- async for chunk in resp.aiter_bytes():
- total += len(chunk)
- validate_inbound_media_size(
- total, media_type="attachment", max_bytes=max_bytes,
- )
- chunks.append(chunk)
- return b"".join(chunks)
- async def _cache_attachments(
- self, room_id: str, attachments: List[Any],
- ) -> Tuple[List[str], List[str], List[str]]:
- """Download message attachments into the gateway media cache.
- Returns ``(media_urls, media_types, media_kinds)`` — the paths are
- agent-visible cache paths, exactly what ``cache_media_bytes`` yields for
- every other platform. A failing attachment is logged and skipped: the
- message itself still reaches the agent.
- """
- media_urls: List[str] = []
- media_types: List[str] = []
- media_kinds: List[str] = []
- for att in attachments or []:
- asset_url = getattr(att, "asset_url", None)
- url = getattr(asset_url, "url", "") if asset_url else ""
- filename = getattr(att, "filename", "") or ""
- content_type = getattr(att, "content_type", "") or ""
- if not url:
- # Videos are announced before transcoding finishes, so the
- # signed URL can legitimately be missing on arrival.
- logger.info(
- "Chatto: attachment '%s' has no asset URL yet, skipping", filename,
- )
- continue
- try:
- data = await self._download_attachment_bytes(url)
- cached = cache_media_bytes(
- data, filename=filename, mime_type=content_type,
- )
- except Exception as e:
- logger.warning(
- "Chatto: failed to cache attachment '%s' (%s): %s",
- filename, content_type, e,
- )
- continue
- if cached is None:
- logger.warning(
- "Chatto: attachment '%s' (%s) could not be cached, skipping",
- filename, content_type,
- )
- continue
- media_urls.append(cached.path)
- media_types.append(cached.media_type)
- media_kinds.append(cached.kind)
- return media_urls, media_types, media_kinds
- @staticmethod
- def _message_type_for_media_kinds(media_kinds: List[str]) -> MessageType:
- """Pick the MessageType for a set of cached attachment kinds."""
- if "document" in media_kinds:
- return MessageType.DOCUMENT
- if "image" in media_kinds:
- return MessageType.PHOTO
- if "video" in media_kinds:
- return MessageType.VIDEO
- if "audio" in media_kinds:
- return MessageType.AUDIO
- return MessageType.TEXT
- async def _dispatch_message_posted(self, payload: MessagePostedPayload) -> None:
- try:
- client = await self._require_client()
- except RuntimeError:
- logger.warning("Chatto: chattolib event loop aborted - no client available")
- return
-
- logger.info("Chatto WS: 'message_posted' event_payload:%s", payload)
- message = await payload.fetch_message(client=client)
- if message is None or message.deleted_at:
- return
- message_body = message.body or ""
- attachments = list(message.attachments or [])
- # A message carrying only an image/PDF has an empty body — dropping it
- # here is what made attachments sent to Hermes disappear silently.
- if not message_body and not attachments:
- return
- if message.actor_id in self._user_cache:
- # try the user cache.
- user = self._user_cache.get(message.actor_id)
- else:
- # get the user and update cache.
- directory_member = await client.get_user(user_id=message.actor_id)
- if directory_member is None:
- return
- user = directory_member.user
- if user is None:
- return
- self._user_cache[user.id] = user
- if user is None:
- return
- if not self._check_auth(user):
- return
-
- # Todo: use a function that either reads from cache or gets room kind again.
- if self._room_kinds.get(message.room_id) is None:
- room_viewer_state = await client.get_room(message.room_id)
- if room_viewer_state is None:
- return
- if room_viewer_state.room is None:
- return
- self._room_kinds[message.room_id] = room_viewer_state.room.kind or RoomKind.UNSPECIFIED
- room_kind = self._room_kinds.get(message.room_id)
- logger.info("message_body: %s room_kind: %s", message_body, room_kind)
- # require_mention deliberately gates channels only: in a channel the bot
- # is one of many listeners and must be addressed, whereas a DM is already
- # addressed at it — so DMs are always answered, mention or not.
- mentioned = False
- if (room_kind == RoomKind.CHANNEL and self.chatto_config.require_mention.value and self.me):
- if self.me.login and not mentioned:
- mentioned = bool(f"@{self.me.login}" in message_body)
- if self.me.display_name and not mentioned:
- mentioned = bool(f"@{self.me.display_name}" in message_body)
- if mentioned is False:
- logger.debug("Discarding message. Bot was not mentionend but require_mention is '%s'.", self.chatto_config.require_mention.value)
- return
-
- logger.info("mentioned: %s", mentioned)
- # With require_mention off we see every message in the channel, including
- # ones plainly aimed at a named colleague. Answering those would be
- # barging in, so acknowledge that we read it and stay quiet. Checked
- # after the bot-mention test above, so a message naming us *and* someone
- # else still counts as ours.
- if (
- room_kind == RoomKind.CHANNEL
- and not self.chatto_config.require_mention.value
- and not self._mentions_me(message_body)
- and await self._mentions_someone_else(message_body)
- ):
- logger.info("Chatto: message addresses someone else, acknowledging only")
- if self.chatto_config.reactions.value:
- await self.add_reaction(message.room_id, message.id, "🫥")
- return
- # Thread anchoring — if the incoming message is inside a thread, we
- # keep that thread by default; otherwise leave thread_id unset so
- # replies land at the root.
- thread_id = payload.thread_root_event_id or None
- if not thread_id and room_kind != RoomKind.DM and self.chatto_config.auto_thread.value:
- thread_id = message.id
-
- source = self.build_source(
- chat_id=payload.room_id,
- chat_name=self._room_names.get(message.room_id),
- 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,
- message_id=payload.message_event_id,
- role_authorized=True,
- )
- msg_type = MessageType.COMMAND if (message_body.lstrip().startswith("/")) else MessageType.TEXT
- my_body = message_body.lstrip() if msg_type == MessageType.COMMAND else message_body
- # Attachments — download and hand the local cache paths to the gateway,
- # which runs vision enrichment / document extraction off media_urls.
- media_urls, media_types, media_kinds = await self._cache_attachments(
- payload.room_id, attachments,
- )
- if media_kinds and msg_type != MessageType.COMMAND:
- # Same precedence as the Teams/Signal adapters: document-context
- # injection gates strictly on DOCUMENT, image handling keys off the
- # per-path image/* MIME regardless of message_type.
- msg_type = self._message_type_for_media_kinds(media_kinds)
- message_event = MessageEvent(
- text=my_body,
- source=source,
- message_id=message.id,
- message_type=msg_type,
- media_urls=media_urls,
- media_types=media_types,
- timestamp=message.created_at or datetime.now(timezone.utc),
- raw_message=message,
- reply_to_message_id=message.id,
- reply_to_text=message_body,
- reply_to_author_id=user.id,
- reply_to_author_name=user.login,
- )
- logger.info("Chatto: Dispatching MessageEvent to Hermes: %s", message_event)
- await self.handle_message(message_event)
- return
- async def _forward_reaction(
- self, event: RealtimeEvent, payload: ReactionPayload, *, removed: bool,
- ) -> None:
- """Forward a human reaction to the gateway's reaction hook surface.
- The handler is registered by the gateway via ``set_reaction_handler``
- and fans out as ``reaction:added`` / ``reaction:removed`` through the
- HookRegistry. The dict shape mirrors the Slack adapter's — hook
- consumers are written against that contract, not against a per-platform
- one. Our own lifecycle reactions (👀/✅/❌) are dropped: forwarding them
- would feed the agent its own markers.
- """
- actor_id = event.actor_id
- if actor_id and self.me and actor_id == self.me.id:
- return
- if not payload.room_id or not payload.message_event_id or not actor_id:
- return
- handler = getattr(self, "_reaction_handler", None)
- if handler is None:
- return
- action = "removed" if removed else "added"
- try:
- await handler(
- {
- "platform": ChattoConstants.PLATFORM_NAME,
- "event_name": f"reaction:{action}",
- "reaction": payload.emoji,
- "user_id": actor_id,
- "item_user_id": None,
- "item_type": "message",
- "channel_id": payload.room_id,
- "message_ts": payload.message_event_id,
- "event_ts": event.id,
- "raw_event": event,
- }
- )
- except Exception: # pragma: no cover - the hook contract is non-blocking
- logger.debug("Chatto: reaction hook forwarding failed", exc_info=True)
- async def _handle_realtime_event(self, event: RealtimeEvent) -> None:
- if self._is_seen(event.id):
- return
- if event.actor_id is None:
- return
- logger.info("EVENT happened: '%s' from %s", event.kind, event.actor_id)
- if (event_payload := event.get("message_posted")) is not None:
- # Self-event filter — the actor_id on the envelope is authoritative
- # (chattolib does NOT filter this itself; see chatto-bridge notes).
- actor_id = event.actor_id
- if actor_id and self.me and actor_id == self.me.id:
- return
- await self._dispatch_message_posted(event_payload)
- elif event.kind in ("reaction_added", "reaction_removed"):
- reaction_payload = event.get(event.kind)
- if reaction_payload is not None:
- await self._forward_reaction(
- event,
- cast(ReactionPayload, reaction_payload),
- removed=event.kind == "reaction_removed",
- )
- # confirmed:
- elif event.kind in ("mention_notification", "projection_event", "presence_changed", "notification_dismissed",
- "room_marked_as_read", "user_typing", "notification_created", "new_direct_message_notification",
- "message_edited", "message_retracted"):
- logger.debug("Chatto: '%s' event received. Not yet implemented or not needed.", event.kind)
- else:
- logger.error("Chatto: unknown event kind: '%s'", event.kind)
- async def _chattolib_event_loop(self) -> None:
- """Event loop using chattolib's stream_events.
-
- This replaces the manual WebSocket loop with chattolib's high-level
- stream_events() which provides pre-decoded RealtimeEvent objects.
- """
- delay = ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF
- while not self._closing:
- try:
- client = await self._require_client()
- await self._refresh_rooms()
- logger.info("Chatto: starting chattolib event stream with %d rooms", len(self._watch_room_ids))
- async for event in stream_events(client):
- if self._closing:
- return
- await self._handle_realtime_event(event)
- # Iterator exited cleanly — treat as a normal close and reconnect
- # with the local backoff (no server hint available).
- logger.info("Chatto: realtime stream ended, reconnecting")
- except asyncio.CancelledError:
- return
- except ChattoRealtimeCloseError as exc:
- if not exc.reconnect:
- logger.error(
- "Chatto: realtime closed by server (%s: %s), not reconnecting",
- exc.code, exc.message,
- )
- return
- wait = max(exc.retry_after_ms / 1000.0, ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF)
- logger.warning(
- "Chatto: realtime closed by server (%s), reconnecting in %.1fs",
- exc.code, wait,
- )
- delay = ChattoConstants.WS_RECONNECT_INITIAL_BACKOFF # server hint supersedes local backoff
- await self._sleep_interruptible(wait)
- continue
- except ChattoRealtimeError as exc:
- if getattr(exc, "fatal", False):
- logger.error("Chatto: fatal realtime error (%s): %s", exc.code, exc.message)
- return
- logger.warning(
- "Chatto: realtime error (%s: %s), reconnecting in %.1fs",
- exc.code, exc.message, delay,
- )
- except Exception as exc:
- logger.warning(
- "Chatto: unexpected realtime error: %s, reconnecting in %.1fs",
- exc, delay,
- )
- if self._closing:
- return
- jitter = delay * 0.2 * random.random()
- await self._sleep_interruptible(delay + jitter)
- delay = min(delay * 2, ChattoConstants.WS_RECONNECT_MAX_BACKOFF)
- async def _sleep_interruptible(self, seconds: float) -> None:
- """Sleep in short slices so disconnect() cancels promptly."""
- end = asyncio.get_running_loop().time() + seconds
- while not self._closing:
- remaining = end - asyncio.get_running_loop().time()
- if remaining <= 0:
- return
- await asyncio.sleep(min(remaining, 0.5))
- async def _refresh_rooms(self) -> None:
- """Refresh room list via ConnectRPC, join and seed any newly discovered rooms."""
- try:
- client = await self._require_client()
- except RuntimeError:
- logger.warning("Chatto WS: _refresh_rooms aborted - no client available")
- return
- try:
- rooms_list = await client.list_rooms()
- new_room_ids: List[str] = []
- for room_with_state in rooms_list:
- if not room_with_state:
- continue
-
- room_obj = room_with_state.room or None
- if not room_obj:
- continue
-
- self._room_names[room_obj.id] = room_obj.name
- self._room_kinds[room_obj.id] = room_obj.kind
- if room_with_state.viewer_state.is_member and room_obj.id not in self._watch_room_ids:
- new_room_ids.append(room_obj.id)
- if not new_room_ids:
- return
- logger.info("Chatto WS: discovered %d new room(s): %s", len(new_room_ids), new_room_ids)
- for rid in new_room_ids:
- if self._room_kinds.get(rid) != RoomKind.DM:
- await client.join_room(rid) # but list_rooms() would not return any if we were not already joined?!
- await self._seed_room(rid)
- self._watch_room_ids.append(rid)
- watch_room_names: list[str] = []
- for rid in self._watch_room_ids:
- watch_room_names.append(self._room_names[rid] + " (" + rid + ")")
- logger.info("Chatto WS: Watching %d room(s): %s", len(self._watch_room_ids), ", ".join(watch_room_names))
- except Exception:
- logger.warning("Chatto WS: _refresh_rooms failed", exc_info=True)
- # ------------------------------------------------------------------ #
- # Read state & notification dismissal (best-effort, Chatto-unique)
- # ------------------------------------------------------------------ #
- # Best-effort: mark all watched rooms as read (room_id may be undefined here)
- for _rid in list(self._watch_room_ids):
- try:
- await client.mark_room_as_read(room_id=_rid)
- await client.dismiss_all_notifications()
- except Exception:
- logger.debug("Chatto: mark_room_as_read failed for %s", _rid, exc_info=True)
- # ------------------------------------------------------------------ #
- # Sending (ConnectRPC — unchanged from polling version)
- # ------------------------------------------------------------------ #
- async def send(
- self,
- chat_id: str,
- content: str,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- ) -> SendResult:
- """Send a message to a Chatto room.
- Long messages are split into chunks via ``truncate_message`` and
- each chunk is sent as a separate CreateMessage call. The first
- chunk's message ID is returned as ``message_id``.
- When ``auto_thread`` is enabled and the incoming message was a
- regular room message (not already in a thread), the first chunk is
- sent as a room message and its ID becomes the thread root. Subsequent
- chunks are sent in that thread. This mirrors Discord's auto_thread
- behavior.
- BasePlatformAdapter override
- """
- if not content:
- return SendResult(success=False, error="Empty message")
- formatted = self.format_message(content)
- chunks = self.truncate_message(formatted, ChattoConstants.MAX_MESSAGE_LENGTH)
- # Thread support — resolve thread_id once
- # DM rooms don't support threads, so skip threading for DMs
- thread_id = (metadata or {}).get("thread_id")
- # Only use reply_to as thread_id if auto_thread is enabled.
- # When auto_thread=false, responses go directly in the room
- # without threading under the incoming message.
- if reply_to and self.chatto_config.auto_thread.value:
- # reply_to might be the incoming message ID. If we already have
- # thread_id from metadata, keep it (it's the thread root).
- # Only use reply_to as thread_id if we don't already have one.
- if not thread_id:
- thread_id = reply_to
- # Check if this is a DM room — DMs don't support threads
- room_kind = self._room_kinds.get(chat_id)
- is_dm = room_kind == RoomKind.DM
- if is_dm:
- thread_id = None
- # Auto-thread: by default, Chatto creates a thread for replies to room
- # messages (not DMs, not already in a thread). This keeps conversations
- # organized in the room. Can be disabled via extra.auto_thread=false.
- use_auto_thread = self.chatto_config.auto_thread.value and not thread_id and not is_dm
- message_ids: List[str] = []
- last_resp: Optional[Any] = None
- last_error: Optional[str] = None
- retryable = False
- try:
- client = await self._require_client()
- except RuntimeError:
- return SendResult(success=False, error="Chatto client not available", retryable=True)
- for i, chunk in enumerate(chunks):
- try:
- msg_obj = await client.post_message(
- room_id=chat_id,
- body=chunk,
- thread_root_event_id=str(thread_id) if thread_id else "",
- )
- except ChattoError as e:
- last_error = str(e)
- retryable = True
- break
- except Exception as e:
- last_error = str(e)
- retryable = True
- break
- last_resp = msg_obj
- self._mark_seen(msg_obj.id)
- message_ids.append(msg_obj.id)
- self._our_message_ids.add(msg_obj.id)
- # If we sent a message WITHOUT a thread_id, this message could
- # become a thread root if someone replies to it
- if not thread_id:
- self._our_thread_roots.add(msg_obj.id)
- # Auto-thread: first chunk becomes the thread root,
- # subsequent chunks go in the thread
- if use_auto_thread and i == 0 and not thread_id:
- thread_id = msg_obj.id
- # Nothing got through at all — report the failure instead of a phantom success.
- if not message_ids:
- return SendResult(
- success=False,
- error=last_error or "Chatto: message could not be sent",
- retryable=retryable,
- )
- first_id = message_ids[0]
- # ------------------------------------------------------------------ #
- # Thread following (best-effort, Chatto-unique)
- # ------------------------------------------------------------------ #
- if thread_id:
- try:
- await client.follow_thread(chat_id, thread_id)
- except Exception:
- logger.debug("Chatto: follow_thread failed for %s/%s", chat_id, thread_id, exc_info=True)
- # A later chunk failed after earlier ones went out: partial delivery.
- if last_error:
- logger.warning(
- "Chatto: sent %d/%d chunk(s) to %s before failing: %s",
- len(message_ids), len(chunks), chat_id, last_error,
- )
- return SendResult(success=True, message_id=first_id, raw_response=last_resp)
- def format_message(self, content: str) -> str:
- """Normalise outgoing text for Chatto.
- Chatto renders Markdown natively, so there is nothing to escape or
- translate — the only transformations here are the ones that measurably
- render wrong: CRLF line endings (which show up as stray blank lines)
- and runs of more than two blank lines.
- BasePlatformAdapter override
- """
- if not content:
- return content
- normalised = content.replace("\r\n", "\n").replace("\r", "\n")
- while "\n\n\n\n" in normalised:
- normalised = normalised.replace("\n\n\n\n", "\n\n\n")
- return normalised
- async def edit_message(
- self,
- chat_id: str,
- message_id: str,
- content: str,
- *,
- finalize: bool = False,
- ) -> SendResult:
- """Edit a message we previously sent, via MessageService/UpdateMessage.
- The stream consumer drives streaming replies through this: without the
- override the base class reports "Not supported" and every incremental
- update arrives as a *new* message.
- ``finalize`` is a no-op for Chatto — an edit is an edit here, there is
- no in-progress card state to close out (hence no
- ``REQUIRES_EDIT_FINALIZE``).
- Content that exceeds the per-message limit is refused rather than
- silently truncated, so the caller falls back to ``send()``, which
- splits across messages.
- BasePlatformAdapter override
- """
- if not message_id:
- return SendResult(success=False, error="Chatto: no message id to edit")
- if not content:
- return SendResult(success=False, error="Empty message")
- formatted = self.format_message(content)
- if len(formatted) > ChattoConstants.MAX_MESSAGE_LENGTH:
- # Refuse instead of truncating: the caller's fallback path splits.
- return SendResult(
- success=False,
- error=(
- f"Chatto: edit exceeds {ChattoConstants.MAX_MESSAGE_LENGTH} "
- f"chars ({len(formatted)})"
- ),
- )
- try:
- client = await self._require_client()
- except RuntimeError:
- return SendResult(success=False, error="Chatto client not available", retryable=True)
- try:
- msg = await client.update_message(
- room_id=str(chat_id),
- event_id=str(message_id),
- body=formatted,
- )
- except ChattoError as e:
- logger.warning("Chatto: UpdateMessage failed for %s: %s", message_id, e)
- return SendResult(success=False, error=str(e), retryable=True)
- except Exception as e:
- logger.warning("Chatto: UpdateMessage error for %s: %s", message_id, e)
- return SendResult(success=False, error=str(e), retryable=False)
- # Our own edit comes back as a message_edited event; mark it seen so it
- # is never mistaken for inbound traffic.
- edited_id = getattr(msg, "id", "") or str(message_id)
- self._mark_seen(edited_id)
- return SendResult(success=True, message_id=edited_id, raw_response=msg)
- async def delete_message(self, chat_id: str, message_id: str) -> bool:
- """Delete a message via MessageService/DeleteMessage.
- Used by the stream consumer's fresh-final cleanup (removing a preview
- message once the completed reply has been sent) and by the ephemeral
- reply TTL.
- BasePlatformAdapter override
- """
- if not chat_id or not message_id:
- return False
- try:
- client = await self._require_client()
- except RuntimeError:
- logger.warning("Chatto: DeleteMessage — client unavailable")
- return False
- try:
- return bool(await client.delete_message(
- room_id=str(chat_id), event_id=str(message_id),
- ))
- except ChattoError as e:
- logger.warning("Chatto: DeleteMessage failed for %s: %s", message_id, e)
- return False
- except Exception as e:
- logger.warning("Chatto: DeleteMessage error for %s: %s", message_id, e)
- return False
- async def create_handoff_thread(
- self, parent_chat_id: str, name: str,
- ) -> Optional[str]:
- """Anchor a session handoff in a fresh thread under *parent_chat_id*.
- Chatto threads hang off a message, not off the room, so we post a seed
- message and hand its ID back as the thread root — the same shape the
- Slack adapter uses. DMs don't support threads, so they get ``None``
- and the watcher keeps delivering into the DM itself.
- BasePlatformAdapter override
- """
- if not parent_chat_id:
- return None
- if self._room_kinds.get(parent_chat_id) == RoomKind.DM:
- logger.debug("Chatto: handoff thread skipped — %s is a DM", parent_chat_id)
- return None
- try:
- client = await self._require_client()
- except RuntimeError:
- logger.warning("Chatto: handoff thread — client unavailable")
- return None
- seed_text = f"🧵 Hermes handoff — **{(name or 'session').strip()[:80]}**"
- try:
- msg = await client.post_message(room_id=str(parent_chat_id), body=seed_text)
- except Exception as e:
- logger.warning(
- "Chatto: handoff thread seed-post failed for room %s: %s",
- parent_chat_id, e,
- )
- return None
- seed_id = getattr(msg, "id", "") or ""
- if not seed_id:
- logger.warning("Chatto: handoff thread seed-post returned no message id")
- return None
- self._mark_seen(seed_id)
- self._our_message_ids.add(seed_id)
- self._our_thread_roots.add(seed_id)
- try:
- await client.follow_thread(str(parent_chat_id), seed_id)
- except Exception:
- logger.debug(
- "Chatto: follow_thread failed for handoff %s/%s",
- parent_chat_id, seed_id, exc_info=True,
- )
- return seed_id
- # Overridden from BaseAdapter:
- async def send_typing(self, chat_id: str, metadata=None) -> None:
- """Start a persistent typing indicator for a room.
- Sends a typing ping every 10 seconds (Chatto's indicator likely
- lasts ~8-10s). The background loop runs until ``stop_typing()``
- is called or the task is cancelled.
- BasePlatformAdapter override
- """
- if chat_id in self._typing_tasks:
- return # already running
- async def _typing_loop() -> None:
- try:
- while True:
- try:
- try:
- client = await self._require_client()
- except RuntimeError:
- return
- await client.update_typing_indicator(room_id=str(chat_id))
- except asyncio.CancelledError:
- return
- except Exception:
- pass
- await asyncio.sleep(10)
- except asyncio.CancelledError:
- pass
- finally:
- self._typing_tasks.pop(chat_id, None)
- self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
- async def stop_typing(self, chat_id: str) -> None:
- """Stop the persistent typing indicator for a room.
- BasePlatformAdapter override
- """
- task = self._typing_tasks.pop(chat_id, None)
- if task:
- task.cancel()
- try:
- await task
- except (asyncio.CancelledError, Exception):
- pass
- async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
- """Get information about a chat/room.
- BasePlatformAdapter override
- """
- name = self._room_names.get(chat_id, chat_id)
- kind = self._room_kinds.get(chat_id)
- return {
- "name": name,
- "type": chat_type_for_room_kind(kind).value,
- }
- # ------------------------------------------------------------------ #
- # Reactions
- # ------------------------------------------------------------------ #
- @staticmethod
- def _emoji_to_shortcode(emoji: str) -> str:
- """Convert a unicode emoji to a Chatto shortcode name.
- If the emoji is already a shortcode (no unicode mapping found),
- return it as-is.
- """
- shortcode = ChattoConstants.EMOJI_TO_SHORTCODE.get(emoji)
- if shortcode:
- return shortcode
- # Already a shortcode like "thumbsup" — return as-is
- return emoji
- async def add_reaction(self, room_id: str, message_id: str, emoji: str) -> bool:
- """Add a reaction to a message via MessageService/AddReaction."""
- shortcode = self._emoji_to_shortcode(emoji)
- try:
- try:
- client = await self._require_client()
- except RuntimeError:
- logger.warning("Chatto: AddReaction — client unavailable")
- return False
- result = await client.add_reaction(
- room_id=room_id,
- message_event_id=message_id,
- emoji=shortcode,
- )
- return result
- except ChattoError as e:
- logger.warning("Chatto: AddReaction failed: %s", e)
- return False
- except Exception as e:
- logger.warning("Chatto: AddReaction error: %s", e)
- return False
- async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
- """Remove a reaction from a message via MessageService/RemoveReaction."""
- shortcode = self._emoji_to_shortcode(emoji)
- try:
- try:
- client = await self._require_client()
- except RuntimeError:
- logger.warning("Chatto: RemoveReaction — client unavailable")
- return False
- result = await client.remove_reaction(
- room_id=str(chat_id),
- message_event_id=str(message_id),
- emoji=shortcode,
- )
- return result
- except ChattoError as e:
- logger.warning("Chatto: RemoveReaction failed: %s", e)
- return False
- except Exception as e:
- logger.warning("Chatto: RemoveReaction error: %s", e)
- return False
- # ------------------------------------------------------------------ #
- # DM initiation (Chatto-unique)
- # ------------------------------------------------------------------ #
- async def start_dm(self, user_id: str) -> Optional[str]:
- """Start a direct message with a user via RoomService/StartDM.
- Returns the room ID on success, or None on failure.
- """
- if not user_id:
- return None
- try:
- try:
- client = await self._require_client()
- except RuntimeError:
- return None
- room = await client.start_dm(participant_ids=[str(user_id)])
- self._room_names[room.id] = room.name
- self._room_kinds[room.id] = room.kind
- return room.id
- except ChattoError as e:
- logger.debug("Chatto: StartDM failed: %s", e)
- return None
- except Exception as e:
- logger.debug("Chatto: StartDM error: %s", e)
- return None
- # ------------------------------------------------------------------ #
- # Room creation (Chatto-unique)
- # ------------------------------------------------------------------ #
- async def create_room(
- self,
- name: str,
- description: str = "",
- group_id: str = "",
- universal: bool = True,
- ) -> Optional[str]:
- """Create an ad-hoc room via RoomService/CreateRoom.
- Returns the room ID on success, or None on failure.
- """
- try:
- try:
- client = await self._require_client()
- except RuntimeError:
- return None
- room = await client.create_room(
- name=name,
- group_id=group_id or "",
- description=description,
- universal=universal,
- )
- rid = str(room.id) if room else ""
- if rid:
- self._room_names[rid] = room.name
- self._room_kinds[rid] = room.kind
- return rid
- logger.debug("Chatto: CreateRoom returned no room id")
- return None
- except ChattoError as e:
- logger.debug("Chatto: CreateRoom failed: %s", e)
- return None
- except Exception as e:
- logger.debug("Chatto: CreateRoom error: %s", e)
- return None
- # ------------------------------------------------------------------ #
- # Processing lifecycle hooks (reactions-based, like Discord)
- # ------------------------------------------------------------------ #
- def _event_room_and_message_id(self, event: MessageEvent) -> Tuple[str, str]:
- """Extract room_id and message_id from a MessageEvent."""
- message_id = event.message_id or ""
- chat_id = event.source.chat_id
- return chat_id, message_id
- async def on_processing_start(self, event: MessageEvent) -> None:
- """Add an 👀 (eyes) reaction to the incoming message.
- BasePlatformAdapter override
- """
- if not self.chatto_config.reactions.value:
- return
- chat_id, message_id = self._event_room_and_message_id(event)
- if not chat_id or not message_id:
- # Routine, not a fault: the gateway runs agent-initiated turns
- # (heartbeat polls, goal continuations) through the same pipeline
- # with message_id=None, and there is no inbound message to mark.
- logger.debug(
- "Chatto: nothing to react to (chat_id=%r, message_id=%r)",
- chat_id, message_id,
- )
- return
- await self.add_reaction(chat_id, message_id, "👀")
- async def on_processing_complete(
- self, event: MessageEvent, outcome: ProcessingOutcome
- ) -> None:
- """Swap the 👀 reaction for ✅ (success) or ❌ (failure).
- BasePlatformAdapter override
- """
- if not self.chatto_config.reactions.value:
- return
- chat_id, message_id = self._event_room_and_message_id(event)
- if not chat_id or not message_id:
- return
- # Remove the processing eyes reaction
- await self.remove_reaction(chat_id, message_id, "👀")
- # Add the outcome reaction
- if outcome == ProcessingOutcome.SUCCESS:
- await self.add_reaction(chat_id, message_id, "✅")
- elif outcome == ProcessingOutcome.FAILURE:
- await self.add_reaction(chat_id, message_id, "❌")
- # ------------------------------------------------------------------ #
- # Asset upload (chunked)
- # ------------------------------------------------------------------ #
- async def _upload_asset(self, room_id: str, file_path: str) -> Optional[str]:
- """Upload a file via the chunked AssetUploadService.
- Returns the asset ID on success, or None on failure.
- """
- try:
- with open(file_path, "rb") as f:
- file_data = f.read()
- except Exception as e:
- logger.error("Chatto: failed to read file %s — %s", file_path, e)
- return None
- if not file_data:
- logger.error("Chatto: file %s is empty", file_path)
- return None
- file_size = len(file_data)
- file_name = os.path.basename(file_path)
- mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
- sha256_hash = hashlib.sha256(file_data).hexdigest()
- try:
- try:
- client = await self._require_client()
- except RuntimeError:
- logger.error("Chatto: upload aborted - no client available")
- return None
- # Step 1: Create upload session
- upload = await client.create_upload(
- room_id=room_id,
- filename=file_name,
- size=file_size,
- sha256=sha256_hash,
- content_type=mime_type,
- )
- # AssetUpload names this upload_id, not id — reading it through an
- # untyped getattr default is what let the mismatch reach production.
- upload_id = upload.upload_id
- if not upload_id:
- logger.error("Chatto: CreateUpload returned no upload ID")
- return None
- # Step 2: Upload chunks
- offset = 0
- while offset < file_size:
- chunk = file_data[offset:offset + ChattoConstants.UPLOAD_CHUNK_SIZE]
- chunk_sha256 = hashlib.sha256(chunk).hexdigest()
- await client.upload_chunk(
- upload_id=upload_id,
- offset=offset,
- content=chunk,
- chunk_sha256=chunk_sha256,
- )
- offset += len(chunk)
- # Step 3: Complete upload
- upload, asset = await client.complete_upload(upload_id=upload_id)
- if not asset:
- logger.error("Chatto: CompleteUpload returned no asset")
- return None
- asset_id = str(getattr(cast(Any, asset), "id", ""))
- logger.info("Chatto: uploaded %s as asset %s (%d bytes)", file_name, asset_id, file_size)
- return asset_id
- except ChattoError as e:
- logger.error("Chatto: upload failed: %s", e)
- return None
- except Exception as e:
- logger.error("Chatto: upload error: %s", e)
- return None
- async def _post_attachment_message(
- self,
- chat_id: str,
- asset_ids: List[str],
- caption: Optional[str],
- reply_to: Optional[str],
- metadata: Optional[Dict[str, Any]],
- ) -> SendResult:
- """Post one message carrying already-uploaded assets."""
- thread_id = (metadata or {}).get("thread_id")
- if reply_to:
- thread_id = reply_to
- try:
- try:
- client = await self._require_client()
- except RuntimeError:
- return SendResult(success=False, error="Chatto client not available", retryable=True)
- msg = await client.post_message(
- room_id=str(chat_id),
- body=self.format_message(caption) if caption else "",
- attachment_asset_ids=asset_ids,
- thread_root_event_id=str(thread_id) if thread_id else "",
- )
- self._mark_seen(msg.id)
- self._our_message_ids.add(msg.id)
- return SendResult(success=True, message_id=msg.id, raw_response=msg)
- except ChattoError as e:
- return SendResult(success=False, error=str(e), retryable=True)
- except Exception as e:
- return SendResult(success=False, error=str(e), retryable=False)
- async def _send_local_attachment(
- self,
- chat_id: str,
- file_path: str,
- caption: Optional[str],
- reply_to: Optional[str],
- metadata: Optional[Dict[str, Any]],
- *,
- kind: str,
- ) -> SendResult:
- """Upload a local file and post it as a native Chatto attachment.
- Shared by ``send_image_file``/``send_document``/``send_video``/
- ``send_voice`` — the upload mechanics are identical, only the wording of
- the failure notice differs. On failure we send that notice as text and
- never the host path (it leaks the Hermes home layout).
- """
- notice = f"⚠️ Couldn't deliver the {kind} attachment."
- safe_path = self.validate_media_delivery_path(file_path)
- if not safe_path:
- logger.warning(
- "[%s] send %s: unsafe path %s", self.name, kind, file_path,
- )
- text = f"{caption}\n{notice}" if caption else notice
- return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
- asset_id = await self._upload_asset(str(chat_id), safe_path)
- if not asset_id:
- logger.warning(
- "[%s] send %s: upload failed for %s", self.name, kind, safe_path,
- )
- text = f"{caption}\n{notice}" if caption else notice
- return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
- return await self._post_attachment_message(
- chat_id, [asset_id], caption, reply_to, metadata,
- )
- async def send_image_file(
- self,
- chat_id: str,
- image_path: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- """Send a local image file via the chunked upload API.
- The parameter is ``image_path``, not ``file_path``: every caller passes
- it by keyword (``gateway/run.py:22354``, ``:22470``, and the base class's
- own ``send_multiple_images`` file:// branch), so a renamed parameter
- makes each of those raise TypeError and silently degrade to a text
- notice.
- BasePlatformAdapter override
- """
- return await self._send_local_attachment(
- chat_id, image_path, caption, reply_to, metadata, kind="image",
- )
- async def send_document(
- self,
- chat_id: str,
- file_path: str,
- caption: Optional[str] = None,
- file_name: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- """Send a local file as a native Chatto attachment.
- ``file_name`` is the user-facing name the agent chose; Chatto takes the
- filename from the upload session, so it only matters for the failure
- notice.
- BasePlatformAdapter override
- """
- result = await self._send_local_attachment(
- chat_id, file_path, caption, reply_to, metadata, kind="file",
- )
- if not result.success and file_name:
- logger.debug("Chatto: document delivery failed for %s", file_name)
- return result
- async def send_video(
- self,
- chat_id: str,
- video_path: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- """Send a local video as a native Chatto attachment (Chatto transcodes
- and plays it inline).
- BasePlatformAdapter override
- """
- return await self._send_local_attachment(
- chat_id, video_path, caption, reply_to, metadata, kind="video",
- )
- async def send_voice(
- self,
- chat_id: str,
- audio_path: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- """Send a local audio file as a native Chatto attachment.
- Chatto has no dedicated voice-bubble type, so this is an ordinary audio
- attachment — still far better than the base class's text notice.
- BasePlatformAdapter override
- """
- return await self._send_local_attachment(
- chat_id, audio_path, caption, reply_to, metadata, kind="audio",
- )
- async def send_image(
- self,
- chat_id: str,
- image_url: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- ) -> SendResult:
- """Send an image to a Chatto room.
- Tries to download the image from the URL and upload it as a native
- attachment. Falls back to sending the URL as a link (Chatto renders
- link previews) if the download fails.
- BasePlatformAdapter override
- """
- # Try downloading and uploading as attachment
- try:
- import tempfile
- import urllib.request as _urllib_request
- # Download to a temp file
- parsed = urlsplit(image_url)
- url_path = parsed.path
- ext = os.path.splitext(url_path)[1] or ".png"
- tmp_fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="chatto_img_")
- try:
- os.close(tmp_fd)
- req = _urllib_request.Request(image_url, headers={"User-Agent": "Hermes/1.0"})
- try:
- import ssl
- ctx = ssl.create_default_context()
- except Exception:
- ctx = None
- with _urllib_request.urlopen(req, timeout=ChattoConstants.HTTP_TIMEOUT, context=ctx) as resp:
- with open(tmp_path, "wb") as f:
- f.write(resp.read())
- # Upload as attachment
- result = await self.send_image_file(
- chat_id, tmp_path, caption=caption,
- reply_to=reply_to, metadata=metadata,
- )
- if result.success:
- return result
- finally:
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
- except Exception as e:
- logger.debug("Chatto: send_image download/upload failed, falling back to link: %s", e)
- # Fallback: send as link (Chatto renders link previews)
- text = image_url
- if caption:
- text = f"{caption}\n{image_url}"
- return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
- async def _materialise_image(self, image_url: str) -> Tuple[Optional[str], bool]:
- """Resolve one ``send_multiple_images`` entry to a local file path.
- Accepts ``http(s)://`` URLs (downloaded to a temp file), ``file://``
- URIs and bare paths. Returns ``(path, is_temp)`` — the caller unlinks
- when ``is_temp``. ``(None, False)`` means the entry is unusable.
- """
- import tempfile
- from urllib.parse import unquote as _unquote
- if image_url.startswith(("http://", "https://")):
- parsed = urlsplit(image_url)
- ext = os.path.splitext(parsed.path)[1] or ".png"
- tmp_fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="chatto_img_")
- os.close(tmp_fd)
- try:
- data = await self._download_attachment_bytes(image_url)
- with open(tmp_path, "wb") as f:
- f.write(data)
- except Exception as e:
- logger.warning("Chatto: image download failed for %s: %s", image_url, e)
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
- return None, False
- return tmp_path, True
- local = image_url
- if local.startswith("file://"):
- local = _unquote(urlsplit(local).path)
- return self.validate_media_delivery_path(local), False
- async def send_multiple_images(
- self,
- chat_id: str,
- images: List[Tuple[str, str]],
- metadata: Optional[Dict[str, Any]] = None,
- human_delay: float = 0.0,
- ) -> None:
- """Send a batch of images as ONE message with several attachments.
- The base implementation posts each image separately; a Chatto message
- carries a list of attachment assets, so a batch belongs in a single
- message (and a single notification).
- ``human_delay`` is ignored deliberately — there is only one outbound
- call to pace. Entries that can't be fetched are dropped with a warning;
- if nothing survives, we fall back to the base class so the user still
- gets the links.
- BasePlatformAdapter override
- """
- if len(images or []) < 2:
- await super().send_multiple_images(
- chat_id, images, metadata=metadata, human_delay=human_delay,
- )
- return
- asset_ids: List[str] = []
- captions: List[str] = []
- for image_url, alt_text in images:
- path, is_temp = await self._materialise_image(image_url)
- if not path:
- logger.warning("Chatto: skipping unusable image %s", image_url)
- continue
- try:
- asset_id = await self._upload_asset(str(chat_id), path)
- finally:
- if is_temp:
- try:
- os.unlink(path)
- except OSError:
- pass
- if not asset_id:
- logger.warning("Chatto: upload failed for image %s", image_url)
- continue
- asset_ids.append(asset_id)
- if alt_text:
- captions.append(alt_text)
- if not asset_ids:
- logger.warning(
- "Chatto: no image survived upload, falling back to per-image delivery",
- )
- await super().send_multiple_images(
- chat_id, images, metadata=metadata, human_delay=human_delay,
- )
- return
- if len(asset_ids) < len(images):
- logger.warning(
- "Chatto: sending %d of %d images — the rest could not be uploaded",
- len(asset_ids), len(images),
- )
- await self._post_attachment_message(
- chat_id, asset_ids, "\n".join(captions) or None, None, metadata,
- )
- # ---------------------------------------------------------------------------
- # Cron / out-of-process delivery
- # ---------------------------------------------------------------------------
- async def hermes_standalone_sender_fn(
- pconfig,
- chat_id,
- message,
- *,
- thread_id=None,
- media_files=None,
- force_document=False,
- ) -> SendResult:
- """Deliver a message to Chatto without a running gateway adapter. Do not modify signature.
- Used by cron / scheduled routines that run out-of-process. Creates a
- short-lived chattolib client, posts, and closes.
- """
- chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig=pconfig)
- # Create a temporary client for standalone sending — we need a base URL plus
- # either a token or a full login/password pair.
- has_credentials = bool(
- chatto_config.token.value
- or (chatto_config.login.value and chatto_config.password.value)
- )
- if not chatto_config.base_url.value or not has_credentials:
- return SendResult(success=False, error="Chatto: base URL or credentials missing")
- client: ChattoClient
- try:
- if chatto_config.token.value:
- client = ChattoClient(base_url=chatto_config.base_url.value, token=chatto_config.token.value)
- else:
- client = await ChattoClient.login(
- base_url=chatto_config.base_url.value,
- login=chatto_config.login.value,
- password=chatto_config.password.value,
- )
- except Exception as exc:
- return SendResult(success=False, error=f"Chatto login failed: {exc}")
- try:
- kwargs: Dict[str, Any] = {}
- if chatto_config.auto_thread.value and thread_id:
- kwargs["in_reply_to"] = thread_id
- if media_files and media_files.get("attachment_asset_ids"):
- kwargs["attachment_asset_ids"] = list(media_files["attachment_asset_ids"])
- try:
- posted = await client.post_message(chat_id, message, **kwargs)
- except Exception as exc:
- return SendResult(success=False, error=str(exc))
- return SendResult(success=True, message_id=getattr(posted, "id", "") or None)
- finally:
- try:
- await client.close()
- except Exception as exc:
- logger.error(
- "Chatto standalone: error closing short-lived client (perhaps already closed): %s", exc,
- )
- def hermes_validate_config(config: PlatformConfig) -> bool:
- """"
- Function name should be the same as register argument name with "hermes_" prefix, so we
- know that it is needed for plugin register(). Do not change signature.
- - config
- Check whether Chatto Plugin is configured. Compare to hermes_is_connected()."""
- chatto_config = ChattoConfiguration(pconfig=config)
- if len(chatto_config.allowed_users.value) > 0 and chatto_config.allow_all_users.value:
- logger.info("Chatto: Conflicting configuration. Either use 'allowed_users' or 'allow_all_users' but not both.")
- return False
- if chatto_config.base_url.value:
- if (chatto_config.token.value is not None) or (chatto_config.login.value and chatto_config.password.value):
- return True
- else:
- logger.error("Chatto: Minimally, either token or login/password must be set.")
- else:
- logger.error("Chatto: base_url must be set.")
- return False
- def hermes_check_fn() -> bool:
- """Check if Chatto is configured and dependencies are available.
- Add real logic?! Or just .. there are no dependencies.. always return true. Really. Docs suck."""
- try:
- import chattolib.client # noqa: F401 — vendored dependency probe
- return True
- except ImportError:
- return False
- # ---------------------------------------------------------------------------
- # is_connected probe
- # ---------------------------------------------------------------------------
- def hermes_is_connected(config: PlatformConfig) -> bool:
- """Check whether Chatto Plugin is connected. But where to? To the Hermes Agent? To Chatto Server?
- The Hermes Agent plugin docs suck and it seems there are many functions to do the same."""
- return bool(hermes_validate_config(config) and config.enabled)
- def hermes_setup_fn() -> None:
- """Interactive setup wizard for Chatto. Is called by and only works in Hermes CLI context.
- Function name should be the same as register argument name with "hermes_" prefix, so we
- know that it is needed for plugin register().
- """
- from hermes_cli.setup import (
- prompt,
- prompt_yes_no,
- save_env_value,
- get_env_value,
- print_header,
- print_info,
- print_warning,
- print_success,
- )
- url = prompt(
- "Chatto server URL (e.g. https://chat.example.com) or leave blank for default ChattoHQ on chat.chatto.run:")
- if url:
- save_env_value(ChattoConfiguration.base_url.env_name, url)
- login = prompt("Chatto login (username):")
- if login:
- save_env_value(ChattoConfiguration.login.env_name, login)
- password = prompt("Chatto password:", password=True)
- if password:
- save_env_value(ChattoConfiguration.password.env_name, password)
- channels = prompt("Room IDs to watch (comma-separated, or empty for all):")
- if channels:
- save_env_value(ChattoConfiguration.channels_list.env_name, channels)
- home = prompt("Home room ID for notifications (or empty):")
- if home:
- save_env_value(ChattoConfiguration.home_channel.env_name, home)
- allow_all = prompt_yes_no("Allow all users to talk? (true/false):")
- if allow_all:
- save_env_value(ChattoConfiguration.allow_all_users.env_name, str(allow_all))
- print_success("\n✓ Chatto configured. Restart the gateway to activate.")
- def hermes_env_enablement_fn() -> Optional[dict]:
- """Seed PlatformConfig.extra from env vars.
- Returns a dict compatible with the PlatformConfig merge hook (or None
- when no env-provided values are present).
- Called by the platform registry during load_gateway_config().
- Return None when the platform isn't minimally configured — the
- caller then skips auto-enabling. Return a dict to seed extras.
- The special 'home_channel' key is extracted and becomes a proper
- HomeChannel dataclass on the PlatformConfig; every other key is
- merged into PlatformConfig.extra.
- Function name should be the same as register argument name with "hermes_" prefix, so we
- know that it is needed for plugin register().
- """
- # Seed keys must be the config.yaml "extra" keys (config_key), NOT the env
- # var names — ChattoConfiguration reads extra[config_key].
- seed: Dict[str, Any] = {
- ChattoConfiguration.base_url.config_key: (
- os.getenv(ChattoConfiguration.base_url.env_name) or ChattoClient.DEFAULT_BASE_URL
- ).strip(),
- }
- for field in ChattoConfiguration.fields():
- if field.field_name == ChattoConfiguration.base_url.field_name:
- continue
- env_value = os.getenv(field.env_name)
- if env_value:
- seed[field.config_key] = env_value.strip()
- logger.debug("seed: %s", {k: v for k, v in seed.items() if k not in ("token", "password")})
- return seed
- # ---------------------------------------------------------------------------
- # Plugin registration entry point
- # ---------------------------------------------------------------------------
- # What each capability is called in the startup banner, keyed by the method
- # that implements it. Derived from real overrides rather than hard-coded, so
- # dropping a method drops its claim from the log instead of leaving a lie.
- _CAPABILITY_LABELS = {
- "send": "text",
- "send_image_file": "images",
- "send_multiple_images": "image batches (bundled into one message)",
- "send_video": "video",
- "send_voice": "voice messages",
- "send_document": "documents",
- "add_reaction": "reactions",
- "edit_message": "message editing",
- "delete_message": "message deletion",
- "send_typing": "typing indicators",
- "create_handoff_thread": "threads",
- "start_dm": "direct messages",
- "create_room": "room creation",
- }
- def _capabilities() -> List[str]:
- """Name the things this adapter genuinely implements itself.
- A capability counts only when ChattoAdapter overrides the base method —
- inheriting BasePlatformAdapter's fallback means the feature is not
- natively supported, and announcing it would mislead.
- """
- found = [
- label
- for name, label in _CAPABILITY_LABELS.items()
- if getattr(ChattoAdapter, name, None) is not getattr(BasePlatformAdapter, name, None)
- ]
- if ChattoAdapter.supports_code_blocks:
- found.append("code blocks")
- if ChattoAdapter.supports_status_text:
- found.append("custom status text")
- found.append("presence (refreshed while connected)")
- return found
- def register(ctx) -> None:
- """Plugin entry point — called by the Hermes plugin system."""
- logger.info("Registering Chatto platform plugin on Hermes Agent")
- for capability in _capabilities():
- logger.info("Chatto capability: %s", capability)
- logger.info("ChattoConfiguration.allowed_users.env_name: %s", ChattoConfiguration.allowed_users.env_name)
- ctx.register_platform(
- name=ChattoConstants.PLATFORM_NAME, # this will be the config.yaml key.
- label=ChattoConstants.PLATFORM_LABEL,
- adapter_factory=hermes_adapter_factory,
- check_fn=hermes_check_fn,
- validate_config=hermes_validate_config,
- is_connected=hermes_is_connected,
- install_hint=ChattoConstants.INSTALL_HINT,
- env_enablement_fn=hermes_env_enablement_fn,
- setup_fn=hermes_setup_fn,
- cron_deliver_env_var=ChattoConfiguration.home_channel.env_name,
- standalone_sender_fn=hermes_standalone_sender_fn,
- allowed_users_env=ChattoConfiguration.allowed_users.env_name,
- allow_all_env=ChattoConfiguration.allow_all_users.env_name,
- max_message_length=ChattoConstants.MAX_MESSAGE_LENGTH,
- emoji="💬",
- allow_update_command=True,
- pii_safe=False,
- platform_hint=(
- "Using the 'Hermes Chatto Platform Plugin' you connect to a Chatto server. "
- "Authorized admins and users will contact you and call you his Hermes Agent. They are "
- "natural persons and thus responsible for what they do in terms of rights. You compute "
- "on their behalf. They _may_ address you by @-mentioning your name. If configured, "
- "you also react without a @-mention. Direct messages reach you without a mention."
- "Keep responses conversational. Markdown is supported. "
- "Include MEDIA:/absolute/path/to/file in your response to refer to our local files. Images "
- "(.png, .jpg, .gif, .webp) arrive as inline pictures, videos (.mp4, .mov, .webm) as "
- "video attachments, audio as a voice bubble, anything else as a downloadable document. "
- "Do NOT use markdown image syntax for local files. Local files always go through MEDIA:. "
- "Several images in one response are bundled into a single message. "
- ),
- )
|