adapter.py 94 KB

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