adapter.py 87 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126
  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 (lazy loaded)
  45. from tools.lazy_deps import lazy_import
  46. ChattoClient = lazy_import("chattolib", "ChattoClient")
  47. ChattoError = lazy_import("chattolib", "ChattoError")
  48. ChattoAuthError = lazy_import("chattolib", "ChattoAuthError")
  49. ChattoConnectError = lazy_import("chattolib", "ChattoConnectError")
  50. ChattoRealtimeError = lazy_import("chattolib", "ChattoRealtimeError")
  51. ChattoRealtimeCloseError = lazy_import("chattolib", "ChattoRealtimeCloseError")
  52. RealtimeConnection = lazy_import("chattolib", "RealtimeConnection")
  53. RealtimeEvent = lazy_import("chattolib", "RealtimeEvent")
  54. ServerHello = lazy_import("chattolib", "ServerHello")
  55. stream_events = lazy_import("chattolib", "stream_events")
  56. # chattolib types
  57. RoomKind = lazy_import("chattolib.types", "RoomKind")
  58. PresenceStatus = lazy_import("chattolib.types", "PresenceStatus")
  59. RoomWithViewerState = lazy_import("chattolib.types", "RoomWithViewerState")
  60. User = lazy_import("chattolib.types", "User")
  61. Message = lazy_import("chattolib.types", "Message")
  62. # --------------------------------------------------------------------------- #
  63. # Constants
  64. # --------------------------------------------------------------------------- #
  65. _MAX_MESSAGE_LENGTH = 10000
  66. _SEEN_CAP = 500
  67. # WebSocket / realtime protocol
  68. _WS_PATH = "/api/realtime"
  69. _WS_AUTH_TIMEOUT = 20.0
  70. _WS_MAX_MESSAGE_BYTES = 4_000_000
  71. _WS_PING_INTERVAL = 30.0
  72. _WS_RECONNECT_INITIAL_BACKOFF = 1.0
  73. _WS_RECONNECT_MAX_BACKOFF = 30.0
  74. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  75. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  76. _EMOJI_TO_SHORTCODE: Dict[str, str] = {
  77. "👍": "thumbsup",
  78. "👎": "thumbsdown",
  79. "❤️": "heart",
  80. "❤": "heart",
  81. "✅": "white_check_mark",
  82. "❌": "x",
  83. "👀": "eyes",
  84. "🎉": "tada",
  85. "😂": "joy",
  86. "🚀": "rocket",
  87. "🔥": "fire",
  88. "💯": "100",
  89. "🤔": "thinking",
  90. "👏": "clap",
  91. "🙏": "pray",
  92. "😅": "sweat_smile",
  93. "😴": "sleeping",
  94. "⏳": "hourglass",
  95. }
  96. # Chunk size for asset uploads (256 KB)
  97. _UPLOAD_CHUNK_SIZE = 256 * 1024
  98. # --------------------------------------------------------------------------- #
  99. # Adapter
  100. # --------------------------------------------------------------------------- #
  101. class ChattoAdapter(BasePlatformAdapter):
  102. """Chatto platform adapter — receives messages via WebSocket realtime,
  103. sends via ConnectRPC REST."""
  104. MAX_MESSAGE_LENGTH = 10000
  105. _SPLIT_THRESHOLD = 9900
  106. splits_long_messages = True
  107. def __init__(self, config, **kwargs):
  108. platform = Platform("chatto")
  109. super().__init__(config=config, platform=platform)
  110. extra = getattr(config, "extra", {}) or {}
  111. # --- Configuration (env > config.yaml extra) ---
  112. self._base_url = (
  113. os.getenv("CHATTO_URL", "").strip()
  114. or str(extra.get("url", "")).strip()
  115. )
  116. self._login = os.getenv("CHATTO_LOGIN", "").strip()
  117. self._password = os.getenv("CHATTO_PASSWORD", "").strip()
  118. raw_channels = os.getenv("CHATTO_CHANNELS", "").strip()
  119. if raw_channels:
  120. self._channel_ids = [c.strip() for c in raw_channels.split(",") if c.strip()]
  121. elif isinstance(extra.get("channels"), list):
  122. self._channel_ids = [str(c) for c in extra["channels"]]
  123. else:
  124. self._channel_ids = []
  125. self._home_channel = (
  126. os.getenv("CHATTO_HOME_CHANNEL", "").strip()
  127. or str(extra.get("home_channel", "")).strip()
  128. )
  129. self._require_mention = os.getenv("CHATTO_REQUIRE_MENTION", "").strip().lower()
  130. if self._require_mention:
  131. self._require_mention = self._require_mention in ("true", "1", "yes")
  132. else:
  133. self._require_mention = bool(extra.get("require_mention", True))
  134. # free_response_channels: room IDs where the bot responds without being tagged
  135. fr_env = os.getenv("CHATTO_FREE_RESPONSE_CHANNELS", "").strip()
  136. if fr_env:
  137. self._free_response_channels = set(c.strip() for c in fr_env.split(",") if c.strip())
  138. else:
  139. self._free_response_channels = set(
  140. str(c) for c in extra.get("free_response_channels", []) if str(c).strip()
  141. )
  142. # --- Chattolib client ---
  143. self._chatto_client: Optional[ChattoClient] = None
  144. # --- Runtime state ---
  145. self._token: Optional[str] = None
  146. self._user_id: str = ""
  147. self._user_login: str = ""
  148. self._user_display: str = ""
  149. self._room_names: Dict[str, str] = {}
  150. self._room_kinds: Dict[str, str] = {}
  151. self._our_thread_roots: set = set() # thread root event IDs we created
  152. self._our_message_ids: set = set() # message IDs we sent (for thread root detection)
  153. self._seen: Dict[str, OrderedDict] = {} # room_id -> OrderedDict(event_id -> None)
  154. self._resume_cursor: Optional[str] = None
  155. self._watch_room_ids: List[str] = []
  156. self._ws_task: Optional[asyncio.Task] = None
  157. self._ws_ready: Optional[asyncio.Event] = None
  158. self._ws_active = False
  159. self._ws_ref = None # reference to open websocket for dynamic resubscribe
  160. # Persistent typing indicator loops per room
  161. self._typing_tasks: Dict[str, asyncio.Task] = {}
  162. # Liveness probe (REST health check)
  163. self._liveness_interval_seconds = 60.0
  164. self._liveness_failure_threshold = 3
  165. self._liveness_task: Optional[asyncio.Task] = None
  166. # Member directory cache: user_id -> user info dict
  167. self._user_cache: Dict[str, dict] = {}
  168. # ------------------------------------------------------------------ #
  169. # Auth
  170. # ------------------------------------------------------------------ #
  171. async def _get_chatto_client(self) -> Optional[ChattoClient]:
  172. """Get or create a ChattoClient instance."""
  173. if self._chatto_client is not None:
  174. return self._chatto_client
  175. if not self._base_url or not self._login or not self._password:
  176. logger.error("Chatto: missing configuration (URL, login, or password)")
  177. return None
  178. try:
  179. self._chatto_client = await ChattoClient.login(
  180. self._login,
  181. self._password,
  182. base_url=self._base_url,
  183. )
  184. logger.info("Chatto: logged in as %s via chattolib", self._login)
  185. return self._chatto_client
  186. except ChattoAuthError as e:
  187. logger.error("Chatto: authentication failed: %s", e)
  188. self._set_fatal_error("auth_failed", str(e), retryable=True)
  189. return None
  190. except Exception as e:
  191. logger.error("Chatto: failed to create client: %s", e)
  192. self._set_fatal_error("client_error", str(e), retryable=True)
  193. return None
  194. async def _ensure_token(self) -> bool:
  195. """Login via chattolib."""
  196. if self._token:
  197. return True
  198. client = await self._get_chatto_client()
  199. if client is not None:
  200. self._token = client.token
  201. return True
  202. return False
  203. async def _relogin(self) -> bool:
  204. """Force re-login (token expired)."""
  205. self._token = None
  206. self._chatto_client = None # Also clear chattolib client
  207. return await self._ensure_token()
  208. # ------------------------------------------------------------------ #
  209. # Connection
  210. # ------------------------------------------------------------------ #
  211. async def connect(self, *, is_reconnect: bool = False) -> bool:
  212. """Login, discover rooms, start WebSocket realtime connection."""
  213. if not await self._ensure_token():
  214. return False
  215. client = self._chatto_client
  216. # Get our own user info
  217. try:
  218. me = await client.me()
  219. self._user_id = str(me.id)
  220. self._user_login = str(me.login)
  221. self._user_display = str(me.display_name or "")
  222. logger.info("Chatto: got user info: %s", self._user_login)
  223. except Exception as e:
  224. logger.error("Chatto: failed to get user info: %s", e)
  225. self._set_fatal_error("connect_failed", str(e), retryable=True)
  226. return False
  227. # Discover rooms
  228. try:
  229. rooms_list = await client.list_rooms()
  230. rooms = []
  231. for room_with_state in rooms_list:
  232. entry = {
  233. "room": {
  234. "id": str(room_with_state.room.id),
  235. "name": str(room_with_state.room.name),
  236. "kind": str(room_with_state.room.kind.value) if room_with_state.room.kind else "",
  237. },
  238. "viewerState": {
  239. "isMember": room_with_state.viewer_state.is_member if room_with_state.viewer_state else False,
  240. }
  241. }
  242. rooms.append(entry)
  243. logger.info("Chatto: got %d rooms", len(rooms))
  244. except Exception as e:
  245. logger.error("Chatto: failed to list rooms: %s", e)
  246. self._set_fatal_error("connect_failed", str(e), retryable=True)
  247. return False
  248. all_room_ids = []
  249. for entry in rooms:
  250. room = entry.get("room", {})
  251. rid = str(room.get("id", ""))
  252. if not rid:
  253. continue
  254. name = str(room.get("name", rid))
  255. kind = str(room.get("kind", ""))
  256. self._room_names[rid] = name
  257. self._room_kinds[rid] = kind
  258. viewer = entry.get("viewerState", {})
  259. is_member = viewer.get("isMember", False)
  260. # If user-specified channels, only watch those; otherwise watch all joined rooms
  261. if self._channel_ids:
  262. if rid in self._channel_ids and not is_member:
  263. await self._join_room(rid)
  264. all_room_ids.append(rid)
  265. elif is_member:
  266. all_room_ids.append(rid)
  267. if self._channel_ids:
  268. watch = list(self._channel_ids)
  269. else:
  270. watch = all_room_ids
  271. if not watch:
  272. logger.error("Chatto: no rooms to watch (join a room or set CHATTO_CHANNELS)")
  273. self._set_fatal_error("config_missing", "no Chatto rooms to watch", retryable=False)
  274. return False
  275. # Ensure we're a member of each watched room
  276. for rid in watch:
  277. if self._room_kinds.get(rid) != "ROOM_KIND_DM":
  278. await self._join_room(rid)
  279. # Pick home channel
  280. if not self._home_channel:
  281. self._home_channel = watch[0]
  282. self._watch_room_ids = watch
  283. # Initialize seen for each room — seed from REST to avoid replaying history
  284. for rid in watch:
  285. self._seen[rid] = OrderedDict()
  286. await self._seed_room(rid)
  287. # Start WebSocket realtime connection
  288. if not await self._start_chattolib_realtime():
  289. self._set_fatal_error(
  290. "ws_connect_failed",
  291. "Chatto WebSocket realtime connection failed",
  292. retryable=True,
  293. )
  294. return False
  295. self._mark_connected()
  296. self._start_liveness_probe()
  297. logger.info(
  298. "Chatto: connected to %s as %s, watching %d room(s) via WebSocket",
  299. self._base_url,
  300. self._user_display or self._user_login,
  301. len(watch),
  302. )
  303. # Broadcast online presence so the bot appears online in the member list
  304. try:
  305. await self.set_presence("online")
  306. except Exception:
  307. logger.debug("Chatto: set_presence(online) failed on connect", exc_info=True)
  308. return True
  309. async def disconnect(self) -> None:
  310. """Stop WebSocket, liveness probe, typing tasks, and clear state."""
  311. # Broadcast away presence before tearing down
  312. try:
  313. await self.set_presence("away")
  314. except Exception:
  315. logger.debug("Chatto: set_presence(away) failed on disconnect", exc_info=True)
  316. self._mark_disconnected()
  317. self._ws_active = False
  318. # Cancel liveness probe
  319. await self._cancel_liveness_task()
  320. # Cancel all typing tasks
  321. for chat_id in list(self._typing_tasks.keys()):
  322. await self.stop_typing(chat_id)
  323. if self._ws_task and not self._ws_task.done():
  324. self._ws_task.cancel()
  325. try:
  326. await self._ws_task
  327. except (asyncio.CancelledError, Exception):
  328. pass
  329. self._ws_task = None
  330. self._token = None
  331. # ------------------------------------------------------------------ #
  332. # Liveness probe
  333. # ------------------------------------------------------------------ #
  334. def _start_liveness_probe(self) -> None:
  335. """Start the periodic REST health probe."""
  336. if (
  337. self._liveness_interval_seconds <= 0
  338. or self._liveness_failure_threshold <= 0
  339. ):
  340. return
  341. if self._liveness_task and not self._liveness_task.done():
  342. return
  343. self._liveness_task = asyncio.create_task(self._liveness_loop())
  344. async def _cancel_liveness_task(self) -> None:
  345. """Cancel the liveness probe task."""
  346. task = self._liveness_task
  347. self._liveness_task = None
  348. if task and not task.done():
  349. task.cancel()
  350. try:
  351. await task
  352. except (asyncio.CancelledError, Exception):
  353. pass
  354. async def _liveness_loop(self) -> None:
  355. """Periodically check if the REST API is alive via ViewerService/GetViewer.
  356. Also refreshes presence status on each successful probe so the bot
  357. stays showing as online — Chatto's presence expires if not refreshed.
  358. On ``threshold`` consecutive failures, set a fatal error with
  359. ``retryable=True`` so the gateway runner rebuilds the adapter.
  360. """
  361. interval = self._liveness_interval_seconds
  362. threshold = self._liveness_failure_threshold
  363. failures = 0
  364. while self._running:
  365. try:
  366. await asyncio.sleep(interval)
  367. except asyncio.CancelledError:
  368. return
  369. if not self._running:
  370. return
  371. try:
  372. await self._chatto_client.get_viewer()
  373. failures = 0
  374. # Refresh presence to keep showing as online
  375. try:
  376. await self.set_presence("online")
  377. except Exception:
  378. logger.debug("Chatto: presence refresh failed", exc_info=True)
  379. continue
  380. except asyncio.CancelledError:
  381. return
  382. except Exception as e:
  383. reason = str(e)
  384. failures += 1
  385. logger.warning(
  386. "Chatto: liveness probe failed (%s, %d/%d)",
  387. reason,
  388. failures,
  389. threshold,
  390. )
  391. if failures < threshold:
  392. continue
  393. # Threshold exceeded — force reconnect
  394. logger.error(
  395. "Chatto: liveness probe failed %d times consecutively; forcing reconnect",
  396. failures,
  397. )
  398. self._set_fatal_error(
  399. "chatto_liveness_failed",
  400. f"Chatto REST API liveness check failed: {reason}",
  401. retryable=True,
  402. )
  403. # Cancel the WebSocket to trigger reconnect
  404. if self._ws_task and not self._ws_task.done():
  405. self._ws_task.cancel()
  406. return
  407. async def _join_room(self, room_id: str) -> None:
  408. """Join a room if not already a member."""
  409. try:
  410. await self._chatto_client.join_room(room_id=room_id)
  411. logger.debug("Chatto: joined room %s (%s)", room_id, self._room_names.get(room_id, room_id))
  412. except ChattoError as e:
  413. if "permission_denied" in str(e).lower() or "403" in str(e):
  414. logger.debug("Chatto: already a member of %s or cannot join", room_id)
  415. else:
  416. logger.debug("Chatto: join room %s failed: %s", room_id, e)
  417. async def _seed_room(self, room_id: str) -> None:
  418. """Seed high-water mark from the newest events so a restart doesn't replay history."""
  419. try:
  420. room_service_pb2 = lazy_import("chattolib._pb.chatto.api.v1", "room_service_pb2")
  421. pb_to_dict = lazy_import("chattolib._transport", "pb_to_dict")
  422. resp = await self._chatto_client.services.rooms.get_room_events(
  423. room_service_pb2.GetRoomEventsRequest(room_id=room_id),
  424. headers=self._chatto_client._headers(),
  425. )
  426. data = pb_to_dict(resp)
  427. events = data.get("page", {}).get("events", [])
  428. for ev in events:
  429. ev_id = str(ev.get("id", ""))
  430. if ev_id:
  431. self._mark_seen(room_id, ev_id)
  432. logger.debug("Chatto: seeded room %s with %d events", room_id, len(events))
  433. except Exception as e:
  434. logger.debug("Chatto: get room events failed for %s: %s", room_id, e)
  435. def _mark_seen(self, room_id: str, event_id: str) -> None:
  436. seen = self._seen.setdefault(room_id, OrderedDict())
  437. seen[event_id] = None
  438. while len(seen) > _SEEN_CAP:
  439. seen.popitem(last=False)
  440. def _is_seen(self, room_id: str, event_id: str) -> bool:
  441. return event_id in self._seen.get(room_id, {})
  442. # ------------------------------------------------------------------ #
  443. # WebSocket Realtime Transport
  444. # ------------------------------------------------------------------ #
  445. def _websocket_url(self) -> str:
  446. """Build the WebSocket URL from the base HTTP URL."""
  447. parsed = urlsplit(self._base_url.strip())
  448. scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
  449. if scheme not in ("ws", "wss") or not parsed.netloc:
  450. raise ValueError(f"Chatto URL must use http(s) or ws(s), got {parsed.scheme}")
  451. path = parsed.path.rstrip("/") + _WS_PATH
  452. return urlunsplit((scheme, parsed.netloc, path, parsed.query, ""))
  453. async def _start_chattolib_realtime(self) -> bool:
  454. """Start realtime connection using chattolib's stream_events."""
  455. self._ws_ready = asyncio.Event()
  456. self._ws_task = asyncio.create_task(self._chattolib_event_loop())
  457. try:
  458. await asyncio.wait_for(self._ws_ready.wait(), timeout=_WS_AUTH_TIMEOUT + 10)
  459. except (asyncio.TimeoutError, TimeoutError):
  460. logger.warning("Chatto: chattolib realtime did not connect in time")
  461. self._ws_active = False
  462. if self._ws_task and not self._ws_task.done():
  463. self._ws_task.cancel()
  464. try:
  465. await self._ws_task
  466. except asyncio.CancelledError:
  467. pass
  468. self._ws_task = None
  469. return False
  470. return True
  471. async def _chattolib_event_loop(self) -> None:
  472. """Event loop using chattolib's stream_events.
  473. This replaces the manual WebSocket loop with chattolib's high-level
  474. stream_events() which provides pre-decoded RealtimeEvent objects.
  475. """
  476. client = self._chatto_client
  477. backoff = _WS_RECONNECT_INITIAL_BACKOFF
  478. try:
  479. while True:
  480. try:
  481. logger.info("Chatto: starting chattolib event stream with %d rooms", len(self._watch_room_ids))
  482. # Start streaming events
  483. async for event in stream_events(
  484. client,
  485. resume_cursor=self._resume_cursor,
  486. retained_room_ids=self._watch_room_ids,
  487. ):
  488. # Signal that we're connected and ready
  489. if not self._ws_ready.is_set():
  490. self._ws_active = True
  491. self._ws_ready.set()
  492. backoff = _WS_RECONNECT_INITIAL_BACKOFF
  493. # Handle different event kinds
  494. if event.kind == "projection_event":
  495. # Convert chattolib RealtimeEvent to our format
  496. # event.payload is the RealtimeProjectionEvent protobuf
  497. try:
  498. # Extract the raw bytes for compatibility with existing handler
  499. # For now, we'll use the existing _handle_projection_event
  500. # which expects bytes. We need to convert.
  501. #
  502. # Actually, let's create a new handler that works with
  503. # chattolib's event objects directly.
  504. await self._handle_chattolib_projection_event(event)
  505. except Exception as e:
  506. logger.warning("Chatto: failed to handle projection event: %s", e)
  507. elif event.kind == "caught_up":
  508. # Update resume cursor
  509. if hasattr(event.payload, 'cursor'):
  510. self._resume_cursor = event.payload.cursor
  511. logger.debug("Chatto: caught_up received, cursor=%s", self._resume_cursor or "(none)")
  512. elif event.kind in ("message_posted", "mention_notification",
  513. "new_direct_message_notification", "user_joined_room",
  514. "room_created", "user_left_room", "message_edited",
  515. "message_retracted", "session_terminated"):
  516. # Transient events - convert to envelope format for existing handler
  517. await self._handle_chattolib_transient_event(event)
  518. elif event.kind in ("heartbeat", "pong", "subscribed"):
  519. # Ignore these
  520. logger.debug("Chatto: %s event received", event.kind)
  521. elif event.kind == "error":
  522. logger.warning("Chatto: server error event: %s", event.payload)
  523. elif event.kind == "close":
  524. logger.info("Chatto: server sent close event")
  525. raise ConnectionError("Server closed connection")
  526. else:
  527. logger.debug("Chatto: unknown event kind: %s", event.kind)
  528. except ChattoRealtimeCloseError as e:
  529. logger.warning("Chatto: realtime closed by server: %s (reconnect=%s)", e.message, e.reconnect)
  530. if e.reconnect:
  531. self._ws_active = False
  532. await asyncio.sleep(backoff)
  533. backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
  534. continue
  535. raise
  536. except ChattoRealtimeError as e:
  537. logger.warning("Chatto: realtime error: %s (fatal=%s)", e.message, e.fatal)
  538. if e.fatal:
  539. raise
  540. except (ConnectionError, asyncio.CancelledError):
  541. raise
  542. except Exception as e:
  543. self._ws_active = False
  544. logger.warning("Chatto: event stream error: %s, retrying in %.1fs", e, backoff)
  545. await asyncio.sleep(backoff)
  546. backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
  547. finally:
  548. self._ws_active = False
  549. async def _handle_chattolib_projection_event(self, event: RealtimeEvent) -> None:
  550. """Handle a chattolib RealtimeEvent with kind='projection_event'.
  551. This is a wrapper that converts chattolib's event to the format
  552. expected by _handle_projection_event.
  553. """
  554. try:
  555. # event.payload is a RealtimeProjectionEvent protobuf message
  556. # We need to convert it to the dict format that _handle_projection_event expects
  557. from tools.lazy_deps import lazy_import
  558. pb_to_dict = lazy_import("chattolib._transport", "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. pb_to_dict = lazy_import("chattolib._transport", "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. # Import all required protobuf modules
  830. thread_service_pb2 = lazy_import("chattolib._pb.chatto.api.v1", "thread_service_pb2")
  831. room_service_pb2 = lazy_import("chattolib._pb.chatto.api.v1", "room_service_pb2")
  832. pb_to_dict = lazy_import("chattolib._transport", "pb_to_dict")
  833. if thread_root_event_id:
  834. # Thread reply — use GetThreadEvents
  835. resp = await self._chatto_client.services.threads.get_thread_events(
  836. thread_service_pb2.GetThreadEventsRequest(
  837. room_id=room_id,
  838. thread_root_event_id=thread_root_event_id,
  839. ),
  840. headers=self._chatto_client._headers(),
  841. )
  842. data = pb_to_dict(resp)
  843. else:
  844. # Regular room message — use GetRoomEvents
  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._chatto_client.update_typing_indicator(room_id=str(chat_id))
  1104. except asyncio.CancelledError:
  1105. return
  1106. except Exception:
  1107. pass
  1108. await asyncio.sleep(10)
  1109. except asyncio.CancelledError:
  1110. pass
  1111. finally:
  1112. self._typing_tasks.pop(chat_id, None)
  1113. self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
  1114. async def stop_typing(self, chat_id: str) -> None:
  1115. """Stop the persistent typing indicator for a room."""
  1116. task = self._typing_tasks.pop(chat_id, None)
  1117. if task:
  1118. task.cancel()
  1119. try:
  1120. await task
  1121. except (asyncio.CancelledError, Exception):
  1122. pass
  1123. async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
  1124. """Get information about a chat/room."""
  1125. name = self._room_names.get(chat_id, chat_id)
  1126. kind = self._room_kinds.get(chat_id, "")
  1127. chat_type = "dm" if kind == "ROOM_KIND_DM" else "group"
  1128. return {
  1129. "name": name,
  1130. "type": chat_type,
  1131. }
  1132. # ------------------------------------------------------------------ #
  1133. # Reactions
  1134. # ------------------------------------------------------------------ #
  1135. @staticmethod
  1136. def _emoji_to_shortcode(emoji: str) -> str:
  1137. """Convert a unicode emoji to a Chatto shortcode name.
  1138. If the emoji is already a shortcode (no unicode mapping found),
  1139. return it as-is.
  1140. """
  1141. shortcode = _EMOJI_TO_SHORTCODE.get(emoji)
  1142. if shortcode:
  1143. return shortcode
  1144. # Already a shortcode like "thumbsup" — return as-is
  1145. return emoji
  1146. async def send_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
  1147. """Add a reaction to a message via MessageService/AddReaction."""
  1148. shortcode = self._emoji_to_shortcode(emoji)
  1149. try:
  1150. result = await self._chatto_client.add_reaction(
  1151. room_id=str(chat_id),
  1152. message_event_id=str(message_id),
  1153. emoji=shortcode,
  1154. )
  1155. return result
  1156. except ChattoError as e:
  1157. logger.debug("Chatto: AddReaction failed: %s", e)
  1158. return False
  1159. except Exception as e:
  1160. logger.debug("Chatto: AddReaction error: %s", e)
  1161. return False
  1162. async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
  1163. """Remove a reaction from a message via MessageService/RemoveReaction."""
  1164. shortcode = self._emoji_to_shortcode(emoji)
  1165. try:
  1166. result = await self._chatto_client.remove_reaction(
  1167. room_id=str(chat_id),
  1168. message_event_id=str(message_id),
  1169. emoji=shortcode,
  1170. )
  1171. return result
  1172. except ChattoError as e:
  1173. logger.debug("Chatto: RemoveReaction failed: %s", e)
  1174. return False
  1175. except Exception as e:
  1176. logger.debug("Chatto: RemoveReaction error: %s", e)
  1177. return False
  1178. # ------------------------------------------------------------------ #
  1179. # Read state management (Chatto-unique)
  1180. # ------------------------------------------------------------------ #
  1181. async def mark_room_as_read(self, room_id: str) -> bool:
  1182. """Mark a room as read via RoomService/MarkRoomAsRead."""
  1183. try:
  1184. await self._chatto_client.mark_room_as_read(room_id=str(room_id))
  1185. return True
  1186. except ChattoError as e:
  1187. logger.debug("Chatto: MarkRoomAsRead failed: %s", e)
  1188. return False
  1189. except Exception as e:
  1190. logger.debug("Chatto: MarkRoomAsRead error: %s", e)
  1191. return False
  1192. async def mark_thread_as_read(self, room_id: str, thread_root_event_id: str) -> bool:
  1193. """Mark a thread as read via ThreadService/MarkThreadAsRead."""
  1194. try:
  1195. await self._chatto_client.mark_thread_as_read(
  1196. room_id=str(room_id), thread_root_event_id=str(thread_root_event_id)
  1197. )
  1198. return True
  1199. except ChattoError as e:
  1200. logger.debug("Chatto: MarkThreadAsRead failed: %s", e)
  1201. return False
  1202. except Exception as e:
  1203. logger.debug("Chatto: MarkThreadAsRead error: %s", e)
  1204. return False
  1205. # ------------------------------------------------------------------ #
  1206. # DM initiation (Chatto-unique)
  1207. # ------------------------------------------------------------------ #
  1208. async def start_dm(self, user_id: str) -> Optional[str]:
  1209. """Start a direct message with a user via RoomService/StartDM.
  1210. Returns the room ID on success, or None on failure.
  1211. """
  1212. if not user_id:
  1213. return None
  1214. try:
  1215. room = await self._chatto_client.start_dm(participant_ids=[str(user_id)])
  1216. rid = str(room.id) if room else ""
  1217. if rid:
  1218. self._room_names[rid] = self._room_names.get(rid, "")
  1219. self._room_kinds[rid] = "ROOM_KIND_DM"
  1220. return rid
  1221. logger.debug("Chatto: StartDM returned no room id")
  1222. return None
  1223. except ChattoError as e:
  1224. logger.debug("Chatto: StartDM failed: %s", e)
  1225. return None
  1226. except Exception as e:
  1227. logger.debug("Chatto: StartDM error: %s", e)
  1228. return None
  1229. # ------------------------------------------------------------------ #
  1230. # Thread following (Chatto-unique)
  1231. # ------------------------------------------------------------------ #
  1232. async def _follow_thread(self, room_id: str, thread_root_event_id: str) -> None:
  1233. """Best-effort: follow a thread via ThreadService/FollowThread."""
  1234. try:
  1235. await self._chatto_client.follow_thread(
  1236. room_id=str(room_id), thread_root_event_id=str(thread_root_event_id)
  1237. )
  1238. except ChattoError as e:
  1239. logger.debug("Chatto: FollowThread failed: %s", e)
  1240. except Exception as e:
  1241. logger.debug("Chatto: FollowThread error: %s", e)
  1242. # ------------------------------------------------------------------ #
  1243. # Room creation (Chatto-unique)
  1244. # ------------------------------------------------------------------ #
  1245. async def create_room(
  1246. self,
  1247. name: str,
  1248. description: str = "",
  1249. group_id: str = "",
  1250. universal: bool = True,
  1251. ) -> Optional[str]:
  1252. """Create an ad-hoc room via RoomService/CreateRoom.
  1253. Returns the room ID on success, or None on failure.
  1254. """
  1255. try:
  1256. room = await self._chatto_client.create_room(
  1257. name=name,
  1258. group_id=group_id or "",
  1259. description=description,
  1260. universal=universal,
  1261. )
  1262. rid = str(room.id) if room else ""
  1263. if rid:
  1264. self._room_names[rid] = name
  1265. self._room_kinds[rid] = "ROOM_KIND_GROUP"
  1266. return rid
  1267. logger.debug("Chatto: CreateRoom returned no room id")
  1268. return None
  1269. except ChattoError as e:
  1270. logger.debug("Chatto: CreateRoom failed: %s", e)
  1271. return None
  1272. except Exception as e:
  1273. logger.debug("Chatto: CreateRoom error: %s", e)
  1274. return None
  1275. # ------------------------------------------------------------------ #
  1276. # Notification dismissal (Chatto-unique)
  1277. # ------------------------------------------------------------------ #
  1278. async def dismiss_all_notifications(self) -> bool:
  1279. """Dismiss all notifications via NotificationService/DismissAllNotifications."""
  1280. try:
  1281. await self._chatto_client.dismiss_all_notifications()
  1282. return True
  1283. except ChattoError as e:
  1284. logger.debug("Chatto: DismissAllNotifications failed: %s", e)
  1285. return False
  1286. except Exception as e:
  1287. logger.debug("Chatto: DismissAllNotifications error: %s", e)
  1288. return False
  1289. async def dismiss_notification(self, notification_id: str) -> bool:
  1290. """Dismiss a single notification via NotificationService/DismissNotification."""
  1291. try:
  1292. await self._chatto_client.dismiss_notification(notification_id=str(notification_id))
  1293. return True
  1294. except ChattoError as e:
  1295. logger.debug("Chatto: DismissNotification failed: %s", e)
  1296. return False
  1297. except Exception as e:
  1298. logger.debug("Chatto: DismissNotification error: %s", e)
  1299. return False
  1300. # ------------------------------------------------------------------ #
  1301. # Message editing and deletion
  1302. # ------------------------------------------------------------------ #
  1303. async def edit_message(
  1304. self,
  1305. chat_id: str,
  1306. message_id: str,
  1307. new_content: str,
  1308. metadata: Optional[Dict[str, Any]] = None,
  1309. ) -> bool:
  1310. """Edit a previously sent message via MessageService/UpdateMessage."""
  1311. try:
  1312. await self._chatto_client.update_message(
  1313. room_id=str(chat_id),
  1314. event_id=str(message_id),
  1315. body=new_content,
  1316. )
  1317. return True
  1318. except ChattoError as e:
  1319. logger.debug("Chatto: UpdateMessage failed: %s", e)
  1320. return False
  1321. except Exception as e:
  1322. logger.debug("Chatto: UpdateMessage error: %s", e)
  1323. return False
  1324. async def delete_message(
  1325. self,
  1326. chat_id: str,
  1327. message_id: str,
  1328. metadata: Optional[Dict[str, Any]] = None,
  1329. ) -> bool:
  1330. """Delete a previously sent message via MessageService/DeleteMessage."""
  1331. try:
  1332. result = await self._chatto_client.delete_message(
  1333. room_id=str(chat_id),
  1334. event_id=str(message_id),
  1335. )
  1336. return result
  1337. except ChattoError as e:
  1338. logger.debug("Chatto: DeleteMessage failed: %s", e)
  1339. return False
  1340. except Exception as e:
  1341. logger.debug("Chatto: DeleteMessage error: %s", e)
  1342. return False
  1343. # ------------------------------------------------------------------ #
  1344. # Processing lifecycle hooks (reactions-based, like Discord)
  1345. # ------------------------------------------------------------------ #
  1346. def _reactions_enabled(self) -> bool:
  1347. """Check if processing reactions are enabled."""
  1348. return os.getenv("CHATTO_REACTIONS", "true").lower() not in {"false", "0", "no"}
  1349. def _event_room_and_message_id(self, event: MessageEvent) -> Tuple[str, str]:
  1350. """Extract room_id and message_id from a MessageEvent."""
  1351. chat_id = ""
  1352. message_id = str(event.message_id or "")
  1353. source = event.source
  1354. if source:
  1355. chat_id = str(getattr(source, "chat_id", "") or "")
  1356. # Fallback: try raw_message dict
  1357. if not chat_id or not message_id:
  1358. raw = event.raw_message
  1359. if isinstance(raw, dict):
  1360. if not chat_id:
  1361. chat_id = str(raw.get("roomId", "") or "")
  1362. if not message_id:
  1363. message_id = str(raw.get("id", "") or "")
  1364. return chat_id, message_id
  1365. async def on_processing_start(self, event: MessageEvent) -> None:
  1366. """Add an 👀 (eyes) reaction to the incoming message."""
  1367. if not self._reactions_enabled():
  1368. return
  1369. chat_id, message_id = self._event_room_and_message_id(event)
  1370. if not chat_id or not message_id:
  1371. return
  1372. await self.send_reaction(chat_id, message_id, "👀")
  1373. async def on_processing_complete(
  1374. self, event: MessageEvent, outcome: ProcessingOutcome
  1375. ) -> None:
  1376. """Swap the 👀 reaction for ✅ (success) or ❌ (failure)."""
  1377. if not self._reactions_enabled():
  1378. return
  1379. chat_id, message_id = self._event_room_and_message_id(event)
  1380. if not chat_id or not message_id:
  1381. return
  1382. # Remove the processing eyes reaction
  1383. await self.remove_reaction(chat_id, message_id, "👀")
  1384. # Add the outcome reaction
  1385. if outcome == ProcessingOutcome.SUCCESS:
  1386. await self.send_reaction(chat_id, message_id, "✅")
  1387. elif outcome == ProcessingOutcome.FAILURE:
  1388. await self.send_reaction(chat_id, message_id, "❌")
  1389. # ------------------------------------------------------------------ #
  1390. # Asset upload (chunked)
  1391. # ------------------------------------------------------------------ #
  1392. async def _upload_asset(self, room_id: str, file_path: str) -> Optional[str]:
  1393. """Upload a file via the chunked AssetUploadService.
  1394. Returns the asset ID on success, or None on failure.
  1395. """
  1396. try:
  1397. with open(file_path, "rb") as f:
  1398. file_data = f.read()
  1399. except Exception as e:
  1400. logger.error("Chatto: failed to read file %s — %s", file_path, e)
  1401. return None
  1402. if not file_data:
  1403. logger.error("Chatto: file %s is empty", file_path)
  1404. return None
  1405. file_size = len(file_data)
  1406. file_name = os.path.basename(file_path)
  1407. mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
  1408. sha256_hash = hashlib.sha256(file_data).hexdigest()
  1409. try:
  1410. # Step 1: Create upload session
  1411. upload = await self._chatto_client.create_upload(
  1412. room_id=room_id,
  1413. filename=file_name,
  1414. size=file_size,
  1415. sha256=sha256_hash,
  1416. content_type=mime_type,
  1417. )
  1418. upload_id = str(upload.id)
  1419. if not upload_id:
  1420. logger.error("Chatto: CreateUpload returned no upload ID")
  1421. return None
  1422. # Step 2: Upload chunks
  1423. offset = 0
  1424. while offset < file_size:
  1425. chunk = file_data[offset:offset + _UPLOAD_CHUNK_SIZE]
  1426. chunk_sha256 = hashlib.sha256(chunk).hexdigest()
  1427. await self._chatto_client.upload_chunk(
  1428. upload_id=upload_id,
  1429. offset=offset,
  1430. content=chunk,
  1431. chunk_sha256=chunk_sha256,
  1432. )
  1433. offset += len(chunk)
  1434. # Step 3: Complete upload
  1435. upload, asset = await self._chatto_client.complete_upload(upload_id=upload_id)
  1436. if not asset:
  1437. logger.error("Chatto: CompleteUpload returned no asset")
  1438. return None
  1439. asset_id = str(asset.id)
  1440. logger.info("Chatto: uploaded %s as asset %s (%d bytes)", file_name, asset_id, file_size)
  1441. return asset_id
  1442. except ChattoError as e:
  1443. logger.error("Chatto: upload failed: %s", e)
  1444. return None
  1445. except Exception as e:
  1446. logger.error("Chatto: upload error: %s", e)
  1447. return None
  1448. async def send_image_file(
  1449. self,
  1450. chat_id: str,
  1451. file_path: str,
  1452. caption: Optional[str] = None,
  1453. reply_to: Optional[str] = None,
  1454. metadata: Optional[Dict[str, Any]] = None,
  1455. ) -> SendResult:
  1456. """Send a local image file via the chunked upload API."""
  1457. # Validate the path is safe
  1458. safe_path = self.validate_media_delivery_path(file_path)
  1459. if not safe_path:
  1460. logger.warning("Chatto: send_image_file — unsafe path %s", file_path)
  1461. text = "⚠️ Couldn't deliver the image attachment."
  1462. if caption:
  1463. text = f"{caption}\n{text}"
  1464. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  1465. asset_id = await self._upload_asset(str(chat_id), safe_path)
  1466. if not asset_id:
  1467. # Fallback to a notice
  1468. text = "⚠️ Couldn't deliver the image attachment."
  1469. if caption:
  1470. text = f"{caption}\n{text}"
  1471. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  1472. thread_id = (metadata or {}).get("thread_id")
  1473. if reply_to:
  1474. thread_id = reply_to
  1475. try:
  1476. msg = await self._chatto_client.post_message(
  1477. room_id=str(chat_id),
  1478. body=caption or "",
  1479. attachment_asset_ids=[asset_id],
  1480. thread_root_event_id=str(thread_id) if thread_id else "",
  1481. )
  1482. msg_id = str(msg.id)
  1483. if msg_id:
  1484. self._mark_seen(str(chat_id), msg_id)
  1485. return SendResult(success=True, message_id=msg_id, raw_response=msg)
  1486. except ChattoError as e:
  1487. return SendResult(success=False, error=str(e), retryable=True)
  1488. except Exception as e:
  1489. return SendResult(success=False, error=str(e), retryable=False)
  1490. async def send_image(
  1491. self,
  1492. chat_id: str,
  1493. image_url: str,
  1494. caption: Optional[str] = None,
  1495. reply_to: Optional[str] = None,
  1496. metadata: Optional[Dict[str, Any]] = None,
  1497. ) -> SendResult:
  1498. """Send an image to a Chatto room.
  1499. Tries to download the image from the URL and upload it as a native
  1500. attachment. Falls back to sending the URL as a link (Chatto renders
  1501. link previews) if the download fails.
  1502. """
  1503. # Try downloading and uploading as attachment
  1504. try:
  1505. import tempfile
  1506. import urllib.request as _urllib_request
  1507. # Download to a temp file
  1508. parsed = urlsplit(image_url)
  1509. url_path = parsed.path
  1510. ext = os.path.splitext(url_path)[1] or ".png"
  1511. tmp_fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="chatto_img_")
  1512. try:
  1513. os.close(tmp_fd)
  1514. req = _urllib_request.Request(image_url, headers={"User-Agent": "Hermes/1.0"})
  1515. ctx = _ssl_context()
  1516. with _urllib_request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
  1517. with open(tmp_path, "wb") as f:
  1518. f.write(resp.read())
  1519. # Upload as attachment
  1520. result = await self.send_image_file(
  1521. chat_id, tmp_path, caption=caption,
  1522. reply_to=reply_to, metadata=metadata,
  1523. )
  1524. if result.success:
  1525. return result
  1526. finally:
  1527. try:
  1528. os.unlink(tmp_path)
  1529. except OSError:
  1530. pass
  1531. except Exception as e:
  1532. logger.debug("Chatto: send_image download/upload failed, falling back to link: %s", e)
  1533. # Fallback: send as link (Chatto renders link previews)
  1534. text = image_url
  1535. if caption:
  1536. text = f"{caption}\n{image_url}"
  1537. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  1538. # ------------------------------------------------------------------ #
  1539. # Platform properties
  1540. # ------------------------------------------------------------------ #
  1541. @property
  1542. def platform_name(self) -> str:
  1543. return "chatto"
  1544. @property
  1545. def supports_markdown(self) -> bool:
  1546. return True
  1547. @property
  1548. def supports_reactions(self) -> bool:
  1549. return True
  1550. # ------------------------------------------------------------------ #
  1551. # Member directory — user lookup and mention resolution (Chatto-unique)
  1552. # ------------------------------------------------------------------ #
  1553. async def list_users(self) -> list:
  1554. """List all server members via UserService/ListUsers.
  1555. Returns a list of user dicts. Each dict typically contains
  1556. ``id``, ``login``, and ``displayName`` keys.
  1557. """
  1558. try:
  1559. members, _ = await self._chatto_client.list_users()
  1560. users = []
  1561. # Cache all returned users and convert to dict format
  1562. for member in members:
  1563. if member and member.user:
  1564. user_dict = {
  1565. "id": str(member.user.id),
  1566. "login": str(member.user.login),
  1567. "displayName": str(member.user.display_name or ""),
  1568. }
  1569. uid = user_dict["id"]
  1570. if uid:
  1571. self._user_cache[uid] = user_dict
  1572. users.append(user_dict)
  1573. return users
  1574. except ChattoError as e:
  1575. logger.debug("Chatto: ListUsers failed: %s", e)
  1576. return []
  1577. except Exception as e:
  1578. logger.debug("Chatto: ListUsers error: %s", e)
  1579. return []
  1580. async def get_user(self, user_id: str) -> Optional[dict]:
  1581. """Get a single user by ID via UserService/GetUser.
  1582. Returns the user dict (containing ``id``, ``login``,
  1583. ``displayName``) or ``None`` on failure. Results are cached in
  1584. ``self._user_cache``.
  1585. """
  1586. if not user_id:
  1587. return None
  1588. # Return cached entry if available
  1589. if user_id in self._user_cache:
  1590. return self._user_cache[user_id]
  1591. try:
  1592. user_obj = await self._chatto_client.get_user(user_id=str(user_id))
  1593. if user_obj and user_obj.user:
  1594. user_dict = {
  1595. "id": str(user_obj.user.id),
  1596. "login": str(user_obj.user.login),
  1597. "displayName": str(user_obj.user.display_name or ""),
  1598. }
  1599. uid = user_dict["id"]
  1600. if uid:
  1601. self._user_cache[uid] = user_dict
  1602. return user_dict
  1603. return None
  1604. except ChattoError as e:
  1605. logger.debug("Chatto: GetUser failed: %s", e)
  1606. return None
  1607. except Exception as e:
  1608. logger.debug("Chatto: GetUser error: %s", e)
  1609. return None
  1610. async def batch_get_users(self, user_ids: list) -> list:
  1611. """Batch-fetch multiple users via UserService/BatchGetUsers.
  1612. Returns a list of user dicts. Cached entries are reused and only
  1613. uncached IDs are fetched from the server.
  1614. """
  1615. if not user_ids:
  1616. return []
  1617. # Separate cached from uncached
  1618. cached: list = []
  1619. uncached_ids: list = []
  1620. for uid in user_ids:
  1621. uid_str = str(uid)
  1622. if uid_str in self._user_cache:
  1623. cached.append(self._user_cache[uid_str])
  1624. else:
  1625. uncached_ids.append(uid_str)
  1626. if not uncached_ids:
  1627. return cached
  1628. try:
  1629. members = await self._chatto_client.batch_get_users(user_ids=uncached_ids)
  1630. fetched = []
  1631. for member in members:
  1632. if member and member.user:
  1633. user_dict = {
  1634. "id": str(member.user.id),
  1635. "login": str(member.user.login),
  1636. "displayName": str(member.user.display_name or ""),
  1637. }
  1638. uid = user_dict["id"]
  1639. if uid:
  1640. self._user_cache[uid] = user_dict
  1641. fetched.append(user_dict)
  1642. return cached + fetched
  1643. except ChattoError as e:
  1644. logger.debug("Chatto: BatchGetUsers failed: %s", e)
  1645. return cached
  1646. except Exception as e:
  1647. logger.debug("Chatto: BatchGetUsers error: %s", e)
  1648. return cached
  1649. # ------------------------------------------------------------------ #
  1650. # Presence broadcasting (Chatto-unique)
  1651. # ------------------------------------------------------------------ #
  1652. async def set_presence(self, status: str) -> bool:
  1653. """Update the bot's presence status via MyAccountService/UpdatePresence.
  1654. Accepts string values ``"online"``, ``"away"``, ``"dnd"`` (or
  1655. ``"do_not_disturb"``) and maps them to Chatto's PresenceStatus enum.
  1656. Returns ``True`` on success.
  1657. """
  1658. # Map string status to chattolib PresenceStatus enum
  1659. status_map = {
  1660. "online": PresenceStatus.ONLINE,
  1661. "away": PresenceStatus.AWAY,
  1662. "dnd": PresenceStatus.DO_NOT_DISTURB,
  1663. "do_not_disturb": PresenceStatus.DO_NOT_DISTURB,
  1664. }
  1665. status_lower = status.lower().strip()
  1666. presence_status = status_map.get(status_lower)
  1667. if presence_status is None:
  1668. logger.warning("Chatto: unknown presence status %r", status)
  1669. return False
  1670. try:
  1671. await self._chatto_client.update_presence(status=presence_status)
  1672. logger.debug("Chatto: presence set to %s", status_lower)
  1673. return True
  1674. except ChattoError as e:
  1675. logger.debug("Chatto: UpdatePresence failed: %s", e)
  1676. return False
  1677. except Exception as e:
  1678. logger.debug("Chatto: UpdatePresence error: %s", e)
  1679. return False
  1680. # ------------------------------------------------------------------ #
  1681. # Custom status messages (Chatto-unique)
  1682. # ------------------------------------------------------------------ #
  1683. async def set_custom_status(self, text: str) -> bool:
  1684. """Set a custom status message via MyAccountService/UpdateCustomStatus.
  1685. The status text is a plain string (max ~100 chars). Useful for
  1686. indicating long-running operations, e.g. ``"Processing..."``.
  1687. Returns ``True`` on success.
  1688. """
  1689. if not text:
  1690. return False
  1691. # Truncate to a reasonable length
  1692. status_text = text.strip()[:100]
  1693. if not status_text:
  1694. return False
  1695. try:
  1696. await self._chatto_client.update_custom_status(emoji="", text=status_text)
  1697. logger.debug("Chatto: custom status set to %r", status_text)
  1698. return True
  1699. except ChattoError as e:
  1700. logger.debug("Chatto: UpdateCustomStatus failed: %s", e)
  1701. return False
  1702. except Exception as e:
  1703. logger.debug("Chatto: UpdateCustomStatus error: %s", e)
  1704. return False
  1705. async def clear_custom_status(self) -> bool:
  1706. """Clear the custom status message via MyAccountService/DeleteCustomStatus.
  1707. Returns ``True`` on success.
  1708. """
  1709. try:
  1710. await self._chatto_client.delete_custom_status()
  1711. logger.debug("Chatto: custom status cleared")
  1712. return True
  1713. except ChattoError as e:
  1714. logger.debug("Chatto: DeleteCustomStatus failed: %s", e)
  1715. return False
  1716. except Exception as e:
  1717. logger.debug("Chatto: DeleteCustomStatus error: %s", e)
  1718. return False
  1719. @property
  1720. def supports_threads(self) -> bool:
  1721. return True
  1722. # --------------------------------------------------------------------------- #
  1723. # Plugin registration
  1724. # --------------------------------------------------------------------------- #
  1725. def check_requirements() -> bool:
  1726. """Check if Chatto is configured."""
  1727. return bool(
  1728. os.getenv("CHATTO_URL", "").strip()
  1729. and os.getenv("CHATTO_LOGIN", "").strip()
  1730. and os.getenv("CHATTO_PASSWORD", "").strip()
  1731. )
  1732. def validate_config(config) -> bool:
  1733. """Validate that the platform config has enough info to connect."""
  1734. extra = getattr(config, "extra", {}) or {}
  1735. url = os.getenv("CHATTO_URL") or str(extra.get("url", ""))
  1736. login = os.getenv("CHATTO_LOGIN", "").strip()
  1737. password = os.getenv("CHATTO_PASSWORD", "").strip()
  1738. return bool(url and login and password)
  1739. def is_connected(config) -> bool:
  1740. """Check whether Chatto is configured."""
  1741. return validate_config(config)
  1742. def _apply_yaml_config(yaml_cfg: dict, chatto_cfg: dict) -> Optional[dict]:
  1743. """Translate config.yaml chatto.extra keys into CHATTO_* env vars."""
  1744. extra = chatto_cfg.get("extra") if isinstance(chatto_cfg.get("extra"), dict) else {}
  1745. mapping = {
  1746. "url": "CHATTO_URL",
  1747. "home_channel": "CHATTO_HOME_CHANNEL",
  1748. "require_mention": "CHATTO_REQUIRE_MENTION",
  1749. "free_response_channels": "CHATTO_FREE_RESPONSE_CHANNELS",
  1750. "auto_thread": "CHATTO_AUTO_THREAD",
  1751. }
  1752. for yaml_key, env_key in mapping.items():
  1753. val = extra.get(yaml_key)
  1754. if val is not None and not os.getenv(env_key):
  1755. if isinstance(val, bool):
  1756. os.environ[env_key] = str(val).lower()
  1757. elif isinstance(val, list):
  1758. os.environ[env_key] = ",".join(str(v) for v in val)
  1759. else:
  1760. os.environ[env_key] = str(val)
  1761. channels = extra.get("channels")
  1762. if isinstance(channels, list) and not os.getenv("CHATTO_CHANNELS"):
  1763. os.environ["CHATTO_CHANNELS"] = ",".join(str(c) for c in channels)
  1764. allowed = extra.get("allowed_users")
  1765. if isinstance(allowed, list) and not os.getenv("CHATTO_ALLOWED_USERS"):
  1766. os.environ["CHATTO_ALLOWED_USERS"] = ",".join(str(u) for u in allowed)
  1767. if "allow_all_users" in extra and not os.getenv("CHATTO_ALLOW_ALL_USERS"):
  1768. os.environ["CHATTO_ALLOW_ALL_USERS"] = str(extra["allow_all_users"]).lower()
  1769. # Return nothing to merge — all config flows through env
  1770. return None
  1771. def _env_enablement() -> Optional[dict]:
  1772. """Seed PlatformConfig.extra from env vars for env-only setups."""
  1773. url = os.getenv("CHATTO_URL", "").strip()
  1774. if not url:
  1775. return None
  1776. extra = {"url": url}
  1777. home = os.getenv("CHATTO_HOME_CHANNEL", "").strip()
  1778. if home:
  1779. extra["home_channel"] = home
  1780. channels = os.getenv("CHATTO_CHANNELS", "").strip()
  1781. if channels:
  1782. extra["channels"] = [c.strip() for c in channels.split(",") if c.strip()]
  1783. rm = os.getenv("CHATTO_REQUIRE_MENTION", "").strip().lower()
  1784. if rm:
  1785. extra["require_mention"] = rm in ("true", "1", "yes")
  1786. home_dict = {"home_channel": home} if home else None
  1787. return {"extra": extra, "home_channel": home_dict}
  1788. async def _standalone_send(
  1789. base_url: str,
  1790. login: str,
  1791. password: str,
  1792. room_id: str,
  1793. content: str,
  1794. thread_id: Optional[str] = None,
  1795. ) -> dict:
  1796. """Out-of-process send for cron delivery (no live adapter needed)."""
  1797. try:
  1798. # Create a temporary client for standalone sending
  1799. # Use the lazy-imported ChattoClient
  1800. client = ChattoClient(base_url=base_url)
  1801. await client.login(login=login, password=password)
  1802. msg = await client.post_message(
  1803. room_id=room_id,
  1804. body=content,
  1805. thread_root_event_id=thread_id or "",
  1806. )
  1807. return {"success": True, "response": {"id": str(msg.id)}, "message_id": str(msg.id)}
  1808. except Exception as e:
  1809. return {"success": False, "error": str(e)}
  1810. def interactive_setup() -> None:
  1811. """Interactive setup wizard for Chatto."""
  1812. from hermes_cli.gateway import prompt_env, set_env_var
  1813. url = prompt_env("Chatto server URL (e.g. https://chat.example.com):")
  1814. if url:
  1815. set_env_var("CHATTO_URL", url)
  1816. login = prompt_env("Chatto login (username):")
  1817. if login:
  1818. set_env_var("CHATTO_LOGIN", login)
  1819. password = prompt_env("Chatto password:", password=True)
  1820. if password:
  1821. set_env_var("CHATTO_PASSWORD", password)
  1822. channels = prompt_env("Room IDs to watch (comma-separated, or empty for all):")
  1823. if channels:
  1824. set_env_var("CHATTO_CHANNELS", channels)
  1825. home = prompt_env("Home room ID for notifications (or empty):")
  1826. if home:
  1827. set_env_var("CHATTO_HOME_CHANNEL", home)
  1828. allow_all = prompt_env("Allow all users? (true/false):")
  1829. if allow_all:
  1830. set_env_var("CHATTO_ALLOW_ALL_USERS", allow_all)
  1831. print("\n✓ Chatto configured. Restart the gateway to activate.")
  1832. def register(ctx) -> None:
  1833. """Plugin entry point — called by the Hermes plugin system."""
  1834. ctx.register_platform(
  1835. name="chatto",
  1836. label="Chatto",
  1837. adapter_factory=lambda cfg: ChattoAdapter(cfg),
  1838. check_fn=check_requirements,
  1839. validate_config=validate_config,
  1840. is_connected=is_connected,
  1841. required_env=["CHATTO_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD"],
  1842. install_hint="Requires a Chatto server. See https://docs.chatto.run",
  1843. setup_fn=interactive_setup,
  1844. apply_yaml_config_fn=_apply_yaml_config,
  1845. cron_deliver_env_var="CHATTO_HOME_CHANNEL",
  1846. standalone_sender_fn=_standalone_send,
  1847. allowed_users_env="CHATTO_ALLOWED_USERS",
  1848. allow_all_env="CHATTO_ALLOW_ALL_USERS",
  1849. max_message_length=_MAX_MESSAGE_LENGTH,
  1850. emoji="💬",
  1851. allow_update_command=True,
  1852. pii_safe=False,
  1853. platform_hint=(
  1854. "You are chatting in Chatto (a self-hosted team chat server). "
  1855. "Markdown IS supported. Users address you by @-mentioning your name "
  1856. "in rooms; direct messages reach you without a mention. "
  1857. "Keep responses conversational."
  1858. ),
  1859. )