adapter.py 89 KB

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