adapter.py 93 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325
  1. """
  2. Chatto Platform Adapter for Hermes Agent.
  3. A plugin-based gateway adapter that connects to a Chatto server
  4. (self-hosted team chat) and relays messages to/from the Hermes agent.
  5. The adapter uses the chattolib library for all Chatto API interactions,
  6. including both outbound messaging and realtime WebSocket connections.
  7. Configuration in config.yaml::
  8. gateway:
  9. platforms:
  10. chatto:
  11. enabled: true
  12. extra:
  13. url: https://chat.lacy.casa
  14. channels: # room IDs to watch (empty = all joined)
  15. - REljMv5Pgolo6Y9
  16. home_channel: REljMv5Pgolo6Y9
  17. require_mention: true # only respond to @mentions in rooms
  18. allowed_users: [] # empty = allow all
  19. allow_all_users: true
  20. Or via environment variables (overrides config.yaml):
  21. CHATTO_URL, CHATTO_LOGIN, CHATTO_PASSWORD (secrets in ~/.hermes/.env),
  22. CHATTO_CHANNELS, CHATTO_HOME_CHANNEL,
  23. CHATTO_REQUIRE_MENTION, CHATTO_ALLOWED_USERS, CHATTO_ALLOW_ALL_USERS
  24. """
  25. from __future__ import annotations
  26. import asyncio
  27. import hashlib
  28. import logging
  29. import mimetypes
  30. import os
  31. from collections import OrderedDict
  32. from datetime import datetime, timezone
  33. import time
  34. from typing import Any, Dict, List, Optional, Tuple, cast
  35. from urllib.parse import urlsplit, urlunsplit
  36. import utils
  37. logger = logging.getLogger(__name__)
  38. from gateway.platforms.base import (
  39. BasePlatformAdapter,
  40. SendResult,
  41. MessageEvent,
  42. MessageType,
  43. ProcessingOutcome,
  44. )
  45. from gateway.config import Platform, PlatformConfig
  46. # Chattolib imports (vendored)
  47. # Using vendored chattolib from vendor/chattolib/
  48. # See vendor_chattolib.sh for how to update the vendored copy
  49. try:
  50. # Try vendored chattolib first
  51. from vendor.chattolib.client import (
  52. ChattoClient,
  53. )
  54. from vendor.chattolib.exceptions import (
  55. ChattoAuthError,
  56. ChattoError,
  57. )
  58. from vendor.chattolib.realtime import (
  59. ChattoRealtimeError,
  60. ChattoRealtimeCloseError,
  61. RealtimeEvent,
  62. stream_events,
  63. )
  64. from vendor.chattolib.types import (
  65. PresenceStatus,
  66. )
  67. from vendor.chattolib._pb.chatto.api.v1 import (
  68. rooms_pb2 as room_service_pb2,
  69. threads_pb2 as thread_service_pb2
  70. )
  71. from vendor.chattolib._transport import pb_to_dict
  72. except ImportError as e:
  73. logger.error("Chatto: failed to import vendored chattolib: %s", e)
  74. from .platform_config import (
  75. ChattoConfiguration, ChattoConstants
  76. )
  77. # --------------------------------------------------------------------------- #
  78. # Constants
  79. # --------------------------------------------------------------------------- #
  80. _MAX_MESSAGE_LENGTH = 10000
  81. _SEEN_CAP = 500
  82. # WebSocket / realtime protocol
  83. _WS_PATH = "/api/realtime"
  84. _WS_AUTH_TIMEOUT = 20.0
  85. _WS_MAX_MESSAGE_BYTES = 4_000_000
  86. _WS_PING_INTERVAL = 30.0
  87. _WS_RECONNECT_INITIAL_BACKOFF = 1.0
  88. _WS_RECONNECT_MAX_BACKOFF = 30.0
  89. # HTTP timeout used for outbound URL fetches
  90. _HTTP_TIMEOUT = 30
  91. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  92. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  93. _EMOJI_TO_SHORTCODE: Dict[str, str] = {
  94. "👍": "thumbsup",
  95. "👎": "thumbsdown",
  96. "❤️": "heart",
  97. "❤": "heart",
  98. "✅": "white_check_mark",
  99. "❌": "x",
  100. "👀": "eyes",
  101. "🎉": "tada",
  102. "😂": "joy",
  103. "🚀": "rocket",
  104. "🔥": "fire",
  105. "💯": "100",
  106. "🤔": "thinking",
  107. "👏": "clap",
  108. "🙏": "pray",
  109. "😅": "sweat_smile",
  110. "😴": "sleeping",
  111. "⏳": "hourglass",
  112. }
  113. # Chunk size for asset uploads (256 KB)
  114. _UPLOAD_CHUNK_SIZE = 256 * 1024
  115. def _decode_event_envelope(data: bytes) -> dict:
  116. """Decode a transient event envelope bytes into a dict.
  117. Tries protobuf parsing via the vendored realtime pb, falls back to JSON.
  118. """
  119. try:
  120. from vendor.chattolib._pb.chatto.realtime.v1 import realtime_pb2
  121. frame = cast(Any, realtime_pb2).RealtimeServerFrame()
  122. frame.ParseFromString(data)
  123. if frame.WhichOneof("frame") == "event":
  124. ev = frame.event
  125. try:
  126. return pb_to_dict(ev)
  127. except Exception:
  128. # Fall through to manual conversion
  129. out = {}
  130. # best-effort: copy simple fields
  131. if hasattr(ev, "room_id"):
  132. out["roomId"] = getattr(ev, "room_id")
  133. return out
  134. except Exception:
  135. pass
  136. try:
  137. import json
  138. return json.loads(data.decode("utf-8")) if data else {}
  139. except Exception:
  140. return {}
  141. # --------------------------------------------------------------------------- #
  142. # Adapter
  143. # --------------------------------------------------------------------------- #
  144. class ChattoAdapter(BasePlatformAdapter):
  145. """Chatto platform adapter — receives messages via WebSocket realtime,
  146. sends via ConnectRPC REST."""
  147. MAX_MESSAGE_LENGTH = 10000
  148. _SPLIT_THRESHOLD = 9900
  149. splits_long_messages = True
  150. supports_code_blocks: bool = True
  151. def __init__(self, config: PlatformConfig, **kwargs):
  152. """Signature needs to be compatible with BasePlatformAdapter.__init__ """
  153. # "extra" has been pre-processed by Hermes-Framework to be a dict of extra config values insode PlatformConfig.
  154. chatto_config: ChattoConfiguration = ChattoConfiguration(config)
  155. super().__init__(config=config, platform=Platform(ChattoConstants.PLATFORM_ID))
  156. # --- Configuration from our configuration data class with some logic ---
  157. self.chatto_config = chatto_config
  158. # ------ State -------
  159. # SDK runtime handle (injected by Hermes); annotate for Pylance
  160. self.sdk: Any = getattr(self, "sdk", None)
  161. # --- Runtime state ---
  162. self._user_id: str = ""
  163. self._user_display: str = ""
  164. self._room_names: Dict[str, str] = {}
  165. self._room_kinds: Dict[str, str] = {}
  166. self._our_thread_roots: set = set() # thread root event IDs we created
  167. self._our_message_ids: set = set() # message IDs we sent (for thread root detection)
  168. self._seen: Dict[str, OrderedDict] = {} # room_id -> OrderedDict(event_id -> None)
  169. self._resume_cursor: Optional[str] = None
  170. self._watch_room_ids: List[str] = []
  171. self._ws_task: Optional[asyncio.Task] = None
  172. self._ws_ready: Optional[asyncio.Event] = None
  173. self._ws_active = False
  174. self._ws_ref = None # reference to open websocket for dynamic resubscribe
  175. # Persistent typing indicator loops per room
  176. self._typing_tasks: Dict[str, asyncio.Task] = {}
  177. # Liveness probe (REST health check)
  178. self._liveness_interval_seconds = 60.0
  179. self._liveness_failure_threshold = 3
  180. self._liveness_task: Optional[asyncio.Task] = None
  181. # Member directory cache: user_id -> user info dict
  182. self._user_cache: Dict[str, dict] = {}
  183. # Chattolib client cache and lock for async access.
  184. self._chatto_client: Optional[ChattoClient] = None
  185. self._chatto_client_lock: asyncio.Lock = asyncio.Lock()
  186. # ------------------------------------------------------------------ #
  187. # Auth
  188. # ------------------------------------------------------------------ #
  189. async def _get_chatto_client(self: ChattoAdapter) -> Optional[ChattoClient]:
  190. """Get or create a ChattoClient instance."""
  191. if self._chatto_client is not None:
  192. return self._chatto_client
  193. async with self._chatto_client_lock:
  194. if self._chatto_client is not None:
  195. return self._chatto_client
  196. try:
  197. self.chatto_config._ensure_configuration() # throws ValueError .. or does just nothing.
  198. assert(self.chatto_config._login) # now we can assume, _login is available.
  199. client = await self._open_client(
  200. base_url=self.chatto_config._base_url,
  201. login=self.chatto_config._login,
  202. password=self.chatto_config._password,
  203. token=self.chatto_config._token,
  204. )
  205. self._chatto_client = client
  206. self._token = client.token
  207. logger.info("Chatto: logged in as %s via chattolib", self.chatto_config._login)
  208. return client
  209. except ChattoAuthError as e:
  210. logger.error("Chatto: authentication failed: %s", e)
  211. return None
  212. except (ChattoError, ValueError) as e:
  213. logger.error("Chatto: failed to create client: %s", e)
  214. return None
  215. async def _require_client(self) -> Any:
  216. """Return a ChattoClient or raise RuntimeError if unavailable.
  217. Use this helper when the caller expects a client to exist and
  218. wants a single canonical failure path. Methods that prefer a
  219. soft-fail can catch RuntimeError and return gracefully.
  220. """
  221. client = await self._get_chatto_client()
  222. if client is None:
  223. raise RuntimeError("Chatto client unavailable")
  224. return cast(Any, client)
  225. async def _ensure_token(self) -> bool:
  226. """Ensure we have a logged-in Chatto client and token."""
  227. if self.chatto_config._token and self._chatto_client is not None:
  228. return True
  229. client = await self._get_chatto_client()
  230. return client is not None
  231. async def _relogin(self) -> bool:
  232. """Force re-login (token expired)."""
  233. self._token = None
  234. return await self._ensure_token()
  235. # ------------------------------------------------------------------ #
  236. # Connection
  237. # ------------------------------------------------------------------ #
  238. async def _open_client(
  239. *,
  240. base_url: str,
  241. login: str,
  242. password: str,
  243. token: Optional[str] = None,
  244. ) -> Any:
  245. """Return a connected ``ChattoClient`` using token or login/password."""
  246. if token:
  247. return ChattoClient(token=token, base_url=base_url)
  248. return await ChattoClient.login(login, password, base_url=base_url)
  249. async def connect(self, *, is_reconnect: bool = False) -> bool:
  250. """Connect to Chatto and start the realtime event stream."""
  251. if not await self._ensure_token():
  252. return False
  253. try:
  254. client = await self._require_client()
  255. except RuntimeError:
  256. self._set_fatal_error("connect_failed", "Chatto client not available", retryable=True)
  257. return False
  258. # Get our own user info
  259. try:
  260. me = await client.me()
  261. self._user_id = str(me.id)
  262. self._user_login = str(me.login)
  263. self._user_display = str(me.display_name or "")
  264. logger.info("Chatto: got user info: %s", self._user_login)
  265. except Exception as e:
  266. logger.error("Chatto: failed to get user info: %s", e)
  267. return False
  268. # Discover rooms
  269. try:
  270. rooms_list = await client.list_rooms()
  271. rooms = []
  272. for room_with_state in rooms_list:
  273. # Defensive checks: vendored types may be partially populated
  274. if not room_with_state:
  275. continue
  276. room_obj = getattr(room_with_state, "room", None)
  277. if not room_obj:
  278. continue
  279. entry = {
  280. "room": {
  281. "id": str(getattr(room_obj, "id", "")),
  282. "name": str(getattr(room_obj, "name", "")),
  283. "kind": str(getattr(getattr(room_obj, "kind", None), "value", "")) if getattr(room_obj, "kind", None) else "",
  284. },
  285. "viewerState": {
  286. "isMember": getattr(getattr(room_with_state, "viewer_state", None), "is_member", False),
  287. }
  288. }
  289. rooms.append(entry)
  290. logger.info("Chatto: got %d rooms", len(rooms))
  291. except Exception as e:
  292. logger.error("Chatto: failed to list rooms: %s", e)
  293. return False
  294. all_room_ids = []
  295. for entry in rooms:
  296. room = entry.get("room", {})
  297. rid = str(room.get("id", ""))
  298. if not rid:
  299. continue
  300. name = str(room.get("name", rid))
  301. kind = str(room.get("kind", ""))
  302. self._room_names[rid] = name
  303. self._room_kinds[rid] = kind
  304. viewer = entry.get("viewerState", {})
  305. is_member = viewer.get("isMember", False)
  306. # If user-specified channels, only watch those; otherwise watch all joined rooms
  307. if self.chatto_config._channels_list:
  308. if rid in self.chatto_config._channels_list and not is_member:
  309. await self._join_room(rid)
  310. all_room_ids.append(rid)
  311. elif is_member:
  312. all_room_ids.append(rid)
  313. if self.chatto_config._channels_list:
  314. watch = list(self.chatto_config._channels_list)
  315. else:
  316. watch = all_room_ids
  317. if not watch:
  318. logger.error("Chatto: no rooms to watch (join a room or set CHATTO_CHANNELS)")
  319. return False
  320. # Ensure we're a member of each watched room
  321. for rid in watch:
  322. if self._room_kinds.get(rid) != "ROOM_KIND_DM":
  323. await self._join_room(rid)
  324. # Pick home channel
  325. if not self._home_channel:
  326. self._home_channel = watch[0]
  327. self._watch_room_ids = watch
  328. # Initialize seen for each room — seed from REST to avoid replaying history
  329. for rid in watch:
  330. self._seen[rid] = OrderedDict()
  331. await self._seed_room(rid)
  332. # Start WebSocket realtime connection
  333. if not await self._start_chattolib_realtime():
  334. return False
  335. self._mark_connected()
  336. self._start_liveness_probe()
  337. logger.info(
  338. "Chatto: connected to %s as %s, watching %d room(s) via WebSocket",
  339. self.chatto_config._base_url,
  340. self._user_display or self._user_login,
  341. len(watch),
  342. )
  343. # Broadcast online presence so the bot appears online in the member list
  344. try:
  345. await self.set_presence("online")
  346. except Exception:
  347. logger.debug("Chatto: set_presence(online) failed on connect", exc_info=True)
  348. return True
  349. async def disconnect(self) -> None:
  350. """Stop WebSocket, liveness probe, typing tasks, and clear state."""
  351. # Broadcast away presence before tearing down
  352. try:
  353. await self.set_presence("away")
  354. except Exception:
  355. logger.debug("Chatto: set_presence(away) failed on disconnect", exc_info=True)
  356. self._mark_disconnected()
  357. self._ws_active = False
  358. # Cancel liveness probe
  359. await self._cancel_liveness_task()
  360. # Cancel all typing tasks
  361. for chat_id in list(self._typing_tasks.keys()):
  362. await self.stop_typing(chat_id)
  363. if self._ws_task and not self._ws_task.done():
  364. self._ws_task.cancel()
  365. try:
  366. await self._ws_task
  367. except (asyncio.CancelledError, Exception):
  368. pass
  369. self._ws_task = None
  370. self._token = None
  371. self._chatto_client = None
  372. # ------------------------------------------------------------------ #
  373. # Liveness probe
  374. # ------------------------------------------------------------------ #
  375. def _start_liveness_probe(self) -> None:
  376. """Start the periodic REST health probe."""
  377. if (
  378. self._liveness_interval_seconds <= 0
  379. or self._liveness_failure_threshold <= 0
  380. ):
  381. return
  382. if self._liveness_task and not self._liveness_task.done():
  383. return
  384. self._liveness_task = asyncio.create_task(self._liveness_loop())
  385. async def _cancel_liveness_task(self) -> None:
  386. """Cancel the liveness probe task."""
  387. task = self._liveness_task
  388. self._liveness_task = None
  389. if task and not task.done():
  390. task.cancel()
  391. try:
  392. await task
  393. except (asyncio.CancelledError, Exception):
  394. pass
  395. async def _liveness_loop(self) -> None:
  396. """Periodically check if the REST API is alive via ViewerService/GetViewer.
  397. Also refreshes presence status on each successful probe so the bot
  398. stays showing as online — Chatto's presence expires if not refreshed.
  399. On ``threshold`` consecutive failures, set a fatal error with
  400. ``retryable=True`` so the gateway runner rebuilds the adapter.
  401. """
  402. interval = self._liveness_interval_seconds
  403. threshold = self._liveness_failure_threshold
  404. failures = 0
  405. while self._running:
  406. try:
  407. await asyncio.sleep(interval)
  408. except asyncio.CancelledError:
  409. return
  410. if not self._running:
  411. return
  412. try:
  413. try:
  414. client = await self._require_client()
  415. except RuntimeError:
  416. reason = "no_client"
  417. failures += 1
  418. logger.warning(
  419. "Chatto: liveness probe aborted - no client available (%s, %d/%d)",
  420. reason,
  421. failures,
  422. threshold,
  423. )
  424. continue
  425. await client.get_viewer()
  426. failures = 0
  427. # Refresh presence to keep showing as online
  428. try:
  429. await self.set_presence("online")
  430. except Exception:
  431. logger.debug("Chatto: presence refresh failed", exc_info=True)
  432. continue
  433. except asyncio.CancelledError:
  434. return
  435. except Exception as e:
  436. reason = str(e)
  437. failures += 1
  438. logger.warning(
  439. "Chatto: liveness probe failed (%s, %d/%d)",
  440. reason,
  441. failures,
  442. threshold,
  443. )
  444. if failures < threshold:
  445. continue
  446. # Threshold exceeded — force reconnect
  447. logger.error(
  448. "Chatto: liveness probe failed %d times consecutively; forcing reconnect",
  449. failures,
  450. )
  451. self._set_fatal_error(
  452. "chatto_liveness_failed",
  453. f"Chatto REST API liveness check failed: {reason}",
  454. retryable=True,
  455. )
  456. # Cancel the WebSocket to trigger reconnect
  457. if self._ws_task and not self._ws_task.done():
  458. self._ws_task.cancel()
  459. return
  460. async def _join_room(self, room_id: str) -> None:
  461. """Join a room if not already a member."""
  462. try:
  463. try:
  464. client = await self._require_client()
  465. except RuntimeError:
  466. logger.debug("Chatto: _join_room aborted - no client available for %s", room_id)
  467. return
  468. await client.join_room(room_id=room_id)
  469. logger.debug("Chatto: joined room %s (%s)", room_id, self._room_names.get(room_id, room_id))
  470. except ChattoError as e:
  471. if "permission_denied" in str(e).lower() or "403" in str(e):
  472. logger.debug("Chatto: already a member of %s or cannot join", room_id)
  473. else:
  474. logger.debug("Chatto: join room %s failed: %s", room_id, e)
  475. async def _seed_room(self, room_id: str) -> None:
  476. """Seed high-water mark from the newest events so a restart doesn't replay history."""
  477. try:
  478. # These should be available from check_chatto_requirements()
  479. try:
  480. client = await self._require_client()
  481. except RuntimeError:
  482. logger.debug("Chatto: _seed_room aborted - no client available for %s", room_id)
  483. return
  484. resp = await client.services.rooms.get_room_events(
  485. cast(Any, room_service_pb2).GetRoomEventsRequest(room_id=room_id),
  486. headers=client._headers(),
  487. )
  488. data = pb_to_dict(resp)
  489. events = data.get("page", {}).get("events", [])
  490. for ev in events:
  491. ev_id = str(ev.get("id", ""))
  492. if ev_id:
  493. self._mark_seen(room_id, ev_id)
  494. logger.debug("Chatto: seeded room %s with %d events", room_id, len(events))
  495. except Exception as e:
  496. logger.debug("Chatto: get room events failed for %s: %s", room_id, e)
  497. def _mark_seen(self, room_id: str, event_id: str) -> None:
  498. seen = self._seen.setdefault(room_id, OrderedDict())
  499. seen[event_id] = None
  500. while len(seen) > _SEEN_CAP:
  501. seen.popitem(last=False)
  502. def _is_seen(self, room_id: str, event_id: str) -> bool:
  503. return event_id in self._seen.get(room_id, {})
  504. # ------------------------------------------------------------------ #
  505. # WebSocket Realtime Transport
  506. # ------------------------------------------------------------------ #
  507. def _websocket_url(self) -> str:
  508. """Build the WebSocket URL from the base HTTP URL."""
  509. parsed = urlsplit(self.chatto_config._base_url)
  510. scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
  511. if scheme not in ("ws", "wss") or not parsed.netloc:
  512. raise ValueError(f"Chatto URL must use http(s) or ws(s), got {parsed.scheme}")
  513. path = parsed.path.rstrip("/") + _WS_PATH
  514. return urlunsplit((scheme, parsed.netloc, path, parsed.query, ""))
  515. async def _start_chattolib_realtime(self) -> bool:
  516. """Start realtime connection using chattolib's stream_events."""
  517. self._ws_ready = asyncio.Event()
  518. self._ws_task = asyncio.create_task(self._chattolib_event_loop())
  519. try:
  520. await asyncio.wait_for(self._ws_ready.wait(), timeout=_WS_AUTH_TIMEOUT + 10)
  521. except (asyncio.TimeoutError, TimeoutError):
  522. logger.warning("Chatto: chattolib realtime did not connect in time")
  523. self._ws_active = False
  524. if self._ws_task and not self._ws_task.done():
  525. self._ws_task.cancel()
  526. try:
  527. await self._ws_task
  528. except asyncio.CancelledError:
  529. pass
  530. self._ws_task = None
  531. return False
  532. return True
  533. async def _chattolib_event_loop(self) -> None:
  534. """Event loop using chattolib's stream_events.
  535. This replaces the manual WebSocket loop with chattolib's high-level
  536. stream_events() which provides pre-decoded RealtimeEvent objects.
  537. """
  538. try:
  539. client = await self._require_client()
  540. except RuntimeError:
  541. logger.warning("Chatto: chattolib event loop aborted - no client available")
  542. return
  543. # Ensure ws_ready is available for synchronization with starter
  544. assert self._ws_ready is not None
  545. backoff = _WS_RECONNECT_INITIAL_BACKOFF
  546. logger.info("Chatto: starting chattolib event stream with %d rooms", len(self._watch_room_ids))
  547. while True:
  548. try:
  549. # Start streaming events
  550. async for event in stream_events(client):
  551. # Signal that we're connected and ready
  552. if not self._ws_ready.is_set():
  553. self._ws_active = True
  554. self._ws_ready.set()
  555. backoff = _WS_RECONNECT_INITIAL_BACKOFF
  556. # Handle different event kinds
  557. if event.kind == "projection_event":
  558. # Convert chattolib RealtimeEvent to our format
  559. # event.payload is the RealtimeProjectionEvent protobuf
  560. try:
  561. # Extract the raw bytes for compatibility with existing handler
  562. # For now, we'll use the existing _handle_projection_event
  563. # which expects bytes. We need to convert.
  564. #
  565. # Actually, let's create a new handler that works with
  566. # chattolib's event objects directly.
  567. await self._handle_chattolib_projection_event(event)
  568. except Exception as e:
  569. logger.warning("Chatto: failed to handle projection event: %s", e)
  570. elif event.kind == "caught_up":
  571. # Update resume cursor
  572. if hasattr(event.payload, 'cursor'):
  573. self._resume_cursor = event.payload.cursor
  574. logger.debug("Chatto: caught_up received, cursor=%s", self._resume_cursor or "(none)")
  575. elif event.kind in ("message_posted", "mention_notification",
  576. "new_direct_message_notification", "user_joined_room",
  577. "room_created", "user_left_room", "message_edited",
  578. "message_retracted", "session_terminated"):
  579. # Transient events - convert to envelope format for existing handler
  580. await self._handle_chattolib_transient_event(event)
  581. elif event.kind in ("heartbeat", "pong", "subscribed"):
  582. # Ignore these
  583. logger.debug("Chatto: %s event received", event.kind)
  584. elif event.kind == "error":
  585. logger.warning("Chatto: server error event: %s", event.payload)
  586. elif event.kind == "close":
  587. logger.info("Chatto: server sent close event")
  588. raise ConnectionError("Server closed connection")
  589. else:
  590. logger.debug("Chatto: unknown event kind: %s", event.kind)
  591. except ChattoRealtimeCloseError as e:
  592. logger.warning("Chatto: realtime closed by server: %s (reconnect=%s)", e.message, e.reconnect)
  593. if e.reconnect:
  594. self._ws_active = False
  595. await asyncio.sleep(backoff)
  596. backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
  597. continue
  598. raise
  599. except ChattoRealtimeError as e:
  600. logger.warning("Chatto: realtime error: %s (fatal=%s)", e.message, e.fatal)
  601. if e.fatal:
  602. raise
  603. except (ChattoError, asyncio.CancelledError):
  604. raise
  605. except Exception as e:
  606. self._ws_active = False
  607. logger.warning("Chatto: event stream error: %s, retrying in %.1fs", e, backoff)
  608. await asyncio.sleep(backoff)
  609. backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
  610. time.sleep(1)
  611. async def _handle_chattolib_projection_event(self, event: RealtimeEvent) -> None:
  612. """Handle a chattolib RealtimeEvent with kind='projection_event'.
  613. This is a wrapper that converts chattolib's event to the format
  614. expected by _handle_projection_event.
  615. """
  616. try:
  617. # event.payload is a RealtimeProjectionEvent protobuf message
  618. # We need to convert it to the dict format that _handle_projection_event expects
  619. # pb_to_dict should be available from check_chatto_requirements()
  620. pe_dict = pb_to_dict(event.payload)
  621. # Extract operations from the projection event
  622. operations = []
  623. if "operations" in pe_dict:
  624. for op_pb in event.payload.operations:
  625. op_dict = pb_to_dict(op_pb)
  626. operations.append(op_dict)
  627. # Build the event dict in the format expected by _handle_projection_event
  628. event_data = {
  629. "id": pe_dict.get("id", ""),
  630. "created_at": pe_dict.get("createdAt", ""),
  631. "actor_id": pe_dict.get("actorId", ""),
  632. "resume_cursor": pe_dict.get("resumeCursor", ""),
  633. "operations": operations,
  634. }
  635. await self._handle_projection_event_from_dict(event_data)
  636. except Exception as e:
  637. logger.warning("Chatto: failed to convert chattolib projection event: %s", e)
  638. async def _handle_projection_event_from_dict(self, event: dict) -> None:
  639. """Handle a projection event from a dict (used by chattolib wrapper)."""
  640. # Update resume cursor if provided
  641. cursor = event.get("resume_cursor") or event.get("resumeCursor")
  642. if cursor:
  643. self._resume_cursor = cursor
  644. operations = event.get("operations", [])
  645. for op in operations:
  646. op_type = op.get("type", "")
  647. if op_type == "room_timeline_event_upsert":
  648. await self._handle_timeline_event_upsert_from_dict(op)
  649. async def _handle_timeline_event_upsert_from_dict(self, op: dict) -> None:
  650. """Handle a room_timeline_event_upsert operation from dict."""
  651. room_id = op.get("room_id", "") or op.get("roomId", "")
  652. event_data = op.get("event", {})
  653. if not event_data:
  654. return
  655. ev_id = str(event_data.get("id", ""))
  656. if not ev_id:
  657. return
  658. # De-dupe
  659. if self._is_seen(room_id, ev_id):
  660. return
  661. self._mark_seen(room_id, ev_id)
  662. # Only handle messagePosted events
  663. posted = event_data.get("messagePosted", {})
  664. if not posted:
  665. return
  666. msg = posted.get("message", {})
  667. if not msg:
  668. return
  669. await self._dispatch_message(msg, room_id)
  670. async def _handle_chattolib_transient_event(self, event: RealtimeEvent) -> None:
  671. """Handle a chattolib RealtimeEvent with transient event kinds.
  672. This converts chattolib's event to the envelope format expected by
  673. _handle_transient_event.
  674. """
  675. try:
  676. # pb_to_dict should be available from check_chatto_requirements()
  677. # Build envelope dict based on event kind
  678. envelope = {
  679. "type": event.kind,
  680. "actorId": event.actor_id or "",
  681. "id": event.id or "",
  682. "data": pb_to_dict(event.payload) if event.payload else {},
  683. }
  684. # Convert data field names to match expected format
  685. data = envelope["data"]
  686. if event.kind == "message_posted":
  687. data["roomId"] = data.get("roomId", "")
  688. data["messageEventId"] = data.get("eventId", data.get("id", ""))
  689. data["threadRootEventId"] = data.get("threadRootEventId", "")
  690. elif event.kind == "mention_notification":
  691. data["roomId"] = data.get("roomId", "")
  692. data["eventId"] = data.get("eventId", data.get("id", ""))
  693. elif event.kind == "new_direct_message_notification":
  694. data["roomId"] = data.get("roomId", "")
  695. data["eventId"] = data.get("eventId", data.get("id", ""))
  696. elif event.kind in ("user_joined_room", "room_created", "user_left_room"):
  697. data["roomId"] = data.get("roomId", data.get("room_id", ""))
  698. data["actorId"] = data.get("actorId", data.get("actor_id", ""))
  699. elif event.kind == "message_edited":
  700. data["roomId"] = data.get("roomId", "")
  701. data["messageEventId"] = data.get("eventId", data.get("id", ""))
  702. elif event.kind == "message_retracted":
  703. data["roomId"] = data.get("roomId", "")
  704. data["messageEventId"] = data.get("messageEventId", data.get("eventId", ""))
  705. data["reason"] = data.get("reason", "")
  706. elif event.kind == "session_terminated":
  707. data["reason"] = data.get("reason", "")
  708. await self._handle_transient_event_from_dict(envelope)
  709. except Exception as e:
  710. logger.warning("Chatto: failed to handle chattolib transient event: %s", e)
  711. async def _handle_transient_event_from_dict(self, envelope: dict) -> None:
  712. """Handle a transient event from a dict (used by chattolib wrapper)."""
  713. event_type = envelope.get("type", "unknown")
  714. event_data = envelope.get("data", {})
  715. actor_id = envelope.get("actorId", "")
  716. if event_type == "message_posted":
  717. room_id = event_data.get("roomId", "")
  718. event_id = event_data.get("messageEventId", "")
  719. thread_root = event_data.get("threadRootEventId", "")
  720. logger.info("Chatto WS: message_posted in room %s, event %s (thread=%s)", room_id, event_id, thread_root or "none")
  721. if room_id and event_id and not self._is_seen(room_id, event_id):
  722. await self._fetch_and_dispatch_event(room_id, event_id, thread_root)
  723. elif event_type == "mention_notification":
  724. room_id = event_data.get("roomId", "")
  725. event_id = event_data.get("eventId", "")
  726. logger.info("Chatto WS: mention notification in room %s for event %s", room_id, event_id)
  727. if room_id and event_id and not self._is_seen(room_id, event_id):
  728. await self._fetch_and_dispatch_event(room_id, event_id)
  729. elif event_type == "new_direct_message_notification":
  730. room_id = event_data.get("roomId", "")
  731. event_id = event_data.get("eventId", "")
  732. logger.info("Chatto WS: new DM notification in room %s for event %s", room_id, event_id)
  733. if room_id and event_id and not self._is_seen(room_id, event_id):
  734. await self._fetch_and_dispatch_event(room_id, event_id)
  735. elif event_type == "user_joined_room":
  736. room_id = event_data.get("roomId", "")
  737. actor_id = envelope.get("actorId", "")
  738. logger.info("Chatto WS: user_joined_room room=%s actor=%s", room_id, actor_id)
  739. if room_id and room_id not in self._watch_room_ids:
  740. await self._refresh_rooms()
  741. elif event_type == "room_created":
  742. room_id = event_data.get("roomId", "")
  743. logger.info("Chatto WS: room_created room=%s", room_id)
  744. if room_id and room_id not in self._watch_room_ids:
  745. await self._refresh_rooms()
  746. elif event_type == "user_left_room":
  747. room_id = event_data.get("roomId", "")
  748. actor_id = envelope.get("actorId", "")
  749. logger.info("Chatto WS: user_left_room room=%s actor=%s", room_id, actor_id)
  750. if room_id and actor_id == self._user_id and room_id in self._watch_room_ids:
  751. self._watch_room_ids.remove(room_id)
  752. logger.info("Chatto WS: stopped watching room %s (we left)", room_id)
  753. elif event_type == "message_edited":
  754. room_id = event_data.get("roomId", "")
  755. event_id = event_data.get("messageEventId", "")
  756. logger.info("Chatto WS: message_edited in room %s, event %s", room_id, event_id)
  757. elif event_type == "message_retracted":
  758. room_id = event_data.get("roomId", "")
  759. event_id = event_data.get("messageEventId", "")
  760. reason = event_data.get("reason", "")
  761. logger.info("Chatto WS: message_retracted in room %s, event %s (reason=%s)", room_id, event_id, reason or "none")
  762. if room_id and event_id:
  763. self._mark_seen(room_id, event_id)
  764. elif event_type == "session_terminated":
  765. reason = event_data.get("reason", "")
  766. logger.warning("Chatto WS: session terminated by server (reason=%s) — forcing reconnect", reason or "none")
  767. # We can't close _ws_ref here since we're using chattolib
  768. # The reconnect will happen automatically in _chattolib_event_loop
  769. else:
  770. logger.debug("Chatto WS: unknown transient event type: %s", event_type)
  771. # Update resume cursor if provided
  772. cursor = envelope.get("resume_cursor") or envelope.get("resumeCursor")
  773. if cursor:
  774. self._resume_cursor = cursor
  775. operations = envelope.get("operations", [])
  776. for op in operations:
  777. if op.get("type") == "room_timeline_event_upsert":
  778. await self._handle_timeline_event_upsert(op)
  779. # Other operation types (room_upsert, room_member_upsert, etc.) are
  780. # not relevant to message delivery — ignore them.
  781. async def _handle_timeline_event_upsert(self, op: dict) -> None:
  782. """Handle a room_timeline_event_upsert operation."""
  783. room_id = op.get("room_id", "")
  784. event = op.get("event", {})
  785. if not event:
  786. return
  787. ev_id = str(event.get("id", ""))
  788. if not ev_id:
  789. return
  790. # De-dupe: skip events we've already seen
  791. if self._is_seen(room_id, ev_id):
  792. return
  793. self._mark_seen(room_id, ev_id)
  794. # Only handle messagePosted events
  795. posted = event.get("messagePosted")
  796. if not posted:
  797. return
  798. msg = posted.get("message", {})
  799. if not msg:
  800. return
  801. await self._dispatch_message(msg, room_id)
  802. async def _handle_transient_event(self, data: bytes) -> None:
  803. """Handle a transient RealtimeEventEnvelope (message_posted, mentions, DMs).
  804. These are signal-only events — they contain room_id and event_id but NOT
  805. the message body. We fetch the actual message via REST as a fallback.
  806. """
  807. if not data:
  808. return
  809. try:
  810. envelope = _decode_event_envelope(data)
  811. except (ValueError, IndexError) as e:
  812. logger.warning("Chatto WS: failed to decode transient event: %s", e)
  813. return
  814. event_type = envelope.get("type", "unknown")
  815. event_data = envelope.get("data", {})
  816. if event_type == "message_posted":
  817. room_id = event_data.get("roomId", "")
  818. event_id = event_data.get("messageEventId", "")
  819. thread_root = event_data.get("threadRootEventId", "")
  820. logger.info("Chatto WS: message_posted in room %s, event %s (thread=%s)", room_id, event_id, thread_root or "none")
  821. if room_id and event_id and not self._is_seen(room_id, event_id):
  822. await self._fetch_and_dispatch_event(room_id, event_id, thread_root)
  823. elif event_type == "mention_notification":
  824. room_id = event_data.get("roomId", "")
  825. event_id = event_data.get("eventId", "")
  826. logger.info("Chatto WS: mention notification in room %s for event %s", room_id, event_id)
  827. if room_id and event_id and not self._is_seen(room_id, event_id):
  828. await self._fetch_and_dispatch_event(room_id, event_id)
  829. elif event_type == "new_direct_message_notification":
  830. room_id = event_data.get("roomId", "")
  831. event_id = event_data.get("eventId", "")
  832. logger.info("Chatto WS: new DM notification in room %s for event %s", room_id, event_id)
  833. if room_id and event_id and not self._is_seen(room_id, event_id):
  834. await self._fetch_and_dispatch_event(room_id, event_id)
  835. elif event_type == "user_joined_room":
  836. room_id = event_data.get("roomId", "")
  837. actor_id = envelope.get("actorId", "")
  838. logger.info("Chatto WS: user_joined_room room=%s actor=%s", room_id, actor_id)
  839. # If WE joined a room (or someone else joined and we should watch it),
  840. # refresh room list and resubscribe
  841. if room_id and room_id not in self._watch_room_ids:
  842. await self._refresh_rooms()
  843. elif event_type == "room_created":
  844. room_id = event_data.get("roomId", "")
  845. logger.info("Chatto WS: room_created room=%s", room_id)
  846. # A new room was created — check if we should join/watch it
  847. if room_id and room_id not in self._watch_room_ids:
  848. await self._refresh_rooms()
  849. elif event_type == "user_left_room":
  850. room_id = event_data.get("roomId", "")
  851. actor_id = envelope.get("actorId", "")
  852. logger.info("Chatto WS: user_left_room room=%s actor=%s", room_id, actor_id)
  853. # If WE left a room, stop watching it
  854. if room_id and actor_id == self._user_id and room_id in self._watch_room_ids:
  855. self._watch_room_ids.remove(room_id)
  856. logger.info("Chatto WS: stopped watching room %s (we left)", room_id)
  857. elif event_type == "message_edited":
  858. room_id = event_data.get("roomId", "")
  859. event_id = event_data.get("messageEventId", "")
  860. logger.info("Chatto WS: message_edited in room %s, event %s", room_id, event_id)
  861. # Log edit — could re-fetch for context if needed in the future
  862. elif event_type == "message_retracted":
  863. room_id = event_data.get("roomId", "")
  864. event_id = event_data.get("messageEventId", "")
  865. reason = event_data.get("reason", "")
  866. logger.info("Chatto WS: message_retracted in room %s, event %s (reason=%s)", room_id, event_id, reason or "none")
  867. # Mark the message as seen so we don't try to dispatch it later
  868. if room_id and event_id:
  869. self._mark_seen(room_id, event_id)
  870. elif event_type == "session_terminated":
  871. reason = event_data.get("reason", "")
  872. logger.warning("Chatto WS: session terminated by server (reason=%s) — forcing reconnect", reason or "none")
  873. # Close the websocket to trigger reconnect with backoff
  874. if self._ws_ref:
  875. try:
  876. ws = cast(Any, self._ws_ref)
  877. await ws.close()
  878. except Exception:
  879. pass
  880. else:
  881. logger.debug("Chatto WS: unknown transient event type: %s", event_type)
  882. async def _fetch_and_dispatch_event(self, room_id: str, event_id: str, thread_root_event_id: str = "") -> None:
  883. """Fetch a single event by ID via REST and dispatch it.
  884. Used as a fallback when the projection_event for a transient
  885. notification (mention/DM) hasn't arrived yet.
  886. When thread_root_event_id is set, fetches from the thread timeline
  887. instead of the room timeline.
  888. """
  889. self._mark_seen(room_id, event_id)
  890. try:
  891. # Ensure we have a client
  892. try:
  893. client = await self._require_client()
  894. except RuntimeError:
  895. logger.warning("Chatto WS: REST fallback fetch aborted - no client available for event %s", event_id)
  896. return
  897. # Fetch events either from the thread timeline or the room timeline
  898. if thread_root_event_id:
  899. resp = await client.services.threads.get_thread_events(
  900. cast(Any, thread_service_pb2).GetThreadEventsRequest(
  901. room_id=room_id,
  902. thread_root_event_id=thread_root_event_id,
  903. ),
  904. headers=client._headers(),
  905. )
  906. else:
  907. resp = await client.services.rooms.get_room_events(
  908. cast(Any, room_service_pb2).GetRoomEventsRequest(room_id=room_id),
  909. headers=client._headers(),
  910. )
  911. data = pb_to_dict(resp)
  912. events = data.get("page", {}).get("events", [])
  913. for ev in events:
  914. ev_id = str(ev.get("id", ""))
  915. if ev_id == event_id:
  916. posted = ev.get("messagePosted")
  917. if posted:
  918. msg = posted.get("message", {})
  919. if msg:
  920. # Ensure thread info is set on the message so
  921. # _dispatch_message can extract the thread root ID.
  922. if thread_root_event_id and not msg.get("thread"):
  923. msg["thread"] = {"threadRootEventId": thread_root_event_id}
  924. logger.info("Chatto WS: dispatching event %s via REST fallback (thread=%s)", event_id, thread_root_event_id or "none")
  925. await self._dispatch_message(msg, room_id)
  926. return
  927. logger.warning("Chatto WS: event %s not found in room %s events (thread=%s)", event_id, room_id, thread_root_event_id or "none")
  928. except Exception as e:
  929. logger.warning("Chatto WS: REST fallback fetch failed for event %s: %s", event_id, e)
  930. # If the REST fallback didn't find the event, refresh known rooms
  931. # by delegating to a dedicated helper. This avoids duplicate logic
  932. # across transient/projection event handlers.
  933. await self._refresh_rooms()
  934. async def _dispatch_message(self, msg: dict, room_id: str) -> None:
  935. """Build a MessageEvent and hand it to the base class handler.
  936. This method is identical to the polling version — it receives a
  937. message dict (decoded from protobuf) and dispatches it through the
  938. standard Hermes message pipeline.
  939. """
  940. if not self._message_handler:
  941. return
  942. actor_id = str(msg.get("actorId", ""))
  943. # Skip our own messages
  944. if actor_id == self._user_id:
  945. return
  946. # Best-effort: cache the sender's display name for richer message context
  947. if actor_id and actor_id not in self._user_cache:
  948. try:
  949. await self.get_user(actor_id)
  950. except Exception:
  951. logger.debug("Chatto: get_user(%s) failed during dispatch", actor_id, exc_info=True)
  952. body = str(msg.get("body", ""))
  953. if not body:
  954. return
  955. msg_id = str(msg.get("id", ""))
  956. chat_type = "dm" if self._room_kinds.get(room_id) == "ROOM_KIND_DM" else "group"
  957. # Mention detection
  958. is_dm = chat_type == "dm"
  959. mentioned = False
  960. if self._user_login:
  961. mentioned = f"@{self._user_login}" in body
  962. if self._user_display:
  963. mentioned = mentioned or f"@{self._user_display}" in body
  964. if self.chatto_config._require_mention and not is_dm and not mentioned:
  965. # Allow free-response rooms (like Discord's free_response_channels)
  966. if room_id not in self.chatto_config._free_response_channels_list:
  967. return
  968. # For DMs, always respond. For rooms with require_mention, only respond when mentioned.
  969. # Strip the mention from the text for the agent
  970. text = body
  971. if mentioned and not is_dm:
  972. # Remove mention prefix if present
  973. if self._user_login and text.startswith(f"@{self._user_login}"):
  974. text = text[len(f"@{self._user_login}"):].lstrip()
  975. elif self._user_display and text.startswith(f"@{self._user_display}"):
  976. text = text[len(f"@{self._user_display}"):].lstrip()
  977. # Resolve user display name from actorLogin or actorDisplayName
  978. user_name = str(msg.get("actorLogin", "")) or str(msg.get("actorDisplayName", actor_id))
  979. thread_id = None
  980. thread_info = msg.get("thread", {})
  981. if thread_info and str(thread_info.get("threadRootEventId", "")) != msg_id:
  982. thread_id = str(thread_info.get("threadRootEventId", ""))
  983. # Hermes SDK: Propagate thread context if thread_id is set
  984. try:
  985. # Hermes injects the SDK into the plugin context as self.sdk
  986. propagate_context_to_thread = self.sdk.thread_context.propagate_context_to_thread
  987. propagate_context_to_thread(thread_id)
  988. except AttributeError:
  989. logger.debug("Hermes SDK thread_context not available in plugin context")
  990. except Exception as e:
  991. logger.warning("Failed to propagate thread context: %s", e, exc_info=True)
  992. source = self.build_source(
  993. chat_id=room_id,
  994. chat_name=self._room_names.get(room_id, room_id),
  995. chat_type=chat_type,
  996. user_id=actor_id,
  997. user_name=user_name,
  998. thread_id=thread_id,
  999. )
  1000. created_at_str = str(msg.get("createdAt", ""))
  1001. try:
  1002. timestamp = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) if created_at_str else datetime.now()
  1003. except (ValueError, TypeError):
  1004. timestamp = datetime.now()
  1005. event = MessageEvent(
  1006. text=text,
  1007. message_type=MessageType.TEXT,
  1008. source=source,
  1009. message_id=msg_id,
  1010. timestamp=timestamp,
  1011. raw_message=msg,
  1012. )
  1013. await self.handle_message(event)
  1014. async def _refresh_rooms(self) -> None:
  1015. """Refresh room list via REST, join and seed any newly discovered rooms."""
  1016. try:
  1017. try:
  1018. client = await self._require_client()
  1019. except RuntimeError:
  1020. logger.warning("Chatto WS: _refresh_rooms aborted - no client available")
  1021. return
  1022. rooms_list = await client.list_rooms()
  1023. new_room_ids: List[str] = []
  1024. for room_with_state in rooms_list:
  1025. if not room_with_state:
  1026. continue
  1027. room_obj = getattr(room_with_state, "room", None)
  1028. if not room_obj:
  1029. continue
  1030. rid = str(getattr(room_obj, "id", ""))
  1031. name = str(getattr(room_obj, "name", ""))
  1032. kind = str(getattr(getattr(room_obj, "kind", None), "value", "")) if getattr(room_obj, "kind", None) else ""
  1033. is_member = getattr(getattr(room_with_state, "viewer_state", None), "is_member", False)
  1034. self._room_names[rid] = name
  1035. self._room_kinds[rid] = kind
  1036. if is_member and rid not in self._watch_room_ids:
  1037. new_room_ids.append(rid)
  1038. if not new_room_ids:
  1039. return
  1040. logger.info("Chatto WS: discovered %d new room(s): %s", len(new_room_ids), new_room_ids)
  1041. for rid in new_room_ids:
  1042. if self._room_kinds.get(rid) != "ROOM_KIND_DM":
  1043. await self._join_room(rid)
  1044. self._seen[rid] = OrderedDict()
  1045. await self._seed_room(rid)
  1046. self._watch_room_ids.append(rid)
  1047. logger.info("Chatto WS: updated watch list with %d room(s)", len(self._watch_room_ids))
  1048. except Exception:
  1049. logger.warning("Chatto WS: _refresh_rooms failed", exc_info=True)
  1050. # ------------------------------------------------------------------ #
  1051. # Read state & notification dismissal (best-effort, Chatto-unique)
  1052. # ------------------------------------------------------------------ #
  1053. # Best-effort: mark all watched rooms as read (room_id may be undefined here)
  1054. for _rid in list(self._watch_room_ids):
  1055. try:
  1056. await self.mark_room_as_read(_rid)
  1057. except Exception:
  1058. logger.debug("Chatto: mark_room_as_read failed for %s", _rid, exc_info=True)
  1059. try:
  1060. await self.dismiss_all_notifications()
  1061. except Exception:
  1062. logger.debug("Chatto: dismiss_all_notifications failed", exc_info=True)
  1063. # ------------------------------------------------------------------ #
  1064. # Sending (REST — unchanged from polling version)
  1065. # ------------------------------------------------------------------ #
  1066. async def send(
  1067. self,
  1068. chat_id: str,
  1069. content: str,
  1070. reply_to: Optional[str] = None,
  1071. metadata: Optional[Dict[str, Any]] = None,
  1072. ) -> SendResult:
  1073. """Send a message to a Chatto room.
  1074. Long messages are split into chunks via ``truncate_message`` and
  1075. each chunk is sent as a separate CreateMessage call. The first
  1076. chunk's message ID is returned as ``message_id``.
  1077. When ``auto_thread`` is enabled and the incoming message was a
  1078. regular room message (not already in a thread), the first chunk is
  1079. sent as a room message and its ID becomes the thread root. Subsequent
  1080. chunks are sent in that thread. This mirrors Discord's auto_thread
  1081. behavior.
  1082. """
  1083. if not content:
  1084. return SendResult(success=False, error="Empty message")
  1085. formatted = self.format_message(content) if hasattr(self, "format_message") else content
  1086. chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
  1087. # Thread support — resolve thread_id once
  1088. # DM rooms don't support threads, so skip threading for DMs
  1089. thread_id = (metadata or {}).get("thread_id")
  1090. # Only use reply_to as thread_id if auto_thread is enabled.
  1091. # When auto_thread=false, responses go directly in the room
  1092. # without threading under the incoming message.
  1093. if reply_to and self.chatto_config._auto_thread:
  1094. # reply_to might be the incoming message ID. If we already have
  1095. # thread_id from metadata, keep it (it's the thread root).
  1096. # Only use reply_to as thread_id if we don't already have one.
  1097. if not thread_id:
  1098. thread_id = reply_to
  1099. # Check if this is a DM room — DMs don't support threads
  1100. room_kind = self._room_kinds.get(str(chat_id), "")
  1101. is_dm = room_kind == "ROOM_KIND_DM" or room_kind == "dm"
  1102. if is_dm:
  1103. thread_id = None
  1104. # Auto-thread: by default, Chatto creates a thread for replies to room
  1105. # messages (not DMs, not already in a thread). This keeps conversations
  1106. # organized in the room. Can be disabled via extra.auto_thread=false.
  1107. auto_thread_enabled = os.getenv("CHATTO_AUTO_THREAD", "").strip().lower()
  1108. if auto_thread_enabled:
  1109. auto_thread_enabled = auto_thread_enabled in ("true", "1", "yes")
  1110. else:
  1111. auto_thread_enabled = True # default: enabled
  1112. use_auto_thread = auto_thread_enabled and not thread_id and not is_dm
  1113. message_ids: List[str] = []
  1114. last_resp: Optional[dict] = None
  1115. last_error: Optional[str] = None
  1116. retryable = False
  1117. try:
  1118. client = await self._require_client()
  1119. except RuntimeError:
  1120. return SendResult(success=False, error="Chatto client not available", retryable=True)
  1121. for i, chunk in enumerate(chunks):
  1122. try:
  1123. msg_obj = await client.post_message(
  1124. room_id=str(chat_id),
  1125. body=chunk,
  1126. thread_root_event_id=str(thread_id) if thread_id else "",
  1127. )
  1128. msg_id = str(msg_obj.id)
  1129. last_resp = {"message": {"id": msg_id}}
  1130. except ChattoError as e:
  1131. last_error = str(e)
  1132. retryable = True
  1133. break
  1134. except Exception as e:
  1135. last_error = str(e)
  1136. retryable = True
  1137. break
  1138. if msg_id:
  1139. self._mark_seen(str(chat_id), msg_id)
  1140. message_ids.append(msg_id)
  1141. self._our_message_ids.add(msg_id)
  1142. # If we sent a message WITHOUT a thread_id, this message could
  1143. # become a thread root if someone replies to it
  1144. if not thread_id:
  1145. self._our_thread_roots.add(msg_id)
  1146. # Auto-thread: first chunk becomes the thread root,
  1147. # subsequent chunks go in the thread
  1148. if use_auto_thread and i == 0 and not thread_id:
  1149. thread_id = msg_id
  1150. if last_error and not message_ids:
  1151. return SendResult(success=False, error=last_error, retryable=retryable)
  1152. first_id = message_ids[0] if message_ids else ""
  1153. # ------------------------------------------------------------------ #
  1154. # Thread following (best-effort, Chatto-unique)
  1155. # ------------------------------------------------------------------ #
  1156. if thread_id and message_ids:
  1157. try:
  1158. await self._follow_thread(str(chat_id), str(thread_id))
  1159. except Exception:
  1160. logger.debug("Chatto: _follow_thread failed for room=%s thread=%s",
  1161. chat_id, thread_id, exc_info=True)
  1162. return SendResult(success=True, message_id=first_id, raw_response=last_resp)
  1163. async def send_typing(self, chat_id: str, metadata=None) -> None:
  1164. """Start a persistent typing indicator for a room.
  1165. Sends a typing ping every 10 seconds (Chatto's indicator likely
  1166. lasts ~8-10s). The background loop runs until ``stop_typing()``
  1167. is called or the task is cancelled.
  1168. """
  1169. if chat_id in self._typing_tasks:
  1170. return # already running
  1171. async def _typing_loop() -> None:
  1172. try:
  1173. while True:
  1174. try:
  1175. try:
  1176. client = await self._require_client()
  1177. except RuntimeError:
  1178. return
  1179. await client.update_typing_indicator(room_id=str(chat_id))
  1180. except asyncio.CancelledError:
  1181. return
  1182. except Exception:
  1183. pass
  1184. await asyncio.sleep(10)
  1185. except asyncio.CancelledError:
  1186. pass
  1187. finally:
  1188. self._typing_tasks.pop(chat_id, None)
  1189. self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
  1190. async def stop_typing(self, chat_id: str) -> None:
  1191. """Stop the persistent typing indicator for a room."""
  1192. task = self._typing_tasks.pop(chat_id, None)
  1193. if task:
  1194. task.cancel()
  1195. try:
  1196. await task
  1197. except (asyncio.CancelledError, Exception):
  1198. pass
  1199. async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
  1200. """Get information about a chat/room."""
  1201. name = self._room_names.get(chat_id, chat_id)
  1202. kind = self._room_kinds.get(chat_id, "")
  1203. chat_type = "dm" if kind == "ROOM_KIND_DM" else "group"
  1204. return {
  1205. "name": name,
  1206. "type": chat_type,
  1207. }
  1208. # ------------------------------------------------------------------ #
  1209. # Reactions
  1210. # ------------------------------------------------------------------ #
  1211. @staticmethod
  1212. def _emoji_to_shortcode(emoji: str) -> str:
  1213. """Convert a unicode emoji to a Chatto shortcode name.
  1214. If the emoji is already a shortcode (no unicode mapping found),
  1215. return it as-is.
  1216. """
  1217. shortcode = _EMOJI_TO_SHORTCODE.get(emoji)
  1218. if shortcode:
  1219. return shortcode
  1220. # Already a shortcode like "thumbsup" — return as-is
  1221. return emoji
  1222. async def send_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
  1223. """Add a reaction to a message via MessageService/AddReaction."""
  1224. shortcode = self._emoji_to_shortcode(emoji)
  1225. try:
  1226. try:
  1227. client = await self._require_client()
  1228. except RuntimeError:
  1229. return False
  1230. result = await client.add_reaction(
  1231. room_id=str(chat_id),
  1232. message_event_id=str(message_id),
  1233. emoji=shortcode,
  1234. )
  1235. return result
  1236. except ChattoError as e:
  1237. logger.debug("Chatto: AddReaction failed: %s", e)
  1238. return False
  1239. except Exception as e:
  1240. logger.debug("Chatto: AddReaction error: %s", e)
  1241. return False
  1242. async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
  1243. """Remove a reaction from a message via MessageService/RemoveReaction."""
  1244. shortcode = self._emoji_to_shortcode(emoji)
  1245. try:
  1246. try:
  1247. client = await self._require_client()
  1248. except RuntimeError:
  1249. return False
  1250. result = await client.remove_reaction(
  1251. room_id=str(chat_id),
  1252. message_event_id=str(message_id),
  1253. emoji=shortcode,
  1254. )
  1255. return result
  1256. except ChattoError as e:
  1257. logger.debug("Chatto: RemoveReaction failed: %s", e)
  1258. return False
  1259. except Exception as e:
  1260. logger.debug("Chatto: RemoveReaction error: %s", e)
  1261. return False
  1262. # ------------------------------------------------------------------ #
  1263. # Read state management (Chatto-unique)
  1264. # ------------------------------------------------------------------ #
  1265. async def mark_room_as_read(self, room_id: str) -> bool:
  1266. """Mark a room as read via RoomService/MarkRoomAsRead."""
  1267. try:
  1268. try:
  1269. client = await self._require_client()
  1270. except RuntimeError:
  1271. return False
  1272. await client.mark_room_as_read(room_id=str(room_id))
  1273. return True
  1274. except ChattoError as e:
  1275. logger.debug("Chatto: MarkRoomAsRead failed: %s", e)
  1276. return False
  1277. except Exception as e:
  1278. logger.debug("Chatto: MarkRoomAsRead error: %s", e)
  1279. return False
  1280. async def mark_thread_as_read(self, room_id: str, thread_root_event_id: str) -> bool:
  1281. """Mark a thread as read via ThreadService/MarkThreadAsRead."""
  1282. try:
  1283. try:
  1284. client = await self._require_client()
  1285. except RuntimeError:
  1286. return False
  1287. await client.mark_thread_as_read(
  1288. room_id=str(room_id), thread_root_event_id=str(thread_root_event_id)
  1289. )
  1290. return True
  1291. except ChattoError as e:
  1292. logger.debug("Chatto: MarkThreadAsRead failed: %s", e)
  1293. return False
  1294. except Exception as e:
  1295. logger.debug("Chatto: MarkThreadAsRead error: %s", e)
  1296. return False
  1297. # ------------------------------------------------------------------ #
  1298. # DM initiation (Chatto-unique)
  1299. # ------------------------------------------------------------------ #
  1300. async def start_dm(self, user_id: str) -> Optional[str]:
  1301. """Start a direct message with a user via RoomService/StartDM.
  1302. Returns the room ID on success, or None on failure.
  1303. """
  1304. if not user_id:
  1305. return None
  1306. try:
  1307. try:
  1308. client = await self._require_client()
  1309. except RuntimeError:
  1310. return None
  1311. room = await client.start_dm(participant_ids=[str(user_id)])
  1312. rid = str(getattr(cast(Any, room), "id", "")) if room else ""
  1313. if rid:
  1314. self._room_names[rid] = self._room_names.get(rid, "")
  1315. self._room_kinds[rid] = "ROOM_KIND_DM"
  1316. return rid
  1317. logger.debug("Chatto: StartDM returned no room id")
  1318. return None
  1319. except ChattoError as e:
  1320. logger.debug("Chatto: StartDM failed: %s", e)
  1321. return None
  1322. except Exception as e:
  1323. logger.debug("Chatto: StartDM error: %s", e)
  1324. return None
  1325. # ------------------------------------------------------------------ #
  1326. # Thread following (Chatto-unique)
  1327. # ------------------------------------------------------------------ #
  1328. async def _follow_thread(self, room_id: str, thread_root_event_id: str) -> None:
  1329. """Best-effort: follow a thread via ThreadService/FollowThread."""
  1330. try:
  1331. try:
  1332. client = await self._require_client()
  1333. except RuntimeError:
  1334. return
  1335. await client.follow_thread(
  1336. room_id=str(room_id), thread_root_event_id=str(thread_root_event_id)
  1337. )
  1338. except ChattoError as e:
  1339. logger.debug("Chatto: FollowThread failed: %s", e)
  1340. except Exception as e:
  1341. logger.debug("Chatto: FollowThread error: %s", e)
  1342. # ------------------------------------------------------------------ #
  1343. # Room creation (Chatto-unique)
  1344. # ------------------------------------------------------------------ #
  1345. async def create_room(
  1346. self,
  1347. name: str,
  1348. description: str = "",
  1349. group_id: str = "",
  1350. universal: bool = True,
  1351. ) -> Optional[str]:
  1352. """Create an ad-hoc room via RoomService/CreateRoom.
  1353. Returns the room ID on success, or None on failure.
  1354. """
  1355. try:
  1356. try:
  1357. client = await self._require_client()
  1358. except RuntimeError:
  1359. return None
  1360. room = await client.create_room(
  1361. name=name,
  1362. group_id=group_id or "",
  1363. description=description,
  1364. universal=universal,
  1365. )
  1366. rid = str(room.id) if room else ""
  1367. if rid:
  1368. self._room_names[rid] = name
  1369. self._room_kinds[rid] = "ROOM_KIND_GROUP"
  1370. return rid
  1371. logger.debug("Chatto: CreateRoom returned no room id")
  1372. return None
  1373. except ChattoError as e:
  1374. logger.debug("Chatto: CreateRoom failed: %s", e)
  1375. return None
  1376. except Exception as e:
  1377. logger.debug("Chatto: CreateRoom error: %s", e)
  1378. return None
  1379. # ------------------------------------------------------------------ #
  1380. # Notification dismissal (Chatto-unique)
  1381. # ------------------------------------------------------------------ #
  1382. async def dismiss_all_notifications(self) -> bool:
  1383. """Dismiss all notifications via NotificationService/DismissAllNotifications."""
  1384. try:
  1385. try:
  1386. client = await self._require_client()
  1387. except RuntimeError:
  1388. return False
  1389. await client.dismiss_all_notifications()
  1390. return True
  1391. except ChattoError as e:
  1392. logger.debug("Chatto: DismissAllNotifications failed: %s", e)
  1393. return False
  1394. except Exception as e:
  1395. logger.debug("Chatto: DismissAllNotifications error: %s", e)
  1396. return False
  1397. async def dismiss_notification(self, notification_id: str) -> bool:
  1398. """Dismiss a single notification via NotificationService/DismissNotification."""
  1399. try:
  1400. try:
  1401. client = await self._require_client()
  1402. except RuntimeError:
  1403. return False
  1404. await client.dismiss_notification(notification_id=str(notification_id))
  1405. return True
  1406. except ChattoError as e:
  1407. logger.debug("Chatto: DismissNotification failed: %s", e)
  1408. return False
  1409. except Exception as e:
  1410. logger.debug("Chatto: DismissNotification error: %s", e)
  1411. return False
  1412. # ------------------------------------------------------------------ #
  1413. # Message editing and deletion
  1414. # ------------------------------------------------------------------ #
  1415. async def edit_message(
  1416. self,
  1417. chat_id: str,
  1418. message_id: str,
  1419. new_content: str,
  1420. metadata: Optional[Dict[str, Any]] = None,
  1421. ) -> bool:
  1422. """Edit a previously sent message via MessageService/UpdateMessage."""
  1423. try:
  1424. try:
  1425. client = await self._require_client()
  1426. except RuntimeError:
  1427. return False
  1428. await client.update_message(
  1429. room_id=str(chat_id),
  1430. event_id=str(message_id),
  1431. body=new_content,
  1432. )
  1433. return True
  1434. except ChattoError as e:
  1435. logger.debug("Chatto: UpdateMessage failed: %s", e)
  1436. return False
  1437. except Exception as e:
  1438. logger.debug("Chatto: UpdateMessage error: %s", e)
  1439. return False
  1440. async def delete_message(
  1441. self,
  1442. chat_id: str,
  1443. message_id: str,
  1444. metadata: Optional[Dict[str, Any]] = None,
  1445. ) -> bool:
  1446. """Delete a previously sent message via MessageService/DeleteMessage."""
  1447. try:
  1448. try:
  1449. client = await self._require_client()
  1450. except RuntimeError:
  1451. return False
  1452. result = await client.delete_message(
  1453. room_id=str(chat_id),
  1454. event_id=str(message_id),
  1455. )
  1456. return result
  1457. except ChattoError as e:
  1458. logger.debug("Chatto: DeleteMessage failed: %s", e)
  1459. return False
  1460. except Exception as e:
  1461. logger.debug("Chatto: DeleteMessage error: %s", e)
  1462. return False
  1463. # ------------------------------------------------------------------ #
  1464. # Processing lifecycle hooks (reactions-based, like Discord)
  1465. # ------------------------------------------------------------------ #
  1466. def _reactions_enabled(self) -> bool:
  1467. """Check if processing reactions are enabled."""
  1468. return os.getenv("CHATTO_REACTIONS", "true").lower() not in {"false", "0", "no"}
  1469. def _event_room_and_message_id(self, event: MessageEvent) -> Tuple[str, str]:
  1470. """Extract room_id and message_id from a MessageEvent."""
  1471. chat_id = ""
  1472. message_id = str(event.message_id or "")
  1473. source = event.source
  1474. if source:
  1475. chat_id = str(getattr(source, "chat_id", "") or "")
  1476. # Fallback: try raw_message dict
  1477. if not chat_id or not message_id:
  1478. raw = event.raw_message
  1479. if isinstance(raw, dict):
  1480. if not chat_id:
  1481. chat_id = str(raw.get("roomId", "") or "")
  1482. if not message_id:
  1483. message_id = str(raw.get("id", "") or "")
  1484. return chat_id, message_id
  1485. async def on_processing_start(self, event: MessageEvent) -> None:
  1486. """Add an 👀 (eyes) reaction to the incoming message."""
  1487. if not self._reactions_enabled():
  1488. return
  1489. chat_id, message_id = self._event_room_and_message_id(event)
  1490. if not chat_id or not message_id:
  1491. return
  1492. await self.send_reaction(chat_id, message_id, "👀")
  1493. async def on_processing_complete(
  1494. self, event: MessageEvent, outcome: ProcessingOutcome
  1495. ) -> None:
  1496. """Swap the 👀 reaction for ✅ (success) or ❌ (failure)."""
  1497. if not self._reactions_enabled():
  1498. return
  1499. chat_id, message_id = self._event_room_and_message_id(event)
  1500. if not chat_id or not message_id:
  1501. return
  1502. # Remove the processing eyes reaction
  1503. await self.remove_reaction(chat_id, message_id, "👀")
  1504. # Add the outcome reaction
  1505. if outcome == ProcessingOutcome.SUCCESS:
  1506. await self.send_reaction(chat_id, message_id, "✅")
  1507. elif outcome == ProcessingOutcome.FAILURE:
  1508. await self.send_reaction(chat_id, message_id, "❌")
  1509. # ------------------------------------------------------------------ #
  1510. # Asset upload (chunked)
  1511. # ------------------------------------------------------------------ #
  1512. async def _upload_asset(self, room_id: str, file_path: str) -> Optional[str]:
  1513. """Upload a file via the chunked AssetUploadService.
  1514. Returns the asset ID on success, or None on failure.
  1515. """
  1516. try:
  1517. with open(file_path, "rb") as f:
  1518. file_data = f.read()
  1519. except Exception as e:
  1520. logger.error("Chatto: failed to read file %s — %s", file_path, e)
  1521. return None
  1522. if not file_data:
  1523. logger.error("Chatto: file %s is empty", file_path)
  1524. return None
  1525. file_size = len(file_data)
  1526. file_name = os.path.basename(file_path)
  1527. mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
  1528. sha256_hash = hashlib.sha256(file_data).hexdigest()
  1529. try:
  1530. try:
  1531. client = await self._require_client()
  1532. except RuntimeError:
  1533. logger.error("Chatto: upload aborted - no client available")
  1534. return None
  1535. # Step 1: Create upload session
  1536. upload = await client.create_upload(
  1537. room_id=room_id,
  1538. filename=file_name,
  1539. size=file_size,
  1540. sha256=sha256_hash,
  1541. content_type=mime_type,
  1542. )
  1543. upload_id = str(getattr(cast(Any, upload), "id", ""))
  1544. if not upload_id:
  1545. logger.error("Chatto: CreateUpload returned no upload ID")
  1546. return None
  1547. # Step 2: Upload chunks
  1548. offset = 0
  1549. while offset < file_size:
  1550. chunk = file_data[offset:offset + _UPLOAD_CHUNK_SIZE]
  1551. chunk_sha256 = hashlib.sha256(chunk).hexdigest()
  1552. await client.upload_chunk(
  1553. upload_id=upload_id,
  1554. offset=offset,
  1555. content=chunk,
  1556. chunk_sha256=chunk_sha256,
  1557. )
  1558. offset += len(chunk)
  1559. # Step 3: Complete upload
  1560. upload, asset = await client.complete_upload(upload_id=upload_id)
  1561. if not asset:
  1562. logger.error("Chatto: CompleteUpload returned no asset")
  1563. return None
  1564. asset_id = str(getattr(cast(Any, asset), "id", ""))
  1565. logger.info("Chatto: uploaded %s as asset %s (%d bytes)", file_name, asset_id, file_size)
  1566. return asset_id
  1567. except ChattoError as e:
  1568. logger.error("Chatto: upload failed: %s", e)
  1569. return None
  1570. except Exception as e:
  1571. logger.error("Chatto: upload error: %s", e)
  1572. return None
  1573. async def send_image_file(
  1574. self,
  1575. chat_id: str,
  1576. file_path: str,
  1577. caption: Optional[str] = None,
  1578. reply_to: Optional[str] = None,
  1579. metadata: Optional[Dict[str, Any]] = None,
  1580. ) -> SendResult:
  1581. """Send a local image file via the chunked upload API."""
  1582. # Validate the path is safe
  1583. safe_path = self.validate_media_delivery_path(file_path)
  1584. if not safe_path:
  1585. logger.warning("Chatto: send_image_file — unsafe path %s", file_path)
  1586. text = "⚠️ Couldn't deliver the image attachment."
  1587. if caption:
  1588. text = f"{caption}\n{text}"
  1589. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  1590. asset_id = await self._upload_asset(str(chat_id), safe_path)
  1591. if not asset_id:
  1592. # Fallback to a notice
  1593. text = "⚠️ Couldn't deliver the image attachment."
  1594. if caption:
  1595. text = f"{caption}\n{text}"
  1596. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  1597. thread_id = (metadata or {}).get("thread_id")
  1598. if reply_to:
  1599. thread_id = reply_to
  1600. try:
  1601. try:
  1602. client = await self._require_client()
  1603. except RuntimeError:
  1604. return SendResult(success=False, error="Chatto client not available", retryable=True)
  1605. msg = await client.post_message(
  1606. room_id=str(chat_id),
  1607. body=caption or "",
  1608. attachment_asset_ids=[asset_id],
  1609. thread_root_event_id=str(thread_id) if thread_id else "",
  1610. )
  1611. msg_id = str(msg.id)
  1612. if msg_id:
  1613. self._mark_seen(str(chat_id), msg_id)
  1614. return SendResult(success=True, message_id=msg_id, raw_response=msg)
  1615. except ChattoError as e:
  1616. return SendResult(success=False, error=str(e), retryable=True)
  1617. except Exception as e:
  1618. return SendResult(success=False, error=str(e), retryable=False)
  1619. async def send_image(
  1620. self,
  1621. chat_id: str,
  1622. image_url: str,
  1623. caption: Optional[str] = None,
  1624. reply_to: Optional[str] = None,
  1625. metadata: Optional[Dict[str, Any]] = None,
  1626. ) -> SendResult:
  1627. """Send an image to a Chatto room.
  1628. Tries to download the image from the URL and upload it as a native
  1629. attachment. Falls back to sending the URL as a link (Chatto renders
  1630. link previews) if the download fails.
  1631. """
  1632. # Try downloading and uploading as attachment
  1633. try:
  1634. import tempfile
  1635. import urllib.request as _urllib_request
  1636. # Download to a temp file
  1637. parsed = urlsplit(image_url)
  1638. url_path = parsed.path
  1639. ext = os.path.splitext(url_path)[1] or ".png"
  1640. tmp_fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="chatto_img_")
  1641. try:
  1642. os.close(tmp_fd)
  1643. req = _urllib_request.Request(image_url, headers={"User-Agent": "Hermes/1.0"})
  1644. try:
  1645. import ssl
  1646. ctx = ssl.create_default_context()
  1647. except Exception:
  1648. ctx = None
  1649. with _urllib_request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
  1650. with open(tmp_path, "wb") as f:
  1651. f.write(resp.read())
  1652. # Upload as attachment
  1653. result = await self.send_image_file(
  1654. chat_id, tmp_path, caption=caption,
  1655. reply_to=reply_to, metadata=metadata,
  1656. )
  1657. if result.success:
  1658. return result
  1659. finally:
  1660. try:
  1661. os.unlink(tmp_path)
  1662. except OSError:
  1663. pass
  1664. except Exception as e:
  1665. logger.debug("Chatto: send_image download/upload failed, falling back to link: %s", e)
  1666. # Fallback: send as link (Chatto renders link previews)
  1667. text = image_url
  1668. if caption:
  1669. text = f"{caption}\n{image_url}"
  1670. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  1671. # ------------------------------------------------------------------ #
  1672. # Member directory — user lookup and mention resolution (Chatto-unique)
  1673. # ------------------------------------------------------------------ #
  1674. async def list_users(self) -> list:
  1675. """List all server members via UserService/ListUsers.
  1676. Returns a list of user dicts. Each dict typically contains
  1677. ``id``, ``login``, and ``displayName`` keys.
  1678. """
  1679. try:
  1680. try:
  1681. client = await self._require_client()
  1682. except RuntimeError:
  1683. return []
  1684. members, _ = await client.list_users()
  1685. users = []
  1686. # Cache all returned users and convert to dict format
  1687. for member in members:
  1688. if member and member.user:
  1689. user_dict = {
  1690. "id": str(member.user.id),
  1691. "login": str(member.user.login),
  1692. "displayName": str(member.user.display_name or ""),
  1693. }
  1694. uid = user_dict["id"]
  1695. if uid:
  1696. self._user_cache[uid] = user_dict
  1697. users.append(user_dict)
  1698. return users
  1699. except (ChattoError, Exception) as e:
  1700. logger.debug("Chatto: ListUsers failed: %s", e)
  1701. return []
  1702. async def get_user(self, user_id: str) -> Optional[dict]:
  1703. """Get a single user by ID via UserService/GetUser.
  1704. Returns the user dict (containing ``id``, ``login``,
  1705. ``displayName``) or ``None`` on failure. Results are cached in
  1706. ``self._user_cache``.
  1707. """
  1708. if not user_id:
  1709. return None
  1710. # Return cached entry if available
  1711. if user_id in self._user_cache:
  1712. return self._user_cache[user_id]
  1713. try:
  1714. try:
  1715. client = await self._require_client()
  1716. except RuntimeError:
  1717. return None
  1718. user_obj = await client.get_user(user_id=str(user_id))
  1719. if user_obj and user_obj.user:
  1720. user_dict = {
  1721. "id": str(user_obj.user.id),
  1722. "login": str(user_obj.user.login),
  1723. "displayName": str(user_obj.user.display_name or ""),
  1724. }
  1725. uid = user_dict["id"]
  1726. if uid:
  1727. self._user_cache[uid] = user_dict
  1728. return user_dict
  1729. return None
  1730. except ChattoError as e:
  1731. logger.debug("Chatto: GetUser failed: %s", e)
  1732. return None
  1733. except Exception as e:
  1734. logger.debug("Chatto: GetUser error: %s", e)
  1735. return None
  1736. async def batch_get_users(self, user_ids: list) -> list:
  1737. """Batch-fetch multiple users via UserService/BatchGetUsers.
  1738. Returns a list of user dicts. Cached entries are reused and only
  1739. uncached IDs are fetched from the server.
  1740. """
  1741. if not user_ids:
  1742. return []
  1743. # Separate cached from uncached
  1744. cached: list = []
  1745. uncached_ids: list = []
  1746. for uid in user_ids:
  1747. uid_str = str(uid)
  1748. if uid_str in self._user_cache:
  1749. cached.append(self._user_cache[uid_str])
  1750. else:
  1751. uncached_ids.append(uid_str)
  1752. if not uncached_ids:
  1753. return cached
  1754. try:
  1755. try:
  1756. client = await self._require_client()
  1757. except RuntimeError:
  1758. return cached
  1759. members = await client.batch_get_users(user_ids=uncached_ids)
  1760. fetched = []
  1761. for member in members:
  1762. if member and member.user:
  1763. user_dict = {
  1764. "id": str(member.user.id),
  1765. "login": str(member.user.login),
  1766. "displayName": str(member.user.display_name or ""),
  1767. }
  1768. uid = user_dict["id"]
  1769. if uid:
  1770. self._user_cache[uid] = user_dict
  1771. fetched.append(user_dict)
  1772. return cached + fetched
  1773. except ChattoError as e:
  1774. logger.debug("Chatto: BatchGetUsers failed: %s", e)
  1775. return cached
  1776. except Exception as e:
  1777. logger.debug("Chatto: BatchGetUsers error: %s", e)
  1778. return cached
  1779. # ------------------------------------------------------------------ #
  1780. # Presence broadcasting (Chatto-unique)
  1781. # ------------------------------------------------------------------ #
  1782. async def set_presence(self, status: str) -> bool:
  1783. """Update the bot's presence status via MyAccountService/UpdatePresence.
  1784. Accepts string values ``"online"``, ``"away"``, ``"dnd"`` (or
  1785. ``"do_not_disturb"``) and maps them to Chatto's PresenceStatus enum.
  1786. Returns ``True`` on success.
  1787. """
  1788. # Map string status to chattolib PresenceStatus enum
  1789. status_map = {
  1790. "online": PresenceStatus.ONLINE,
  1791. "away": PresenceStatus.AWAY,
  1792. "dnd": PresenceStatus.DO_NOT_DISTURB,
  1793. "do_not_disturb": PresenceStatus.DO_NOT_DISTURB,
  1794. }
  1795. status_lower = status.lower().strip()
  1796. presence_status = status_map.get(status_lower)
  1797. if presence_status is None:
  1798. logger.warning("Chatto: unknown presence status %r", status)
  1799. return False
  1800. try:
  1801. try:
  1802. client = await self._require_client()
  1803. except RuntimeError:
  1804. return False
  1805. await client.update_presence(status=presence_status)
  1806. logger.debug("Chatto: presence set to %s", status_lower)
  1807. return True
  1808. except ChattoError as e:
  1809. logger.debug("Chatto: UpdatePresence failed: %s", e)
  1810. return False
  1811. except Exception as e:
  1812. logger.debug("Chatto: UpdatePresence error: %s", e)
  1813. return False
  1814. # ------------------------------------------------------------------ #
  1815. # Custom status messages (Chatto-unique)
  1816. # ------------------------------------------------------------------ #
  1817. async def set_custom_status(self, text: str) -> bool:
  1818. """Set a custom status message via MyAccountService/UpdateCustomStatus.
  1819. The status text is a plain string (max ~100 chars). Useful for
  1820. indicating long-running operations, e.g. ``"Processing..."``.
  1821. Returns ``True`` on success.
  1822. """
  1823. if not text:
  1824. return False
  1825. # Truncate to a reasonable length
  1826. status_text = text.strip()[:100]
  1827. if not status_text:
  1828. return False
  1829. try:
  1830. try:
  1831. client = await self._require_client()
  1832. except RuntimeError:
  1833. return False
  1834. await client.update_custom_status(emoji="", text=status_text)
  1835. logger.debug("Chatto: custom status set to %r", status_text)
  1836. return True
  1837. except ChattoError as e:
  1838. logger.debug("Chatto: UpdateCustomStatus failed: %s", e)
  1839. return False
  1840. except Exception as e:
  1841. logger.debug("Chatto: UpdateCustomStatus error: %s", e)
  1842. return False
  1843. async def clear_custom_status(self) -> bool:
  1844. """Clear the custom status message via MyAccountService/DeleteCustomStatus.
  1845. Returns ``True`` on success.
  1846. """
  1847. try:
  1848. try:
  1849. client = await self._require_client()
  1850. except RuntimeError:
  1851. return False
  1852. await client.delete_custom_status()
  1853. logger.debug("Chatto: custom status cleared")
  1854. return True
  1855. except ChattoError as e:
  1856. logger.debug("Chatto: DeleteCustomStatus failed: %s", e)
  1857. return False
  1858. except Exception as e:
  1859. logger.debug("Chatto: DeleteCustomStatus error: %s", e)
  1860. return False
  1861. @property
  1862. def supports_threads(self) -> bool:
  1863. return True
  1864. # --------------------------------------------------------------------------- #
  1865. # Plugin registration
  1866. # --------------------------------------------------------------------------- #
  1867. def check_requirements() -> bool:
  1868. """Check if Chatto is configured and dependencies are available."""
  1869. # Then check configuration
  1870. return bool(
  1871. ChattoConfiguration._ensure_configuration
  1872. )
  1873. def validate_config(config: PlatformConfig) -> bool:
  1874. """Validate that the platform config has enough info to connect."""
  1875. extra = getattr(config, "extra", {}) or {}
  1876. url = os.getenv("CHATTO_URL") or str(extra.get("url", ""))
  1877. login = os.getenv("CHATTO_LOGIN", "").strip()
  1878. password = os.getenv("CHATTO_PASSWORD", "").strip()
  1879. return bool(url and login and password)
  1880. def is_connected(config) -> bool:
  1881. """Check whether Chatto is configured."""
  1882. return validate_config(config)
  1883. def _apply_yaml_config(yaml_cfg: dict, chatto_cfg: dict) -> Optional[dict]:
  1884. """Translate config.yaml chatto.extra keys into CHATTO_* env vars."""
  1885. if not isinstance(chatto_cfg, dict):
  1886. chatto_cfg = {}
  1887. extra = chatto_cfg.get("extra", {}) or {}
  1888. if not isinstance(extra, dict):
  1889. extra = {}
  1890. mapping = {
  1891. "url": "CHATTO_URL",
  1892. "home_channel": "CHATTO_HOME_CHANNEL",
  1893. "require_mention": "CHATTO_REQUIRE_MENTION",
  1894. "free_response_channels": "CHATTO_FREE_RESPONSE_CHANNELS",
  1895. "auto_thread": "CHATTO_AUTO_THREAD",
  1896. }
  1897. for yaml_key, env_key in mapping.items():
  1898. val = extra.get(yaml_key)
  1899. if val is not None and not os.getenv(env_key):
  1900. if isinstance(val, bool):
  1901. env_val: str = str(val).lower()
  1902. elif isinstance(val, list):
  1903. env_val = ",".join(str(v) for v in val)
  1904. else:
  1905. env_val = str(val)
  1906. os.environ[env_key] = env_val
  1907. channels = extra.get("channels")
  1908. if isinstance(channels, list) and not os.getenv("CHATTO_CHANNELS"):
  1909. channels_str: str = ",".join(str(c) for c in channels)
  1910. os.environ["CHATTO_CHANNELS"] = channels_str
  1911. allowed = extra.get("allowed_users")
  1912. if isinstance(allowed, list) and not os.getenv("CHATTO_ALLOWED_USERS"):
  1913. allowed_str: str = ",".join(str(u) for u in allowed)
  1914. os.environ["CHATTO_ALLOWED_USERS"] = allowed_str
  1915. if "allow_all_users" in extra and not os.getenv("CHATTO_ALLOW_ALL_USERS"):
  1916. allow_all_val: str = str(extra["allow_all_users"]).lower()
  1917. os.environ["CHATTO_ALLOW_ALL_USERS"] = allow_all_val
  1918. # Return nothing to merge — all config flows through env
  1919. return None
  1920. async def _standalone_send(
  1921. pconfig,
  1922. chat_id,
  1923. message,
  1924. *,
  1925. thread_id=None,
  1926. media_files=None,
  1927. force_document=False,
  1928. ) -> SendResult:
  1929. """Deliver a message to Chatto without a running gateway adapter. Do not modify signature.
  1930. Used by cron / scheduled routines that run out-of-process. Creates a
  1931. short-lived chattolib client, posts, and closes.
  1932. """
  1933. pconfig.getattr("extra", {})
  1934. chatto_config = ChattoConfiguration(pconfig=pconfig)
  1935. # Create a temporary client for standalone sending
  1936. client: ChattoClient
  1937. token = chatto_config._token
  1938. login = chatto_config._login
  1939. password = chatto_config._password
  1940. if not chatto_config._base_url or (not (login or not password) or not token):
  1941. return SendResult(success=False, error="Chatto: base URL or credentials missing")
  1942. try:
  1943. chatto_config._ensure_configuration()
  1944. if token:
  1945. client = ChattoClient(base_url=chatto_config._base_url, token=token)
  1946. else:
  1947. if login and password:
  1948. client = await ChattoClient.login(
  1949. base_url=chatto_config._base_url, login=login, password=password,
  1950. )
  1951. except (Exception, ValueError) as exc:
  1952. return SendResult(success=False, error=f"Chatto login failed: {exc}")
  1953. finally:
  1954. logger.debug("Chatto standalone client: {client}")
  1955. try:
  1956. kwargs: Dict[str, Any] = {}
  1957. if chatto_config._auto_thread and thread_id:
  1958. kwargs["in_reply_to"] = thread_id
  1959. if media_files and media_files.get("attachment_asset_ids"):
  1960. kwargs["attachment_asset_ids"] = list(media_files["attachment_asset_ids"])
  1961. try:
  1962. posted = await client.post_message(chat_id, message, **kwargs)
  1963. except Exception as exc:
  1964. return SendResult(success=False, error=str(exc))
  1965. return SendResult(success=True, message_id=getattr(posted, "id", "") or None)
  1966. finally:
  1967. try:
  1968. await client.close()
  1969. except Exception as exc:
  1970. logger.error("Chatto standalone: error closing short-lived client. Perhaps already closed. {exc}")
  1971. def interactive_setup() -> None:
  1972. """Interactive setup wizard for Chatto."""
  1973. try:
  1974. from hermes_cli.gateway import prompt_env, set_env_var # type: ignore
  1975. except Exception:
  1976. # Fallback simple prompt implementations for environments where
  1977. # hermes_cli.gateway helpers are not available (tests / limited shells).
  1978. def prompt_env(prompt_text: str, password: bool = False):
  1979. if password:
  1980. import getpass
  1981. return getpass.getpass(prompt_text)
  1982. return input(prompt_text + " ")
  1983. def set_env_var(k: str, v: str) -> None:
  1984. os.environ[k] = v
  1985. url = prompt_env("Chatto server URL (e.g. https://chat.example.com) or leave blank for default ChattoHQ on chat.chatto.run:")
  1986. if url:
  1987. set_env_var("CHATTO_BASE_URL", url)
  1988. login = prompt_env("Chatto login (username):")
  1989. if login:
  1990. set_env_var("CHATTO_LOGIN", login)
  1991. password = prompt_env("Chatto password:", password=True)
  1992. if password:
  1993. set_env_var("CHATTO_PASSWORD", password)
  1994. channels = prompt_env("Room IDs to watch (comma-separated, or empty for all):")
  1995. if channels:
  1996. set_env_var("CHATTO_CHANNELS", channels)
  1997. home = prompt_env("Home room ID for notifications (or empty):")
  1998. if home:
  1999. set_env_var("CHATTO_HOME_CHANNEL", home)
  2000. allow_all = prompt_env("Allow all users? (true/false):")
  2001. if allow_all:
  2002. set_env_var("CHATTO_ALLOW_ALL_USERS", allow_all)
  2003. print("\n✓ Chatto configured. Restart the gateway to activate.")
  2004. def register(ctx) -> None:
  2005. """Plugin entry point — called by the Hermes plugin system."""
  2006. ctx.register_platform(
  2007. name=ChattoConstants.PLATFORM_NAME,
  2008. label=ChattoConstants.PLATFORM_LABEL,
  2009. adapter_factory=lambda cfg: ChattoAdapter(cfg),
  2010. check_fn=check_requirements,
  2011. validate_config=validate_config,
  2012. is_connected=is_connected,
  2013. required_env=["CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD"],
  2014. install_hint="Requires a Chatto server. See https://docs.chatto.run",
  2015. env_enablement_fn=ChattoConfiguration.env_only_to_seed_extra,
  2016. setup_fn=interactive_setup,
  2017. apply_yaml_config_fn=_apply_yaml_config,
  2018. cron_deliver_env_var="CHATTO_HOME_CHANNEL",
  2019. standalone_sender_fn=_standalone_send,
  2020. allowed_users_env="CHATTO_ALLOWED_USERS",
  2021. allow_all_env="CHATTO_ALLOW_ALL_USERS",
  2022. max_message_length=_MAX_MESSAGE_LENGTH,
  2023. emoji="💬",
  2024. allow_update_command=True,
  2025. pii_safe=False,
  2026. platform_hint=(
  2027. "You are chatting in Chatto (a self-hosted team chat server). "
  2028. "Markdown IS supported. Users address you by @-mentioning your name "
  2029. "in rooms; direct messages reach you without a mention. "
  2030. "Keep responses conversational."
  2031. ),
  2032. )