adapter.py 89 KB

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