adapter.py 84 KB

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