adapter.py 87 KB

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