adapter.py 113 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933
  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 base64
  29. import hashlib
  30. import json
  31. import logging
  32. import mimetypes
  33. import os
  34. import ssl
  35. import urllib.error
  36. import urllib.request
  37. from collections import OrderedDict
  38. from datetime import datetime, timezone
  39. from typing import Any, Dict, List, Optional, Tuple
  40. from urllib.parse import urlsplit, urlunsplit
  41. logger = logging.getLogger(__name__)
  42. from gateway.platforms.base import (
  43. BasePlatformAdapter,
  44. SendResult,
  45. MessageEvent,
  46. MessageType,
  47. ProcessingOutcome,
  48. )
  49. from gateway.config import Platform
  50. # --------------------------------------------------------------------------- #
  51. # Constants
  52. # --------------------------------------------------------------------------- #
  53. _HTTP_TIMEOUT = 30.0
  54. _CONNECT_RPC_VERSION = "1"
  55. _MAX_MESSAGE_LENGTH = 10000
  56. _SEEN_CAP = 500
  57. # WebSocket / realtime protocol
  58. _REALTIME_PROTOCOL_VERSION = 1 # v0.4.19 supports v1; v2 reserved for future
  59. _WS_PATH = "/api/realtime"
  60. _WS_AUTH_TIMEOUT = 20.0
  61. _WS_MAX_MESSAGE_BYTES = 4_000_000
  62. _WS_PING_INTERVAL = 30.0 # seconds between client ping frames
  63. _WS_RECONNECT_INITIAL_BACKOFF = 1.0
  64. _WS_RECONNECT_MAX_BACKOFF = 30.0
  65. # ConnectRPC service paths (relative to base URL) — used for REST calls
  66. _RPC_BASE = "/api/connect"
  67. _PATH_LIST_ROOMS = f"{_RPC_BASE}/chatto.api.v1.RoomDirectoryService/ListRooms"
  68. _PATH_JOIN_ROOM = f"{_RPC_BASE}/chatto.api.v1.RoomService/JoinRoom"
  69. _PATH_GET_ROOM_EVENTS = f"{_RPC_BASE}/chatto.api.v1.RoomService/GetRoomEvents"
  70. _PATH_GET_THREAD_EVENTS = f"{_RPC_BASE}/chatto.api.v1.ThreadService/GetThreadEvents"
  71. _PATH_CREATE_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/CreateMessage"
  72. _PATH_GET_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/GetMessage"
  73. _PATH_GET_VIEWER = f"{_RPC_BASE}/chatto.api.v1.ViewerService/GetViewer"
  74. _PATH_UPDATE_TYPING = f"{_RPC_BASE}/chatto.api.v1.RoomService/UpdateTypingIndicator"
  75. _PATH_START_DM = f"{_RPC_BASE}/chatto.api.v1.RoomService/StartDM"
  76. _PATH_LIST_MEMBERS = f"{_RPC_BASE}/chatto.api.v1.RoomService/ListMembers"
  77. # Message lifecycle: reactions, editing, deletion
  78. _PATH_ADD_REACTION = f"{_RPC_BASE}/chatto.api.v1.MessageService/AddReaction"
  79. _PATH_REMOVE_REACTION = f"{_RPC_BASE}/chatto.api.v1.MessageService/RemoveReaction"
  80. _PATH_UPDATE_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/UpdateMessage"
  81. _PATH_DELETE_MESSAGE = f"{_RPC_BASE}/chatto.api.v1.MessageService/DeleteMessage"
  82. # Chunked asset upload
  83. _PATH_CREATE_UPLOAD = f"{_RPC_BASE}/chatto.api.v1.AssetUploadService/CreateUpload"
  84. _PATH_UPLOAD_CHUNK = f"{_RPC_BASE}/chatto.api.v1.AssetUploadService/UploadChunk"
  85. _PATH_COMPLETE_UPLOAD = f"{_RPC_BASE}/chatto.api.v1.AssetUploadService/CompleteUpload"
  86. # Read state management
  87. _PATH_MARK_ROOM_READ = f"{_RPC_BASE}/chatto.api.v1.RoomService/MarkRoomAsRead"
  88. _PATH_MARK_THREAD_READ = f"{_RPC_BASE}/chatto.api.v1.ThreadService/MarkThreadAsRead"
  89. # Thread following
  90. _PATH_FOLLOW_THREAD = f"{_RPC_BASE}/chatto.api.v1.ThreadService/FollowThread"
  91. # Room creation
  92. _PATH_CREATE_ROOM = f"{_RPC_BASE}/chatto.api.v1.RoomService/CreateRoom"
  93. # Notification dismissal
  94. _PATH_DISMISS_ALL_NOTIFICATIONS = f"{_RPC_BASE}/chatto.api.v1.NotificationService/DismissAllNotifications"
  95. _PATH_DISMISS_NOTIFICATION = f"{_RPC_BASE}/chatto.api.v1.NotificationService/DismissNotification"
  96. # Member directory — user lookup and mention resolution
  97. _PATH_LIST_USERS = f"{_RPC_BASE}/chatto.api.v1.UserService/ListUsers"
  98. _PATH_GET_USER = f"{_RPC_BASE}/chatto.api.v1.UserService/GetUser"
  99. _PATH_BATCH_GET_USERS = f"{_RPC_BASE}/chatto.api.v1.UserService/BatchGetUsers"
  100. # Presence broadcasting — online/away/DND status
  101. _PATH_UPDATE_PRESENCE = f"{_RPC_BASE}/chatto.api.v1.MyAccountService/UpdatePresence"
  102. # Custom status messages
  103. _PATH_UPDATE_CUSTOM_STATUS = f"{_RPC_BASE}/chatto.api.v1.MyAccountService/UpdateCustomStatus"
  104. _PATH_DELETE_CUSTOM_STATUS = f"{_RPC_BASE}/chatto.api.v1.MyAccountService/DeleteCustomStatus"
  105. # Presence status int mapping (Chatto API)
  106. _PRESENCE_STATUS_MAP: Dict[str, int] = {
  107. "online": 1,
  108. "away": 2,
  109. "dnd": 3,
  110. "do_not_disturb": 3,
  111. }
  112. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  113. _EMOJI_TO_SHORTCODE: Dict[str, str] = {
  114. "👍": "thumbsup",
  115. "👎": "thumbsdown",
  116. "❤️": "heart",
  117. "❤": "heart",
  118. "✅": "white_check_mark",
  119. "❌": "x",
  120. "👀": "eyes",
  121. "🎉": "tada",
  122. "😂": "joy",
  123. "🚀": "rocket",
  124. "🔥": "fire",
  125. "💯": "100",
  126. "🤔": "thinking",
  127. "👏": "clap",
  128. "🙏": "pray",
  129. "😅": "sweat_smile",
  130. "😴": "sleeping",
  131. "⏳": "hourglass",
  132. }
  133. # Chunk size for asset uploads (256 KB)
  134. _UPLOAD_CHUNK_SIZE = 256 * 1024
  135. # --------------------------------------------------------------------------- #
  136. # Minimal Protobuf Encoder/Decoder (stdlib only)
  137. # --------------------------------------------------------------------------- #
  138. #
  139. # Implements just enough of the protobuf binary format to encode/decode
  140. # the Chatto realtime protocol frames. No external protobuf library needed.
  141. #
  142. # Wire types:
  143. # 0 = varint
  144. # 2 = length-delimited (bytes/string/submessage)
  145. #
  146. # Field tag = (field_number << 3) | wire_type
  147. def _encode_varint(value: int) -> bytes:
  148. """Encode an unsigned integer as a protobuf varint."""
  149. if value < 0:
  150. # Treat as unsigned 64-bit
  151. value &= (1 << 64) - 1
  152. out = bytearray()
  153. while value > 0x7F:
  154. out.append((value & 0x7F) | 0x80)
  155. value >>= 7
  156. out.append(value & 0x7F)
  157. return bytes(out)
  158. def _decode_varint(data: bytes, offset: int) -> Tuple[int, int]:
  159. """Decode a varint from data at offset. Returns (value, new_offset)."""
  160. result = 0
  161. shift = 0
  162. while True:
  163. if offset >= len(data):
  164. raise ValueError("Truncated varint")
  165. byte = data[offset]
  166. offset += 1
  167. result |= (byte & 0x7F) << shift
  168. if not (byte & 0x80):
  169. break
  170. shift += 7
  171. if shift >= 64:
  172. raise ValueError("Varint too long")
  173. return result, offset
  174. def _encode_tag(field_number: int, wire_type: int) -> bytes:
  175. """Encode a protobuf field tag."""
  176. return _encode_varint((field_number << 3) | wire_type)
  177. def _encode_field_varint(field_number: int, value: int) -> bytes:
  178. """Encode a varint field."""
  179. return _encode_tag(field_number, 0) + _encode_varint(value)
  180. def _encode_field_bytes(field_number: int, value: bytes) -> bytes:
  181. """Encode a length-delimited field (bytes/string/submessage)."""
  182. return _encode_tag(field_number, 2) + _encode_varint(len(value)) + value
  183. def _encode_field_string(field_number: int, value: str) -> bytes:
  184. """Encode a string field."""
  185. return _encode_field_bytes(field_number, value.encode("utf-8"))
  186. def _encode_submessage(field_number: int, submessage: bytes) -> bytes:
  187. """Encode a submessage field (length-delimited)."""
  188. return _encode_field_bytes(field_number, submessage)
  189. def _decode_fields(data: bytes) -> Dict[int, List[Any]]:
  190. """Decode all fields from a protobuf message.
  191. Returns a dict mapping field_number -> list of values.
  192. For varint fields, value is int.
  193. For length-delimited fields, value is bytes (raw).
  194. """
  195. fields: Dict[int, List[Any]] = {}
  196. offset = 0
  197. while offset < len(data):
  198. tag, offset = _decode_varint(data, offset)
  199. field_number = tag >> 3
  200. wire_type = tag & 0x07
  201. if wire_type == 0: # varint
  202. value, offset = _decode_varint(data, offset)
  203. fields.setdefault(field_number, []).append(value)
  204. elif wire_type == 2: # length-delimited
  205. length, offset = _decode_varint(data, offset)
  206. if offset + length > len(data):
  207. raise ValueError("Truncated length-delimited field")
  208. value = data[offset:offset + length]
  209. offset += length
  210. fields.setdefault(field_number, []).append(value)
  211. elif wire_type == 1: # 64-bit
  212. if offset + 8 > len(data):
  213. raise ValueError("Truncated 64-bit field")
  214. value = data[offset:offset + 8]
  215. offset += 8
  216. fields.setdefault(field_number, []).append(value)
  217. elif wire_type == 5: # 32-bit
  218. if offset + 4 > len(data):
  219. raise ValueError("Truncated 32-bit field")
  220. value = data[offset:offset + 4]
  221. offset += 4
  222. fields.setdefault(field_number, []).append(value)
  223. else:
  224. raise ValueError(f"Unknown wire type {wire_type} for field {field_number}")
  225. return fields
  226. def _get_first(fields: Dict[int, List[Any]], field_number: int, default: Any = None) -> Any:
  227. """Get the first value for a field number, or default."""
  228. values = fields.get(field_number)
  229. if values:
  230. return values[0]
  231. return default
  232. def _get_all(fields: Dict[int, List[Any]], field_number: int) -> List[Any]:
  233. """Get all values for a field number."""
  234. return fields.get(field_number, [])
  235. # --------------------------------------------------------------------------- #
  236. # Protobuf message helpers for Chatto realtime protocol
  237. # --------------------------------------------------------------------------- #
  238. def _encode_client_hello(bearer_token: str) -> bytes:
  239. """Encode RealtimeClientHello {
  240. uint32 protocol_version = 1; // v0.4.19 uses v1
  241. optional string bearer_token = 2;
  242. }"""
  243. msg = _encode_field_varint(1, _REALTIME_PROTOCOL_VERSION)
  244. if bearer_token:
  245. msg += _encode_field_string(2, bearer_token)
  246. return msg
  247. def _encode_subscribe_events(
  248. resume_cursor: Optional[str] = None,
  249. retained_room_ids: Optional[List[str]] = None,
  250. ) -> bytes:
  251. """Encode RealtimeSubscribeEvents {
  252. optional string resume_cursor = 1;
  253. repeated string retained_room_ids = 2;
  254. }"""
  255. msg = b""
  256. if resume_cursor:
  257. msg += _encode_field_string(1, resume_cursor)
  258. if retained_room_ids:
  259. for rid in retained_room_ids:
  260. msg += _encode_field_string(2, rid)
  261. return msg
  262. def _encode_ping() -> bytes:
  263. """Encode RealtimePing (empty message)."""
  264. return b""
  265. def _encode_client_frame_hello(hello_bytes: bytes) -> bytes:
  266. """Encode RealtimeClientFrame { oneof frame { RealtimeClientHello hello = 1; } }"""
  267. return _encode_submessage(1, hello_bytes)
  268. def _encode_client_frame_subscribe(subscribe_bytes: bytes) -> bytes:
  269. """Encode RealtimeClientFrame { oneof frame { RealtimeSubscribeEvents subscribe_events = 2; } }"""
  270. return _encode_submessage(2, subscribe_bytes)
  271. def _encode_client_frame_ping(ping_bytes: bytes) -> bytes:
  272. """Encode RealtimeClientFrame { oneof frame { RealtimePing ping = 3; } }"""
  273. return _encode_submessage(3, ping_bytes)
  274. def _decode_server_frame(data: bytes) -> Dict[str, Any]:
  275. """Decode RealtimeServerFrame and identify which oneof variant is set.
  276. Returns a dict like:
  277. {"type": "hello", "data": <decoded RealtimeServerHello bytes>}
  278. {"type": "subscribed", "data": <decoded RealtimeSubscribed bytes>}
  279. {"type": "event", "data": <decoded RealtimeEventEnvelope bytes>}
  280. {"type": "heartbeat", "data": <raw bytes>}
  281. {"type": "error", "data": <decoded RealtimeError bytes>}
  282. {"type": "close", "data": <decoded RealtimeClose bytes>}
  283. {"type": "pong", "data": <raw bytes>}
  284. {"type": "caught_up", "data": <raw bytes>}
  285. {"type": "projection_event", "data": <decoded RealtimeProjectionEvent bytes>}
  286. {"type": "unknown", "data": None}
  287. """
  288. fields = _decode_fields(data)
  289. # oneof frame: only one of these fields is set
  290. # field 1 = hello, 2 = subscribed, 3 = event, 4 = heartbeat,
  291. # 5 = error, 6 = close, 7 = pong, 8 = caught_up, 9 = projection_event
  292. type_map = {
  293. 1: "hello",
  294. 2: "subscribed",
  295. 3: "event",
  296. 4: "heartbeat",
  297. 5: "error",
  298. 6: "close",
  299. 7: "pong",
  300. 8: "caught_up",
  301. 9: "projection_event",
  302. }
  303. for field_num, type_name in type_map.items():
  304. if field_num in fields:
  305. return {"type": type_name, "data": fields[field_num][0]}
  306. return {"type": "unknown", "data": None}
  307. def _decode_projection_event(data: bytes) -> Dict[str, Any]:
  308. """Decode RealtimeProjectionEvent {
  309. string id = 1;
  310. google.protobuf.Timestamp created_at = 2;
  311. optional string actor_id = 3;
  312. optional string resume_cursor = 4;
  313. repeated RealtimeProjectionOperation operations = 5;
  314. }
  315. Returns dict with keys: id, created_at (ISO str), actor_id, resume_cursor,
  316. operations (list of decoded operation dicts).
  317. """
  318. fields = _decode_fields(data)
  319. event_id = _get_first(fields, 1, b"")
  320. if isinstance(event_id, bytes):
  321. event_id = event_id.decode("utf-8", errors="replace")
  322. created_at = ""
  323. ts_bytes = _get_first(fields, 2)
  324. if isinstance(ts_bytes, bytes):
  325. created_at = _decode_timestamp(ts_bytes)
  326. actor_id_raw = _get_first(fields, 3)
  327. actor_id = actor_id_raw.decode("utf-8", errors="replace") if isinstance(actor_id_raw, bytes) else ""
  328. resume_cursor_raw = _get_first(fields, 4)
  329. resume_cursor = resume_cursor_raw.decode("utf-8", errors="replace") if isinstance(resume_cursor_raw, bytes) else ""
  330. operations = []
  331. for op_bytes in _get_all(fields, 5):
  332. if isinstance(op_bytes, bytes):
  333. operations.append(_decode_projection_operation(op_bytes))
  334. return {
  335. "id": event_id,
  336. "created_at": created_at,
  337. "actor_id": actor_id,
  338. "resume_cursor": resume_cursor,
  339. "operations": operations,
  340. }
  341. def _decode_projection_operation(data: bytes) -> Dict[str, Any]:
  342. """Decode RealtimeProjectionOperation (oneof).
  343. We only care about room_timeline_event_upsert (field 10).
  344. Returns dict like:
  345. {"type": "room_timeline_event_upsert", "room_id": ..., "event": {...}, "includes": {...}}
  346. {"type": "unknown", "field": N}
  347. """
  348. fields = _decode_fields(data)
  349. # field 10 = room_timeline_event_upsert
  350. if 10 in fields:
  351. upsert_bytes = fields[10][0]
  352. if isinstance(upsert_bytes, bytes):
  353. return _decode_room_timeline_event_upsert(upsert_bytes)
  354. # Find which field is set for debugging
  355. for field_num in fields:
  356. if field_num != 10:
  357. type_names = {
  358. 1: "room_upsert",
  359. 2: "room_delete",
  360. 3: "room_member_upsert",
  361. 4: "room_member_delete",
  362. 5: "room_typing_upsert",
  363. 6: "room_typing_delete",
  364. 7: "room_read_state_upsert",
  365. 8: "room_read_state_delete",
  366. 9: "room_subscription_upsert",
  367. 10: "room_timeline_event_upsert",
  368. 11: "room_timeline_event_delete",
  369. }
  370. return {"type": type_names.get(field_num, f"field_{field_num}"), "field": field_num}
  371. return {"type": "empty"}
  372. def _decode_room_timeline_event_upsert(data: bytes) -> Dict[str, Any]:
  373. """Decode RealtimeProjectionRoomTimelineEventUpsert {
  374. string room_id = 1;
  375. chatto.api.v1.RoomTimelineEvent event = 2;
  376. chatto.api.v1.RoomTimelineIncludes includes = 3;
  377. }"""
  378. fields = _decode_fields(data)
  379. room_id_raw = _get_first(fields, 1)
  380. room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
  381. event_dict = {}
  382. event_bytes = _get_first(fields, 2)
  383. if isinstance(event_bytes, bytes):
  384. event_dict = _decode_room_timeline_event(event_bytes)
  385. return {
  386. "type": "room_timeline_event_upsert",
  387. "room_id": room_id,
  388. "event": event_dict,
  389. }
  390. def _decode_room_timeline_event(data: bytes) -> Dict[str, Any]:
  391. """Decode RoomTimelineEvent from the API proto.
  392. RoomTimelineEvent {
  393. string id = 1;
  394. google.protobuf.Timestamp created_at = 2;
  395. string room_id = 3;
  396. chatto.api.v1.RoomTimelineEventKind kind = 4; // enum as varint
  397. chatto.api.v1.RoomTimelineEventMessagePosted message_posted = 5;
  398. // ... other event kinds (member_joined, etc.) at higher field numbers
  399. }
  400. RoomTimelineEventMessagePosted {
  401. chatto.api.v1.Message message = 1;
  402. }
  403. Message {
  404. string id = 1;
  405. string room_id = 2;
  406. string actor_id = 3;
  407. string body = 4;
  408. google.protobuf.Timestamp created_at = 5;
  409. optional string actor_login = 6;
  410. optional string actor_display_name = 7;
  411. optional chatto.api.v1.MessageThread thread = 8;
  412. // ... other fields
  413. }
  414. MessageThread {
  415. string thread_root_event_id = 1;
  416. }
  417. """
  418. fields = _decode_fields(data)
  419. event_id_raw = _get_first(fields, 1)
  420. event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
  421. created_at = ""
  422. ts_bytes = _get_first(fields, 2)
  423. if isinstance(ts_bytes, bytes):
  424. created_at = _decode_timestamp(ts_bytes)
  425. room_id_raw = _get_first(fields, 3)
  426. room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
  427. kind = _get_first(fields, 4, 0) # enum as varint int
  428. # field 5 = message_posted (submessage)
  429. message = {}
  430. posted_bytes = _get_first(fields, 5)
  431. if isinstance(posted_bytes, bytes):
  432. message = _decode_message_posted(posted_bytes)
  433. return {
  434. "id": event_id,
  435. "createdAt": created_at,
  436. "roomId": room_id,
  437. "kind": kind,
  438. "messagePosted": message if message else None,
  439. }
  440. def _decode_message_posted(data: bytes) -> Dict[str, Any]:
  441. """Decode RoomTimelineEventMessagePosted {
  442. chatto.api.v1.Message message = 1;
  443. }"""
  444. fields = _decode_fields(data)
  445. msg_bytes = _get_first(fields, 1)
  446. if isinstance(msg_bytes, bytes):
  447. return {"message": _decode_message(msg_bytes)}
  448. return {"message": {}}
  449. def _decode_message(data: bytes) -> Dict[str, Any]:
  450. """Decode Message proto.
  451. Message {
  452. string id = 1;
  453. string room_id = 2;
  454. string actor_id = 3;
  455. string body = 4;
  456. google.protobuf.Timestamp created_at = 5;
  457. optional string actor_login = 6;
  458. optional string actor_display_name = 7;
  459. optional MessageThread thread = 8;
  460. }
  461. """
  462. fields = _decode_fields(data)
  463. def _str_field(fnum: int) -> str:
  464. val = _get_first(fields, fnum)
  465. if isinstance(val, bytes):
  466. return val.decode("utf-8", errors="replace")
  467. return ""
  468. msg_id = _str_field(1)
  469. room_id = _str_field(2)
  470. actor_id = _str_field(3)
  471. body = _str_field(4)
  472. created_at = ""
  473. ts_bytes = _get_first(fields, 5)
  474. if isinstance(ts_bytes, bytes):
  475. created_at = _decode_timestamp(ts_bytes)
  476. actor_login = _str_field(6)
  477. actor_display_name = _str_field(7)
  478. thread = {}
  479. thread_bytes = _get_first(fields, 8)
  480. if isinstance(thread_bytes, bytes):
  481. thread = _decode_thread(thread_bytes)
  482. return {
  483. "id": msg_id,
  484. "roomId": room_id,
  485. "actorId": actor_id,
  486. "body": body,
  487. "createdAt": created_at,
  488. "actorLogin": actor_login,
  489. "actorDisplayName": actor_display_name,
  490. "thread": thread,
  491. }
  492. def _decode_thread(data: bytes) -> Dict[str, Any]:
  493. """Decode MessageThread {
  494. string thread_root_event_id = 1;
  495. }"""
  496. fields = _decode_fields(data)
  497. thread_root = _get_first(fields, 1)
  498. if isinstance(thread_root, bytes):
  499. thread_root = thread_root.decode("utf-8", errors="replace")
  500. else:
  501. thread_root = ""
  502. return {"threadRootEventId": thread_root}
  503. def _decode_timestamp(data: bytes) -> str:
  504. """Decode google.protobuf.Timestamp {
  505. int64 seconds = 1;
  506. int32 nanos = 2;
  507. }
  508. Returns ISO 8601 string.
  509. """
  510. fields = _decode_fields(data)
  511. seconds = _get_first(fields, 1, 0)
  512. nanos = _get_first(fields, 2, 0)
  513. # Handle signed int64 (protobuf varints are unsigned, but int64 values
  514. # may be negative — reinterpret)
  515. if isinstance(seconds, int) and seconds >= (1 << 63):
  516. seconds -= (1 << 64)
  517. if not seconds:
  518. return ""
  519. # Convert to ISO format
  520. try:
  521. dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
  522. # Add nanosecond precision if present
  523. if nanos:
  524. # microsecond precision is the best Python supports
  525. micros = nanos // 1000
  526. dt = dt.replace(microsecond=micros % 1000000)
  527. return dt.isoformat().replace("+00:00", "Z")
  528. except (OSError, ValueError, OverflowError):
  529. return ""
  530. def _decode_event_envelope(data: bytes) -> Dict[str, Any]:
  531. """Decode RealtimeEventEnvelope for transient events.
  532. RealtimeEventEnvelope {
  533. string id = 1;
  534. google.protobuf.Timestamp created_at = 2;
  535. optional string actor_id = 3;
  536. oneof event {
  537. RealtimeMessagePostedEvent message_posted = 10;
  538. RealtimeMessageEditedEvent message_edited = 11;
  539. ...
  540. RealtimeMentionNotificationEvent mention_notification = 88;
  541. RealtimeNewDirectMessageNotificationEvent new_direct_message_notification = 89;
  542. }
  543. }
  544. """
  545. fields = _decode_fields(data)
  546. event_id_raw = _get_first(fields, 1)
  547. event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
  548. created_at = ""
  549. ts_bytes = _get_first(fields, 2)
  550. if isinstance(ts_bytes, bytes):
  551. created_at = _decode_timestamp(ts_bytes)
  552. actor_id = ""
  553. actor_raw = _get_first(fields, 3)
  554. if isinstance(actor_raw, bytes):
  555. actor_id = actor_raw.decode("utf-8", errors="replace")
  556. # Check for message_posted (field 10), mention_notification (field 88),
  557. # or new_direct_message_notification (field 89)
  558. event_type = "unknown"
  559. event_data = {}
  560. logger.info("Chatto WS: event envelope fields: %s", list(fields.keys()))
  561. if 10 in fields:
  562. event_type = "message_posted"
  563. raw = fields[10][0]
  564. if isinstance(raw, bytes):
  565. event_data = _decode_message_posted_event(raw)
  566. elif 11 in fields:
  567. event_type = "message_edited"
  568. raw = fields[11][0]
  569. if isinstance(raw, bytes):
  570. event_data = _decode_message_edited_event(raw)
  571. elif 12 in fields:
  572. event_type = "message_retracted"
  573. raw = fields[12][0]
  574. if isinstance(raw, bytes):
  575. event_data = _decode_message_retracted_event(raw)
  576. elif 46 in fields:
  577. event_type = "user_left_room"
  578. raw = fields[46][0]
  579. if isinstance(raw, bytes):
  580. event_data = _decode_room_event(raw)
  581. elif 90 in fields:
  582. event_type = "session_terminated"
  583. raw = fields[90][0]
  584. if isinstance(raw, bytes):
  585. event_data = _decode_session_terminated_event(raw)
  586. elif 40 in fields:
  587. event_type = "room_created"
  588. raw = fields[40][0]
  589. if isinstance(raw, bytes):
  590. event_data = _decode_room_event(raw)
  591. elif 45 in fields:
  592. event_type = "user_joined_room"
  593. raw = fields[45][0]
  594. if isinstance(raw, bytes):
  595. event_data = _decode_room_event(raw)
  596. elif 88 in fields:
  597. event_type = "mention_notification"
  598. raw = fields[88][0]
  599. if isinstance(raw, bytes):
  600. event_data = _decode_mention_notification(raw)
  601. elif 89 in fields:
  602. event_type = "new_direct_message_notification"
  603. raw = fields[89][0]
  604. if isinstance(raw, bytes):
  605. event_data = _decode_dm_notification(raw)
  606. return {
  607. "id": event_id,
  608. "createdAt": created_at,
  609. "actorId": actor_id,
  610. "type": event_type,
  611. "data": event_data,
  612. }
  613. def _decode_message_posted_event(data: bytes) -> Dict[str, Any]:
  614. """Decode a RealtimeMessagePostedEvent.
  615. RealtimeMessagePostedEvent {
  616. string room_id = 1;
  617. string message_event_id = 2;
  618. optional string thread_root_event_id = 3;
  619. }
  620. """
  621. fields = _decode_fields(data)
  622. room_id_raw = _get_first(fields, 1)
  623. room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
  624. event_id_raw = _get_first(fields, 2)
  625. message_event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
  626. thread_root_raw = _get_first(fields, 3)
  627. thread_root_event_id = thread_root_raw.decode("utf-8", errors="replace") if isinstance(thread_root_raw, bytes) else ""
  628. return {
  629. "roomId": room_id,
  630. "messageEventId": message_event_id,
  631. "threadRootEventId": thread_root_event_id,
  632. }
  633. def _decode_mention_notification(data: bytes) -> Dict[str, Any]:
  634. """Decode a mention notification.
  635. MentionNotification {
  636. string room_id = 1;
  637. string event_id = 2;
  638. // ... other fields
  639. }
  640. """
  641. fields = _decode_fields(data)
  642. room_id_raw = _get_first(fields, 1)
  643. room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
  644. event_id_raw = _get_first(fields, 2)
  645. event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
  646. return {"roomId": room_id, "eventId": event_id}
  647. def _decode_dm_notification(data: bytes) -> Dict[str, Any]:
  648. """Decode a new direct message notification.
  649. NewDirectMessageNotification {
  650. string room_id = 1;
  651. string event_id = 2;
  652. // ... other fields
  653. }
  654. """
  655. fields = _decode_fields(data)
  656. room_id_raw = _get_first(fields, 1)
  657. room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
  658. event_id_raw = _get_first(fields, 2)
  659. event_id = event_id_raw.decode("utf-8", errors="replace") if isinstance(event_id_raw, bytes) else ""
  660. return {"roomId": room_id, "eventId": event_id}
  661. def _decode_room_event(data: bytes) -> Dict[str, Any]:
  662. """Decode a RealtimeRoomEvent.
  663. RealtimeRoomEvent {
  664. string room_id = 1;
  665. }
  666. """
  667. fields = _decode_fields(data)
  668. room_id_raw = _get_first(fields, 1)
  669. room_id = room_id_raw.decode("utf-8", errors="replace") if isinstance(room_id_raw, bytes) else ""
  670. return {"roomId": room_id}
  671. def _decode_message_edited_event(data: bytes) -> Dict[str, Any]:
  672. """Decode a RealtimeMessageEditedEvent.
  673. RealtimeMessageEditedEvent {
  674. string room_id = 1;
  675. string message_event_id = 2;
  676. }
  677. """
  678. fields = _decode_fields(data)
  679. room_id = _get_first(fields, 1)
  680. room_id = room_id.decode("utf-8", errors="replace") if isinstance(room_id, bytes) else ""
  681. event_id = _get_first(fields, 2)
  682. message_event_id = event_id.decode("utf-8", errors="replace") if isinstance(event_id, bytes) else ""
  683. return {"roomId": room_id, "messageEventId": message_event_id}
  684. def _decode_message_retracted_event(data: bytes) -> Dict[str, Any]:
  685. """Decode a RealtimeMessageRetractedEvent.
  686. RealtimeMessageRetractedEvent {
  687. string room_id = 1;
  688. string message_event_id = 2;
  689. optional string reason = 3;
  690. }
  691. """
  692. fields = _decode_fields(data)
  693. room_id = _get_first(fields, 1)
  694. room_id = room_id.decode("utf-8", errors="replace") if isinstance(room_id, bytes) else ""
  695. event_id = _get_first(fields, 2)
  696. message_event_id = event_id.decode("utf-8", errors="replace") if isinstance(event_id, bytes) else ""
  697. reason_raw = _get_first(fields, 3)
  698. reason = reason_raw.decode("utf-8", errors="replace") if isinstance(reason_raw, bytes) else ""
  699. return {"roomId": room_id, "messageEventId": message_event_id, "reason": reason}
  700. def _decode_session_terminated_event(data: bytes) -> Dict[str, Any]:
  701. """Decode a RealtimeSessionTerminatedEvent.
  702. RealtimeSessionTerminatedEvent {
  703. string reason = 1;
  704. }
  705. """
  706. fields = _decode_fields(data)
  707. reason_raw = _get_first(fields, 1)
  708. reason = reason_raw.decode("utf-8", errors="replace") if isinstance(reason_raw, bytes) else ""
  709. return {"reason": reason}
  710. def _decode_server_hello(data: bytes) -> Dict[str, Any]:
  711. """Decode RealtimeServerHello.
  712. RealtimeServerHello {
  713. uint32 protocol_version = 1;
  714. // ... other fields
  715. }
  716. """
  717. fields = _decode_fields(data)
  718. protocol_version = _get_first(fields, 1, 0)
  719. return {"protocolVersion": protocol_version}
  720. def _decode_error(data: bytes) -> Dict[str, Any]:
  721. """Decode RealtimeError {
  722. string message = 1;
  723. uint32 code = 2;
  724. }"""
  725. fields = _decode_fields(data)
  726. msg_raw = _get_first(fields, 1)
  727. message = msg_raw.decode("utf-8", errors="replace") if isinstance(msg_raw, bytes) else ""
  728. code = _get_first(fields, 2, 0)
  729. return {"message": message, "code": code}
  730. # --------------------------------------------------------------------------- #
  731. # HTTP helpers
  732. # --------------------------------------------------------------------------- #
  733. def _ssl_context() -> ssl.SSLContext:
  734. """Create a default SSL context that verifies certificates."""
  735. return ssl.create_default_context()
  736. def _rpc_request(
  737. base_url: str,
  738. path: str,
  739. token: Optional[str],
  740. body: dict,
  741. ) -> Tuple[int, dict]:
  742. """Make a ConnectRPC JSON POST request. Returns (status_code, response_dict)."""
  743. url = base_url.rstrip("/") + path
  744. headers = {
  745. "Content-Type": "application/json",
  746. "Connect-Protocol-Version": _CONNECT_RPC_VERSION,
  747. }
  748. if token:
  749. headers["Authorization"] = f"Bearer {token}"
  750. data = json.dumps(body).encode("utf-8")
  751. req = urllib.request.Request(url, data=data, headers=headers, method="POST")
  752. ctx = _ssl_context()
  753. try:
  754. with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
  755. raw = resp.read().decode("utf-8")
  756. return resp.status, json.loads(raw) if raw else {}
  757. except urllib.error.HTTPError as e:
  758. raw = e.read().decode("utf-8", errors="replace")
  759. try:
  760. err_body = json.loads(raw)
  761. except (ValueError, json.JSONDecodeError):
  762. err_body = {"error": raw}
  763. return e.code, err_body
  764. except Exception as e:
  765. return 0, {"error": str(e)}
  766. def _auth_login(base_url: str, login: str, password: str) -> Optional[str]:
  767. """Login to Chatto and return the bearer token, or None on failure."""
  768. url = base_url.rstrip("/") + "/auth/login"
  769. headers = {"Content-Type": "application/json"}
  770. data = json.dumps({"login": login, "password": password}).encode("utf-8")
  771. req = urllib.request.Request(url, data=data, headers=headers, method="POST")
  772. ctx = _ssl_context()
  773. try:
  774. with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
  775. raw = resp.read().decode("utf-8")
  776. result = json.loads(raw)
  777. if result.get("success") and result.get("token"):
  778. return str(result["token"])
  779. logger.error("Chatto: login response did not include token: %s", raw)
  780. return None
  781. except Exception as e:
  782. logger.error("Chatto: login failed for %s — %s", base_url, e)
  783. return None
  784. # --------------------------------------------------------------------------- #
  785. # Adapter
  786. # --------------------------------------------------------------------------- #
  787. class ChattoAdapter(BasePlatformAdapter):
  788. """Chatto platform adapter — receives messages via WebSocket realtime,
  789. sends via ConnectRPC REST."""
  790. MAX_MESSAGE_LENGTH = 10000
  791. _SPLIT_THRESHOLD = 9900
  792. splits_long_messages = True
  793. def __init__(self, config, **kwargs):
  794. platform = Platform("chatto")
  795. super().__init__(config=config, platform=platform)
  796. extra = getattr(config, "extra", {}) or {}
  797. # --- Configuration (env > config.yaml extra) ---
  798. self._base_url = (
  799. os.getenv("CHATTO_URL", "").strip()
  800. or str(extra.get("url", "")).strip()
  801. )
  802. self._login = os.getenv("CHATTO_LOGIN", "").strip()
  803. self._password = os.getenv("CHATTO_PASSWORD", "").strip()
  804. raw_channels = os.getenv("CHATTO_CHANNELS", "").strip()
  805. if raw_channels:
  806. self._channel_ids = [c.strip() for c in raw_channels.split(",") if c.strip()]
  807. elif isinstance(extra.get("channels"), list):
  808. self._channel_ids = [str(c) for c in extra["channels"]]
  809. else:
  810. self._channel_ids = []
  811. self._home_channel = (
  812. os.getenv("CHATTO_HOME_CHANNEL", "").strip()
  813. or str(extra.get("home_channel", "")).strip()
  814. )
  815. self._require_mention = os.getenv("CHATTO_REQUIRE_MENTION", "").strip().lower()
  816. if self._require_mention:
  817. self._require_mention = self._require_mention in ("true", "1", "yes")
  818. else:
  819. self._require_mention = bool(extra.get("require_mention", True))
  820. # free_response_channels: room IDs where the bot responds without being tagged
  821. fr_env = os.getenv("CHATTO_FREE_RESPONSE_CHANNELS", "").strip()
  822. if fr_env:
  823. self._free_response_channels = set(c.strip() for c in fr_env.split(",") if c.strip())
  824. else:
  825. self._free_response_channels = set(
  826. str(c) for c in extra.get("free_response_channels", []) if str(c).strip()
  827. )
  828. # --- Runtime state ---
  829. self._token: Optional[str] = None
  830. self._user_id: str = ""
  831. self._user_login: str = ""
  832. self._user_display: str = ""
  833. self._room_names: Dict[str, str] = {}
  834. self._room_kinds: Dict[str, str] = {}
  835. self._our_thread_roots: set = set() # thread root event IDs we created
  836. self._our_message_ids: set = set() # message IDs we sent (for thread root detection)
  837. self._seen: Dict[str, OrderedDict] = {} # room_id -> OrderedDict(event_id -> None)
  838. self._resume_cursor: Optional[str] = None
  839. self._watch_room_ids: List[str] = []
  840. self._ws_task: Optional[asyncio.Task] = None
  841. self._ws_ready: Optional[asyncio.Event] = None
  842. self._ws_active = False
  843. self._ws_ref = None # reference to open websocket for dynamic resubscribe
  844. # Persistent typing indicator loops per room
  845. self._typing_tasks: Dict[str, asyncio.Task] = {}
  846. # Liveness probe (REST health check)
  847. self._liveness_interval_seconds = 60.0
  848. self._liveness_failure_threshold = 3
  849. self._liveness_task: Optional[asyncio.Task] = None
  850. # Member directory cache: user_id -> user info dict
  851. self._user_cache: Dict[str, dict] = {}
  852. # ------------------------------------------------------------------ #
  853. # Auth
  854. # ------------------------------------------------------------------ #
  855. async def _ensure_token(self) -> bool:
  856. """Login if we don't have a token, or re-login on 401."""
  857. if self._token:
  858. return True
  859. if not self._base_url or not self._login or not self._password:
  860. logger.error("Chatto: missing configuration (URL, login, or password)")
  861. self._set_fatal_error("config_missing", "CHATTO_URL/LOGIN/PASSWORD required", retryable=False)
  862. return False
  863. loop = asyncio.get_event_loop()
  864. token = await loop.run_in_executor(None, _auth_login, self._base_url, self._login, self._password)
  865. if not token:
  866. self._set_fatal_error("auth_failed", "Chatto login failed", retryable=True)
  867. return False
  868. self._token = token
  869. logger.info("Chatto: logged in as %s", self._login)
  870. return True
  871. async def _relogin(self) -> bool:
  872. """Force re-login (token expired)."""
  873. self._token = None
  874. return await self._ensure_token()
  875. async def _rpc(self, path: str, body: dict, *, retry: bool = True) -> Tuple[int, dict]:
  876. """Make an RPC call with automatic re-login on 401."""
  877. if not await self._ensure_token():
  878. return 0, {"error": "no token"}
  879. loop = asyncio.get_event_loop()
  880. status, resp = await loop.run_in_executor(None, _rpc_request, self._base_url, path, self._token, body)
  881. if status == 401 and retry:
  882. logger.debug("Chatto: got 401, re-logging in")
  883. if await self._relogin():
  884. status, resp = await loop.run_in_executor(None, _rpc_request, self._base_url, path, self._token, body)
  885. return status, resp
  886. # ------------------------------------------------------------------ #
  887. # Connection
  888. # ------------------------------------------------------------------ #
  889. async def connect(self, *, is_reconnect: bool = False) -> bool:
  890. """Login, discover rooms, start WebSocket realtime connection."""
  891. if not await self._ensure_token():
  892. return False
  893. # Get our own user info
  894. status, resp = await self._rpc(_PATH_GET_VIEWER, {})
  895. if status != 200:
  896. msg = resp.get("message") or resp.get("error") or f"HTTP {status}"
  897. logger.error("Chatto: GetViewer failed — %s", msg)
  898. self._set_fatal_error("connect_failed", msg, retryable=True)
  899. return False
  900. user = resp.get("user", {}).get("profile", {})
  901. self._user_id = str(user.get("id", ""))
  902. self._user_login = str(user.get("login", ""))
  903. self._user_display = str(user.get("displayName", ""))
  904. # Discover rooms
  905. status, resp = await self._rpc(_PATH_LIST_ROOMS, {})
  906. if status != 200:
  907. msg = resp.get("message") or resp.get("error") or f"HTTP {status}"
  908. logger.error("Chatto: ListRooms failed — %s", msg)
  909. self._set_fatal_error("connect_failed", msg, retryable=True)
  910. return False
  911. rooms = resp.get("rooms", [])
  912. all_room_ids = []
  913. for entry in rooms:
  914. room = entry.get("room", {})
  915. rid = str(room.get("id", ""))
  916. if not rid:
  917. continue
  918. name = str(room.get("name", rid))
  919. kind = str(room.get("kind", ""))
  920. self._room_names[rid] = name
  921. self._room_kinds[rid] = kind
  922. viewer = entry.get("viewerState", {})
  923. is_member = viewer.get("isMember", False)
  924. # If user-specified channels, only watch those; otherwise watch all joined rooms
  925. if self._channel_ids:
  926. if rid in self._channel_ids and not is_member:
  927. await self._join_room(rid)
  928. all_room_ids.append(rid)
  929. elif is_member:
  930. all_room_ids.append(rid)
  931. if self._channel_ids:
  932. watch = list(self._channel_ids)
  933. else:
  934. watch = all_room_ids
  935. if not watch:
  936. logger.error("Chatto: no rooms to watch (join a room or set CHATTO_CHANNELS)")
  937. self._set_fatal_error("config_missing", "no Chatto rooms to watch", retryable=False)
  938. return False
  939. # Ensure we're a member of each watched room
  940. for rid in watch:
  941. if self._room_kinds.get(rid) != "ROOM_KIND_DM":
  942. await self._join_room(rid)
  943. # Pick home channel
  944. if not self._home_channel:
  945. self._home_channel = watch[0]
  946. self._watch_room_ids = watch
  947. # Initialize seen for each room — seed from REST to avoid replaying history
  948. for rid in watch:
  949. self._seen[rid] = OrderedDict()
  950. await self._seed_room(rid)
  951. # Start WebSocket realtime connection
  952. if not await self._start_websocket():
  953. self._set_fatal_error(
  954. "ws_connect_failed",
  955. "Chatto WebSocket realtime connection failed",
  956. retryable=True,
  957. )
  958. return False
  959. self._mark_connected()
  960. self._start_liveness_probe()
  961. logger.info(
  962. "Chatto: connected to %s as %s, watching %d room(s) via WebSocket",
  963. self._base_url,
  964. self._user_display or self._user_login,
  965. len(watch),
  966. )
  967. # Broadcast online presence so the bot appears online in the member list
  968. try:
  969. await self.set_presence("online")
  970. except Exception:
  971. logger.debug("Chatto: set_presence(online) failed on connect", exc_info=True)
  972. return True
  973. async def disconnect(self) -> None:
  974. """Stop WebSocket, liveness probe, typing tasks, and clear state."""
  975. # Broadcast away presence before tearing down
  976. try:
  977. await self.set_presence("away")
  978. except Exception:
  979. logger.debug("Chatto: set_presence(away) failed on disconnect", exc_info=True)
  980. self._mark_disconnected()
  981. self._ws_active = False
  982. # Cancel liveness probe
  983. await self._cancel_liveness_task()
  984. # Cancel all typing tasks
  985. for chat_id in list(self._typing_tasks.keys()):
  986. await self.stop_typing(chat_id)
  987. if self._ws_task and not self._ws_task.done():
  988. self._ws_task.cancel()
  989. try:
  990. await self._ws_task
  991. except (asyncio.CancelledError, Exception):
  992. pass
  993. self._ws_task = None
  994. self._token = None
  995. # ------------------------------------------------------------------ #
  996. # Liveness probe
  997. # ------------------------------------------------------------------ #
  998. def _start_liveness_probe(self) -> None:
  999. """Start the periodic REST health probe."""
  1000. if (
  1001. self._liveness_interval_seconds <= 0
  1002. or self._liveness_failure_threshold <= 0
  1003. ):
  1004. return
  1005. if self._liveness_task and not self._liveness_task.done():
  1006. return
  1007. self._liveness_task = asyncio.create_task(self._liveness_loop())
  1008. async def _cancel_liveness_task(self) -> None:
  1009. """Cancel the liveness probe task."""
  1010. task = self._liveness_task
  1011. self._liveness_task = None
  1012. if task and not task.done():
  1013. task.cancel()
  1014. try:
  1015. await task
  1016. except (asyncio.CancelledError, Exception):
  1017. pass
  1018. async def _liveness_loop(self) -> None:
  1019. """Periodically check if the REST API is alive via ViewerService/GetViewer.
  1020. Also refreshes presence status on each successful probe so the bot
  1021. stays showing as online — Chatto's presence expires if not refreshed.
  1022. On ``threshold`` consecutive failures, set a fatal error with
  1023. ``retryable=True`` so the gateway runner rebuilds the adapter.
  1024. """
  1025. interval = self._liveness_interval_seconds
  1026. threshold = self._liveness_failure_threshold
  1027. failures = 0
  1028. while self._running:
  1029. try:
  1030. await asyncio.sleep(interval)
  1031. except asyncio.CancelledError:
  1032. return
  1033. if not self._running:
  1034. return
  1035. try:
  1036. status, resp = await self._rpc(_PATH_GET_VIEWER, {}, retry=False)
  1037. if status == 200:
  1038. failures = 0
  1039. # Refresh presence to keep showing as online
  1040. try:
  1041. await self.set_presence("online")
  1042. except Exception:
  1043. logger.debug("Chatto: presence refresh failed", exc_info=True)
  1044. continue
  1045. # Non-200 — count as failure
  1046. reason = f"HTTP {status}"
  1047. except asyncio.CancelledError:
  1048. return
  1049. except Exception as e:
  1050. reason = str(e)
  1051. failures += 1
  1052. logger.warning(
  1053. "Chatto: liveness probe failed (%s, %d/%d)",
  1054. reason,
  1055. failures,
  1056. threshold,
  1057. )
  1058. if failures < threshold:
  1059. continue
  1060. # Threshold exceeded — force reconnect
  1061. logger.error(
  1062. "Chatto: liveness probe failed %d times consecutively; forcing reconnect",
  1063. failures,
  1064. )
  1065. self._set_fatal_error(
  1066. "chatto_liveness_failed",
  1067. f"Chatto REST API liveness check failed: {reason}",
  1068. retryable=True,
  1069. )
  1070. # Cancel the WebSocket to trigger reconnect
  1071. if self._ws_task and not self._ws_task.done():
  1072. self._ws_task.cancel()
  1073. return
  1074. async def _join_room(self, room_id: str) -> None:
  1075. """Join a room if not already a member."""
  1076. status, resp = await self._rpc(_PATH_JOIN_ROOM, {"roomId": room_id})
  1077. if status == 200:
  1078. logger.debug("Chatto: joined room %s (%s)", room_id, self._room_names.get(room_id, room_id))
  1079. elif status == 403 or (resp.get("code") == "permission_denied"):
  1080. logger.debug("Chatto: already a member of %s or cannot join", room_id)
  1081. else:
  1082. logger.debug("Chatto: join room %s returned %d — %s", room_id, status, resp.get("message", ""))
  1083. async def _seed_room(self, room_id: str) -> None:
  1084. """Seed high-water mark from the newest events so a restart doesn't replay history."""
  1085. status, resp = await self._rpc(_PATH_GET_ROOM_EVENTS, {"roomId": room_id})
  1086. if status != 200:
  1087. logger.debug("Chatto: seed GetRoomEvents for %s returned %d", room_id, status)
  1088. return
  1089. events = resp.get("page", {}).get("events", [])
  1090. for ev in events:
  1091. ev_id = str(ev.get("id", ""))
  1092. if ev_id:
  1093. self._mark_seen(room_id, ev_id)
  1094. def _mark_seen(self, room_id: str, event_id: str) -> None:
  1095. seen = self._seen.setdefault(room_id, OrderedDict())
  1096. seen[event_id] = None
  1097. while len(seen) > _SEEN_CAP:
  1098. seen.popitem(last=False)
  1099. def _is_seen(self, room_id: str, event_id: str) -> bool:
  1100. return event_id in self._seen.get(room_id, {})
  1101. # ------------------------------------------------------------------ #
  1102. # WebSocket Realtime Transport
  1103. # ------------------------------------------------------------------ #
  1104. def _websocket_url(self) -> str:
  1105. """Build the WebSocket URL from the base HTTP URL."""
  1106. parsed = urlsplit(self._base_url.strip())
  1107. scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
  1108. if scheme not in ("ws", "wss") or not parsed.netloc:
  1109. raise ValueError(f"Chatto URL must use http(s) or ws(s), got {parsed.scheme}")
  1110. path = parsed.path.rstrip("/") + _WS_PATH
  1111. return urlunsplit((scheme, parsed.netloc, path, parsed.query, ""))
  1112. async def _start_websocket(self) -> bool:
  1113. """Start the WebSocket realtime loop. Returns True if handshake succeeds."""
  1114. try:
  1115. import websockets # noqa: F401 (availability probe)
  1116. self._websocket_url()
  1117. except Exception as e:
  1118. logger.error("Chatto: WebSocket transport unavailable (%s)", e)
  1119. return False
  1120. self._ws_ready = asyncio.Event()
  1121. self._ws_task = asyncio.create_task(self._websocket_loop())
  1122. try:
  1123. await asyncio.wait_for(self._ws_ready.wait(), timeout=_WS_AUTH_TIMEOUT + 10)
  1124. except (asyncio.TimeoutError, TimeoutError):
  1125. logger.warning("Chatto: WebSocket did not authenticate in time")
  1126. self._ws_active = False
  1127. if self._ws_task and not self._ws_task.done():
  1128. self._ws_task.cancel()
  1129. try:
  1130. await self._ws_task
  1131. except asyncio.CancelledError:
  1132. pass
  1133. self._ws_task = None
  1134. return False
  1135. return True
  1136. async def _websocket_loop(self) -> None:
  1137. """Persistent WebSocket connection with bounded reconnect backoff.
  1138. Protocol flow:
  1139. 1. Connect to /api/realtime
  1140. 2. Send RealtimeClientFrame with hello (protocol_version=1, bearer_token)
  1141. 3. Receive RealtimeServerFrame with hello (RealtimeServerHello)
  1142. 4. Send RealtimeClientFrame with subscribe_events (retained_room_ids)
  1143. 5. Receive RealtimeServerFrame with subscribed
  1144. 6. Receive projection_event frames with room_timeline_event_upsert operations
  1145. 7. Also handle transient event frames (mention/DM notifications)
  1146. 8. Send periodic ping frames for keepalive
  1147. """
  1148. import websockets
  1149. backoff = _WS_RECONNECT_INITIAL_BACKOFF
  1150. try:
  1151. while True:
  1152. try:
  1153. ws_url = self._websocket_url()
  1154. extra_headers = {}
  1155. # Some WebSocket servers accept auth via header
  1156. if self._token:
  1157. extra_headers["Authorization"] = f"Bearer {self._token}"
  1158. async with websockets.connect(
  1159. ws_url,
  1160. additional_headers=extra_headers if extra_headers else None,
  1161. open_timeout=_WS_AUTH_TIMEOUT,
  1162. close_timeout=5,
  1163. ping_interval=None, # we send our own protocol-level pings
  1164. ping_timeout=None,
  1165. max_size=_WS_MAX_MESSAGE_BYTES,
  1166. ) as websocket:
  1167. # Step 1: Send hello
  1168. hello_body = _encode_client_hello(self._token or "")
  1169. hello_frame = _encode_client_frame_hello(hello_body)
  1170. await websocket.send(hello_frame)
  1171. logger.debug("Chatto WS: sent hello (protocol_version=%d)", _REALTIME_PROTOCOL_VERSION)
  1172. # Step 2: Receive server hello
  1173. raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)
  1174. server_frame = _decode_server_frame(raw)
  1175. if server_frame["type"] != "hello":
  1176. if server_frame["type"] == "error":
  1177. err = _decode_error(server_frame["data"]) if server_frame["data"] else {}
  1178. raise ConnectionError(f"Server error during hello: {err.get('message', 'unknown')}")
  1179. raise ConnectionError(f"Expected server hello, got {server_frame['type']}")
  1180. server_hello = _decode_server_hello(server_frame["data"]) if server_frame["data"] else {}
  1181. proto_ver = server_hello.get("protocolVersion", 0)
  1182. logger.info("Chatto WS: server hello received (protocol_version=%s)", proto_ver)
  1183. # Step 3: Send subscribe_events
  1184. subscribe_body = _encode_subscribe_events(
  1185. resume_cursor=self._resume_cursor,
  1186. retained_room_ids=self._watch_room_ids,
  1187. )
  1188. subscribe_frame = _encode_client_frame_subscribe(subscribe_body)
  1189. await websocket.send(subscribe_frame)
  1190. logger.debug(
  1191. "Chatto WS: sent subscribe_events for %d room(s), cursor=%s",
  1192. len(self._watch_room_ids),
  1193. self._resume_cursor or "(none)",
  1194. )
  1195. # Step 4: Receive subscribed confirmation
  1196. raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)
  1197. server_frame = _decode_server_frame(raw)
  1198. if server_frame["type"] == "error":
  1199. err = _decode_error(server_frame["data"]) if server_frame["data"] else {}
  1200. raise ConnectionError(f"Server error during subscribe: {err.get('message', 'unknown')}")
  1201. if server_frame["type"] not in ("subscribed", "caught_up", "projection_event"):
  1202. # Be lenient — some servers may send events immediately
  1203. logger.debug("Chatto WS: received %s after subscribe (expected subscribed)", server_frame["type"])
  1204. # Process it as an event if it is one
  1205. if server_frame["type"] == "projection_event":
  1206. await self._handle_projection_event(server_frame["data"])
  1207. elif server_frame["type"] == "event":
  1208. await self._handle_transient_event(server_frame["data"])
  1209. self._ws_active = True
  1210. if self._ws_ready is not None and not self._ws_ready.is_set():
  1211. self._ws_ready.set()
  1212. backoff = _WS_RECONNECT_INITIAL_BACKOFF
  1213. logger.info("Chatto WS: subscribed, listening for events")
  1214. # Store websocket reference for dynamic resubscribe
  1215. self._ws_ref = websocket
  1216. # Step 5: Main event loop with ping keepalive
  1217. await self._websocket_event_loop(websocket)
  1218. except asyncio.CancelledError:
  1219. raise
  1220. except Exception as e:
  1221. self._ws_active = False
  1222. if self._ws_ready is not None and not self._ws_ready.is_set():
  1223. # Signal failure to connect() waiter
  1224. self._ws_ready.set()
  1225. logger.warning("Chatto WS: disconnected; retrying in %.1fs: %s", backoff, e)
  1226. await asyncio.sleep(backoff)
  1227. backoff = min(backoff * 2, _WS_RECONNECT_MAX_BACKOFF)
  1228. finally:
  1229. self._ws_active = False
  1230. self._ws_ref = None
  1231. async def _websocket_event_loop(self, websocket) -> None:
  1232. """Main event loop: receive frames and send periodic pings."""
  1233. logger.info("Chatto WS: event loop started, waiting for frames")
  1234. ping_task = asyncio.create_task(self._ping_loop(websocket))
  1235. try:
  1236. async for raw in websocket:
  1237. logger.info("Chatto WS: received frame (%d bytes)", len(raw) if raw else 0)
  1238. if isinstance(raw, str):
  1239. # Shouldn't happen with binary protobuf, but handle gracefully
  1240. logger.debug("Chatto WS: received text frame (unexpected)")
  1241. continue
  1242. try:
  1243. server_frame = _decode_server_frame(raw)
  1244. except (ValueError, IndexError) as e:
  1245. logger.warning("Chatto WS: failed to decode server frame: %s", e)
  1246. continue
  1247. frame_type = server_frame["type"]
  1248. logger.info("Chatto WS: frame type=%s size=%d", frame_type, len(raw))
  1249. frame_data = server_frame["data"]
  1250. if frame_type == "projection_event":
  1251. await self._handle_projection_event(frame_data)
  1252. elif frame_type == "event":
  1253. await self._handle_transient_event(frame_data)
  1254. elif frame_type == "heartbeat":
  1255. logger.debug("Chatto WS: heartbeat received")
  1256. elif frame_type == "pong":
  1257. logger.debug("Chatto WS: pong received")
  1258. elif frame_type == "caught_up":
  1259. logger.debug("Chatto WS: caught_up received")
  1260. elif frame_type == "error":
  1261. err = _decode_error(frame_data) if frame_data else {}
  1262. logger.warning("Chatto WS: server error: %s (code=%s)", err.get("message", "unknown"), err.get("code"))
  1263. elif frame_type == "close":
  1264. msg = ""
  1265. if frame_data:
  1266. try:
  1267. close_fields = _decode_fields(frame_data)
  1268. msg_raw = _get_first(close_fields, 1)
  1269. if isinstance(msg_raw, bytes):
  1270. msg = msg_raw.decode("utf-8", errors="replace")
  1271. except Exception:
  1272. pass
  1273. logger.info("Chatto WS: server sent close: %s", msg or "(no message)")
  1274. raise ConnectionError(f"Server closed: {msg}")
  1275. elif frame_type == "hello":
  1276. # Unexpected re-hello, ignore
  1277. logger.debug("Chatto WS: unexpected hello frame")
  1278. elif frame_type == "subscribed":
  1279. logger.debug("Chatto WS: re-subscribed confirmation")
  1280. else:
  1281. logger.debug("Chatto WS: unknown frame type %s", frame_type)
  1282. finally:
  1283. logger.info("Chatto WS: event loop ended")
  1284. ping_task.cancel()
  1285. try:
  1286. await ping_task
  1287. except asyncio.CancelledError:
  1288. pass
  1289. async def _ping_loop(self, websocket) -> None:
  1290. """Send periodic ping frames for keepalive."""
  1291. try:
  1292. while True:
  1293. await asyncio.sleep(_WS_PING_INTERVAL)
  1294. ping_body = _encode_ping()
  1295. ping_frame = _encode_client_frame_ping(ping_body)
  1296. await websocket.send(ping_frame)
  1297. logger.info("Chatto WS: ping sent")
  1298. except asyncio.CancelledError:
  1299. raise
  1300. except Exception as e:
  1301. logger.info("Chatto WS: ping loop exited: %s", e)
  1302. # WebSocket closed or error — exit silently, the main loop will handle reconnect
  1303. pass
  1304. # ------------------------------------------------------------------ #
  1305. # Event handling
  1306. # ------------------------------------------------------------------ #
  1307. async def _handle_projection_event(self, data: bytes) -> None:
  1308. """Handle a RealtimeProjectionEvent — parse operations for messages."""
  1309. if not data:
  1310. return
  1311. try:
  1312. event = _decode_projection_event(data)
  1313. except (ValueError, IndexError) as e:
  1314. logger.warning("Chatto WS: failed to decode projection event: %s", e)
  1315. return
  1316. # Update resume cursor if provided
  1317. cursor = event.get("resume_cursor")
  1318. if cursor:
  1319. self._resume_cursor = cursor
  1320. operations = event.get("operations", [])
  1321. for op in operations:
  1322. if op.get("type") == "room_timeline_event_upsert":
  1323. await self._handle_timeline_event_upsert(op)
  1324. # Other operation types (room_upsert, room_member_upsert, etc.) are
  1325. # not relevant to message delivery — ignore them.
  1326. async def _handle_timeline_event_upsert(self, op: dict) -> None:
  1327. """Handle a room_timeline_event_upsert operation."""
  1328. room_id = op.get("room_id", "")
  1329. event = op.get("event", {})
  1330. if not event:
  1331. return
  1332. ev_id = str(event.get("id", ""))
  1333. if not ev_id:
  1334. return
  1335. # De-dupe: skip events we've already seen
  1336. if self._is_seen(room_id, ev_id):
  1337. return
  1338. self._mark_seen(room_id, ev_id)
  1339. # Only handle messagePosted events
  1340. posted = event.get("messagePosted")
  1341. if not posted:
  1342. return
  1343. msg = posted.get("message", {})
  1344. if not msg:
  1345. return
  1346. await self._dispatch_message(msg, room_id)
  1347. async def _handle_transient_event(self, data: bytes) -> None:
  1348. """Handle a transient RealtimeEventEnvelope (message_posted, mentions, DMs).
  1349. These are signal-only events — they contain room_id and event_id but NOT
  1350. the message body. We fetch the actual message via REST as a fallback.
  1351. """
  1352. if not data:
  1353. return
  1354. try:
  1355. envelope = _decode_event_envelope(data)
  1356. except (ValueError, IndexError) as e:
  1357. logger.warning("Chatto WS: failed to decode transient event: %s", e)
  1358. return
  1359. event_type = envelope.get("type", "unknown")
  1360. event_data = envelope.get("data", {})
  1361. if event_type == "message_posted":
  1362. room_id = event_data.get("roomId", "")
  1363. event_id = event_data.get("messageEventId", "")
  1364. thread_root = event_data.get("threadRootEventId", "")
  1365. logger.info("Chatto WS: message_posted in room %s, event %s (thread=%s)", room_id, event_id, thread_root or "none")
  1366. if room_id and event_id and not self._is_seen(room_id, event_id):
  1367. await self._fetch_and_dispatch_event(room_id, event_id, thread_root)
  1368. elif event_type == "mention_notification":
  1369. room_id = event_data.get("roomId", "")
  1370. event_id = event_data.get("eventId", "")
  1371. logger.info("Chatto WS: mention notification in room %s for event %s", room_id, event_id)
  1372. if room_id and event_id and not self._is_seen(room_id, event_id):
  1373. await self._fetch_and_dispatch_event(room_id, event_id)
  1374. elif event_type == "new_direct_message_notification":
  1375. room_id = event_data.get("roomId", "")
  1376. event_id = event_data.get("eventId", "")
  1377. logger.info("Chatto WS: new DM notification in room %s for event %s", room_id, event_id)
  1378. if room_id and event_id and not self._is_seen(room_id, event_id):
  1379. await self._fetch_and_dispatch_event(room_id, event_id)
  1380. elif event_type == "user_joined_room":
  1381. room_id = event_data.get("roomId", "")
  1382. actor_id = envelope.get("actorId", "")
  1383. logger.info("Chatto WS: user_joined_room room=%s actor=%s", room_id, actor_id)
  1384. # If WE joined a room (or someone else joined and we should watch it),
  1385. # refresh room list and resubscribe
  1386. if room_id and room_id not in self._watch_room_ids:
  1387. await self._refresh_rooms()
  1388. elif event_type == "room_created":
  1389. room_id = event_data.get("roomId", "")
  1390. logger.info("Chatto WS: room_created room=%s", room_id)
  1391. # A new room was created — check if we should join/watch it
  1392. if room_id and room_id not in self._watch_room_ids:
  1393. await self._refresh_rooms()
  1394. elif event_type == "user_left_room":
  1395. room_id = event_data.get("roomId", "")
  1396. actor_id = envelope.get("actorId", "")
  1397. logger.info("Chatto WS: user_left_room room=%s actor=%s", room_id, actor_id)
  1398. # If WE left a room, stop watching it
  1399. if room_id and actor_id == self._user_id and room_id in self._watch_room_ids:
  1400. self._watch_room_ids.remove(room_id)
  1401. logger.info("Chatto WS: stopped watching room %s (we left)", room_id)
  1402. elif event_type == "message_edited":
  1403. room_id = event_data.get("roomId", "")
  1404. event_id = event_data.get("messageEventId", "")
  1405. logger.info("Chatto WS: message_edited in room %s, event %s", room_id, event_id)
  1406. # Log edit — could re-fetch for context if needed in the future
  1407. elif event_type == "message_retracted":
  1408. room_id = event_data.get("roomId", "")
  1409. event_id = event_data.get("messageEventId", "")
  1410. reason = event_data.get("reason", "")
  1411. logger.info("Chatto WS: message_retracted in room %s, event %s (reason=%s)", room_id, event_id, reason or "none")
  1412. # Mark the message as seen so we don't try to dispatch it later
  1413. if room_id and event_id:
  1414. self._mark_seen(room_id, event_id)
  1415. elif event_type == "session_terminated":
  1416. reason = event_data.get("reason", "")
  1417. logger.warning("Chatto WS: session terminated by server (reason=%s) — forcing reconnect", reason or "none")
  1418. # Close the websocket to trigger reconnect with backoff
  1419. if self._ws_ref:
  1420. try:
  1421. await self._ws_ref.close()
  1422. except Exception:
  1423. pass
  1424. else:
  1425. logger.debug("Chatto WS: unknown transient event type: %s", event_type)
  1426. async def _fetch_and_dispatch_event(self, room_id: str, event_id: str, thread_root_event_id: str = "") -> None:
  1427. """Fetch a single event by ID via REST and dispatch it.
  1428. Used as a fallback when the projection_event for a transient
  1429. notification (mention/DM) hasn't arrived yet.
  1430. When thread_root_event_id is set, fetches from the thread timeline
  1431. instead of the room timeline.
  1432. """
  1433. self._mark_seen(room_id, event_id)
  1434. if thread_root_event_id:
  1435. # Thread reply — use GetThreadEvents
  1436. status, resp = await self._rpc(_PATH_GET_THREAD_EVENTS, {
  1437. "roomId": room_id,
  1438. "threadRootEventId": thread_root_event_id,
  1439. })
  1440. else:
  1441. # Regular room message — use GetRoomEvents
  1442. status, resp = await self._rpc(_PATH_GET_ROOM_EVENTS, {"roomId": room_id})
  1443. if status != 200:
  1444. logger.warning("Chatto WS: REST fallback fetch failed for event %s (status=%d)", event_id, status)
  1445. return
  1446. events = resp.get("page", {}).get("events", [])
  1447. for ev in events:
  1448. ev_id = str(ev.get("id", ""))
  1449. if ev_id == event_id:
  1450. posted = ev.get("messagePosted")
  1451. if posted:
  1452. msg = posted.get("message", {})
  1453. if msg:
  1454. # Ensure thread info is set on the message so
  1455. # _dispatch_message can extract the thread root ID.
  1456. if thread_root_event_id and not msg.get("thread"):
  1457. msg["thread"] = {"threadRootEventId": thread_root_event_id}
  1458. logger.info("Chatto WS: dispatching event %s via REST fallback (thread=%s)", event_id, thread_root_event_id or "none")
  1459. await self._dispatch_message(msg, room_id)
  1460. return
  1461. logger.warning("Chatto WS: event %s not found in room %s events (thread=%s)", event_id, room_id, thread_root_event_id or "none")
  1462. async def _refresh_rooms(self) -> None:
  1463. """Re-list rooms and subscribe to any new ones dynamically.
  1464. Called when a room_created or user_joined_room event arrives.
  1465. This avoids requiring a gateway restart to pick up new rooms.
  1466. """
  1467. try:
  1468. status, resp = await self._rpc(_PATH_LIST_ROOMS, {})
  1469. if status != 200:
  1470. logger.warning("Chatto WS: _refresh_rooms ListRooms failed (status=%d)", status)
  1471. return
  1472. rooms = resp.get("rooms", [])
  1473. new_room_ids = []
  1474. for entry in rooms:
  1475. room = entry.get("room", {})
  1476. rid = str(room.get("id", ""))
  1477. if not rid:
  1478. continue
  1479. name = str(room.get("name", rid))
  1480. kind = str(room.get("kind", ""))
  1481. self._room_names[rid] = name
  1482. self._room_kinds[rid] = kind
  1483. viewer = entry.get("viewerState", {})
  1484. is_member = viewer.get("isMember", False)
  1485. # If we're a member and not already watching, add it
  1486. if is_member and rid not in self._watch_room_ids:
  1487. new_room_ids.append(rid)
  1488. if not new_room_ids:
  1489. return
  1490. logger.info("Chatto WS: discovered %d new room(s): %s", len(new_room_ids), new_room_ids)
  1491. # Join and seed each new room
  1492. for rid in new_room_ids:
  1493. if self._room_kinds.get(rid) != "ROOM_KIND_DM":
  1494. await self._join_room(rid)
  1495. self._seen[rid] = OrderedDict()
  1496. await self._seed_room(rid)
  1497. self._watch_room_ids.append(rid)
  1498. # Resubscribe to all rooms (including new ones) via the open websocket
  1499. if self._ws_ref and self._ws_active:
  1500. subscribe_body = _encode_subscribe_events(
  1501. resume_cursor=self._resume_cursor,
  1502. retained_room_ids=self._watch_room_ids,
  1503. )
  1504. subscribe_frame = _encode_client_frame_subscribe(subscribe_body)
  1505. await self._ws_ref.send(subscribe_frame)
  1506. logger.info("Chatto WS: resubscribed with %d room(s)", len(self._watch_room_ids))
  1507. else:
  1508. logger.warning("Chatto WS: cannot resubscribe — websocket not active")
  1509. except Exception:
  1510. logger.warning("Chatto WS: _refresh_rooms failed", exc_info=True)
  1511. async def _dispatch_message(self, msg: dict, room_id: str) -> None:
  1512. """Build a MessageEvent and hand it to the base class handler.
  1513. This method is identical to the polling version — it receives a
  1514. message dict (decoded from protobuf) and dispatches it through the
  1515. standard Hermes message pipeline.
  1516. """
  1517. if not self._message_handler:
  1518. return
  1519. actor_id = str(msg.get("actorId", ""))
  1520. # Skip our own messages
  1521. if actor_id == self._user_id:
  1522. return
  1523. # Best-effort: cache the sender's display name for richer message context
  1524. if actor_id and actor_id not in self._user_cache:
  1525. try:
  1526. await self.get_user(actor_id)
  1527. except Exception:
  1528. logger.debug("Chatto: get_user(%s) failed during dispatch", actor_id, exc_info=True)
  1529. body = str(msg.get("body", ""))
  1530. if not body:
  1531. return
  1532. msg_id = str(msg.get("id", ""))
  1533. chat_type = "dm" if self._room_kinds.get(room_id) == "ROOM_KIND_DM" else "group"
  1534. # Mention detection
  1535. is_dm = chat_type == "dm"
  1536. mentioned = False
  1537. if self._user_login:
  1538. mentioned = f"@{self._user_login}" in body
  1539. if self._user_display:
  1540. mentioned = mentioned or f"@{self._user_display}" in body
  1541. if self._require_mention and not is_dm and not mentioned:
  1542. # Allow free-response rooms (like Discord's free_response_channels)
  1543. if room_id not in self._free_response_channels:
  1544. return
  1545. # For DMs, always respond. For rooms with require_mention, only respond when mentioned.
  1546. # Strip the mention from the text for the agent
  1547. text = body
  1548. if mentioned and not is_dm:
  1549. # Remove mention prefix if present
  1550. if self._user_login and text.startswith(f"@{self._user_login}"):
  1551. text = text[len(f"@{self._user_login}"):].lstrip()
  1552. elif self._user_display and text.startswith(f"@{self._user_display}"):
  1553. text = text[len(f"@{self._user_display}"):].lstrip()
  1554. # Resolve user display name from actorLogin or actorDisplayName
  1555. user_name = str(msg.get("actorLogin", "")) or str(msg.get("actorDisplayName", actor_id))
  1556. thread_id = None
  1557. thread_info = msg.get("thread", {})
  1558. if thread_info and str(thread_info.get("threadRootEventId", "")) != msg_id:
  1559. thread_id = str(thread_info.get("threadRootEventId", ""))
  1560. # Hermes SDK: Propagate thread context if thread_id is set
  1561. try:
  1562. # Hermes injects the SDK into the plugin context as self.sdk
  1563. propagate_context_to_thread = self.sdk.thread_context.propagate_context_to_thread
  1564. propagate_context_to_thread(thread_id)
  1565. except AttributeError:
  1566. logger.debug("Hermes SDK thread_context not available in plugin context")
  1567. except Exception as e:
  1568. logger.warning("Failed to propagate thread context: %s", e, exc_info=True)
  1569. source = self.build_source(
  1570. chat_id=room_id,
  1571. chat_name=self._room_names.get(room_id, room_id),
  1572. chat_type=chat_type,
  1573. user_id=actor_id,
  1574. user_name=user_name,
  1575. thread_id=thread_id,
  1576. )
  1577. created_at_str = str(msg.get("createdAt", ""))
  1578. try:
  1579. timestamp = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) if created_at_str else datetime.now()
  1580. except (ValueError, TypeError):
  1581. timestamp = datetime.now()
  1582. event = MessageEvent(
  1583. text=text,
  1584. message_type=MessageType.TEXT,
  1585. source=source,
  1586. message_id=msg_id,
  1587. timestamp=timestamp,
  1588. raw_message=msg,
  1589. )
  1590. await self.handle_message(event)
  1591. # ------------------------------------------------------------------ #
  1592. # Read state & notification dismissal (best-effort, Chatto-unique)
  1593. # ------------------------------------------------------------------ #
  1594. try:
  1595. await self.mark_room_as_read(room_id)
  1596. except Exception:
  1597. logger.debug("Chatto: mark_room_as_read failed for %s", room_id, exc_info=True)
  1598. try:
  1599. await self.dismiss_all_notifications()
  1600. except Exception:
  1601. logger.debug("Chatto: dismiss_all_notifications failed", exc_info=True)
  1602. # ------------------------------------------------------------------ #
  1603. # Sending (REST — unchanged from polling version)
  1604. # ------------------------------------------------------------------ #
  1605. async def send(
  1606. self,
  1607. chat_id: str,
  1608. content: str,
  1609. reply_to: Optional[str] = None,
  1610. metadata: Optional[Dict[str, Any]] = None,
  1611. ) -> SendResult:
  1612. """Send a message to a Chatto room.
  1613. Long messages are split into chunks via ``truncate_message`` and
  1614. each chunk is sent as a separate CreateMessage call. The first
  1615. chunk's message ID is returned as ``message_id``.
  1616. When ``auto_thread`` is enabled and the incoming message was a
  1617. regular room message (not already in a thread), the first chunk is
  1618. sent as a room message and its ID becomes the thread root. Subsequent
  1619. chunks are sent in that thread. This mirrors Discord's auto_thread
  1620. behavior.
  1621. """
  1622. if not content:
  1623. return SendResult(success=False, error="Empty message")
  1624. formatted = self.format_message(content) if hasattr(self, "format_message") else content
  1625. chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
  1626. # Thread support — resolve thread_id once
  1627. # DM rooms don't support threads, so skip threading for DMs
  1628. thread_id = (metadata or {}).get("thread_id")
  1629. # Only use reply_to as thread_id if auto_thread is enabled.
  1630. # When auto_thread=false, responses go directly in the room
  1631. # without threading under the incoming message.
  1632. auto_thread_env = os.getenv("CHATTO_AUTO_THREAD", "").strip().lower()
  1633. auto_thread_setting = auto_thread_env in ("true", "1", "yes") if auto_thread_env else True
  1634. if reply_to and auto_thread_setting:
  1635. # reply_to might be the incoming message ID. If we already have
  1636. # thread_id from metadata, keep it (it's the thread root).
  1637. # Only use reply_to as thread_id if we don't already have one.
  1638. if not thread_id:
  1639. thread_id = reply_to
  1640. # Check if this is a DM room — DMs don't support threads
  1641. room_kind = self._room_kinds.get(str(chat_id), "")
  1642. is_dm = room_kind == "ROOM_KIND_DM" or room_kind == "dm"
  1643. if is_dm:
  1644. thread_id = None
  1645. # Auto-thread: by default, Chatto creates a thread for replies to room
  1646. # messages (not DMs, not already in a thread). This keeps conversations
  1647. # organized in the room. Can be disabled via extra.auto_thread=false.
  1648. auto_thread_enabled = os.getenv("CHATTO_AUTO_THREAD", "").strip().lower()
  1649. if auto_thread_enabled:
  1650. auto_thread_enabled = auto_thread_enabled in ("true", "1", "yes")
  1651. else:
  1652. auto_thread_enabled = True # default: enabled
  1653. use_auto_thread = auto_thread_enabled and not thread_id and not is_dm
  1654. message_ids: List[str] = []
  1655. last_resp: Optional[dict] = None
  1656. last_error: Optional[str] = None
  1657. retryable = False
  1658. for i, chunk in enumerate(chunks):
  1659. body: Dict[str, Any] = {"roomId": str(chat_id), "body": chunk}
  1660. # If we have a thread_id, send in the thread
  1661. if thread_id:
  1662. body["threadRootEventId"] = str(thread_id)
  1663. status, resp = await self._rpc(_PATH_CREATE_MESSAGE, body)
  1664. if status != 200:
  1665. err = resp.get("message") or resp.get("error") or f"HTTP {status}"
  1666. last_error = err
  1667. retryable = status >= 500 or status == 401
  1668. break
  1669. last_resp = resp
  1670. msg = resp.get("message", {})
  1671. msg_id = str(msg.get("id", "")) if msg else ""
  1672. if msg_id:
  1673. self._mark_seen(str(chat_id), msg_id)
  1674. message_ids.append(msg_id)
  1675. self._our_message_ids.add(msg_id)
  1676. # If we sent a message WITHOUT a thread_id, this message could
  1677. # become a thread root if someone replies to it
  1678. if not thread_id:
  1679. self._our_thread_roots.add(msg_id)
  1680. # Auto-thread: first chunk becomes the thread root,
  1681. # subsequent chunks go in the thread
  1682. if use_auto_thread and i == 0 and not thread_id:
  1683. thread_id = msg_id
  1684. if last_error and not message_ids:
  1685. return SendResult(success=False, error=last_error, retryable=retryable)
  1686. first_id = message_ids[0] if message_ids else ""
  1687. # ------------------------------------------------------------------ #
  1688. # Thread following (best-effort, Chatto-unique)
  1689. # ------------------------------------------------------------------ #
  1690. if thread_id and message_ids:
  1691. try:
  1692. await self._follow_thread(str(chat_id), str(thread_id))
  1693. except Exception:
  1694. logger.debug("Chatto: _follow_thread failed for room=%s thread=%s",
  1695. chat_id, thread_id, exc_info=True)
  1696. return SendResult(success=True, message_id=first_id, raw_response=last_resp)
  1697. async def send_typing(self, chat_id: str, metadata=None) -> None:
  1698. """Start a persistent typing indicator for a room.
  1699. Sends a typing ping every 10 seconds (Chatto's indicator likely
  1700. lasts ~8-10s). The background loop runs until ``stop_typing()``
  1701. is called or the task is cancelled.
  1702. """
  1703. if chat_id in self._typing_tasks:
  1704. return # already running
  1705. async def _typing_loop() -> None:
  1706. try:
  1707. while True:
  1708. try:
  1709. await self._rpc(
  1710. _PATH_UPDATE_TYPING,
  1711. {"roomId": str(chat_id), "typing": True},
  1712. retry=False,
  1713. )
  1714. except asyncio.CancelledError:
  1715. return
  1716. except Exception:
  1717. pass
  1718. await asyncio.sleep(10)
  1719. except asyncio.CancelledError:
  1720. pass
  1721. finally:
  1722. self._typing_tasks.pop(chat_id, None)
  1723. self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
  1724. async def stop_typing(self, chat_id: str) -> None:
  1725. """Stop the persistent typing indicator for a room."""
  1726. task = self._typing_tasks.pop(chat_id, None)
  1727. if task:
  1728. task.cancel()
  1729. try:
  1730. await task
  1731. except (asyncio.CancelledError, Exception):
  1732. pass
  1733. async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
  1734. """Get information about a chat/room."""
  1735. name = self._room_names.get(chat_id, chat_id)
  1736. kind = self._room_kinds.get(chat_id, "")
  1737. chat_type = "dm" if kind == "ROOM_KIND_DM" else "group"
  1738. return {
  1739. "name": name,
  1740. "type": chat_type,
  1741. }
  1742. # ------------------------------------------------------------------ #
  1743. # Reactions
  1744. # ------------------------------------------------------------------ #
  1745. @staticmethod
  1746. def _emoji_to_shortcode(emoji: str) -> str:
  1747. """Convert a unicode emoji to a Chatto shortcode name.
  1748. If the emoji is already a shortcode (no unicode mapping found),
  1749. return it as-is.
  1750. """
  1751. shortcode = _EMOJI_TO_SHORTCODE.get(emoji)
  1752. if shortcode:
  1753. return shortcode
  1754. # Already a shortcode like "thumbsup" — return as-is
  1755. return emoji
  1756. async def send_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
  1757. """Add a reaction to a message via MessageService/AddReaction."""
  1758. shortcode = self._emoji_to_shortcode(emoji)
  1759. body = {
  1760. "roomId": str(chat_id),
  1761. "messageEventId": str(message_id),
  1762. "emoji": shortcode,
  1763. }
  1764. try:
  1765. status, resp = await self._rpc(_PATH_ADD_REACTION, body, retry=False)
  1766. if status == 200:
  1767. return True
  1768. logger.debug(
  1769. "Chatto: AddReaction failed (%s): %s",
  1770. status,
  1771. resp.get("message") or resp.get("error") or "",
  1772. )
  1773. return False
  1774. except Exception as e:
  1775. logger.debug("Chatto: AddReaction error: %s", e)
  1776. return False
  1777. async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
  1778. """Remove a reaction from a message via MessageService/RemoveReaction."""
  1779. shortcode = self._emoji_to_shortcode(emoji)
  1780. body = {
  1781. "roomId": str(chat_id),
  1782. "messageEventId": str(message_id),
  1783. "emoji": shortcode,
  1784. }
  1785. try:
  1786. status, resp = await self._rpc(_PATH_REMOVE_REACTION, body, retry=False)
  1787. if status == 200:
  1788. return True
  1789. logger.debug(
  1790. "Chatto: RemoveReaction failed (%s): %s",
  1791. status,
  1792. resp.get("message") or resp.get("error") or "",
  1793. )
  1794. return False
  1795. except Exception as e:
  1796. logger.debug("Chatto: RemoveReaction error: %s", e)
  1797. return False
  1798. # ------------------------------------------------------------------ #
  1799. # Read state management (Chatto-unique)
  1800. # ------------------------------------------------------------------ #
  1801. async def mark_room_as_read(self, room_id: str) -> bool:
  1802. """Mark a room as read via RoomService/MarkRoomAsRead."""
  1803. try:
  1804. status, resp = await self._rpc(
  1805. _PATH_MARK_ROOM_READ, {"roomId": str(room_id)}, retry=False
  1806. )
  1807. if status == 200:
  1808. return True
  1809. logger.debug(
  1810. "Chatto: MarkRoomAsRead failed (%s): %s",
  1811. status,
  1812. resp.get("message") or resp.get("error") or "",
  1813. )
  1814. return False
  1815. except Exception as e:
  1816. logger.debug("Chatto: MarkRoomAsRead error: %s", e)
  1817. return False
  1818. async def mark_thread_as_read(self, room_id: str, thread_root_event_id: str) -> bool:
  1819. """Mark a thread as read via ThreadService/MarkThreadAsRead."""
  1820. try:
  1821. status, resp = await self._rpc(
  1822. _PATH_MARK_THREAD_READ,
  1823. {"roomId": str(room_id), "threadRootEventId": str(thread_root_event_id)},
  1824. retry=False,
  1825. )
  1826. if status == 200:
  1827. return True
  1828. logger.debug(
  1829. "Chatto: MarkThreadAsRead failed (%s): %s",
  1830. status,
  1831. resp.get("message") or resp.get("error") or "",
  1832. )
  1833. return False
  1834. except Exception as e:
  1835. logger.debug("Chatto: MarkThreadAsRead error: %s", e)
  1836. return False
  1837. # ------------------------------------------------------------------ #
  1838. # DM initiation (Chatto-unique)
  1839. # ------------------------------------------------------------------ #
  1840. async def start_dm(self, user_id: str) -> Optional[str]:
  1841. """Start a direct message with a user via RoomService/StartDM.
  1842. Returns the room ID on success, or None on failure.
  1843. """
  1844. body: Dict[str, Any] = {"participantIds": [str(user_id)] if user_id else []}
  1845. try:
  1846. status, resp = await self._rpc(_PATH_START_DM, body, retry=True)
  1847. if status != 200:
  1848. logger.debug(
  1849. "Chatto: StartDM failed (%s): %s",
  1850. status,
  1851. resp.get("message") or resp.get("error") or "",
  1852. )
  1853. return None
  1854. room = resp.get("room", {})
  1855. rid = str(room.get("id", "")) if room else ""
  1856. if rid:
  1857. self._room_names[rid] = self._room_names.get(rid, "")
  1858. self._room_kinds[rid] = "ROOM_KIND_DM"
  1859. return rid
  1860. logger.debug("Chatto: StartDM returned no room id: %s", resp)
  1861. return None
  1862. except Exception as e:
  1863. logger.debug("Chatto: StartDM error: %s", e)
  1864. return None
  1865. # ------------------------------------------------------------------ #
  1866. # Thread following (Chatto-unique)
  1867. # ------------------------------------------------------------------ #
  1868. async def _follow_thread(self, room_id: str, thread_root_event_id: str) -> None:
  1869. """Best-effort: follow a thread via ThreadService/FollowThread."""
  1870. try:
  1871. status, resp = await self._rpc(
  1872. _PATH_FOLLOW_THREAD,
  1873. {"roomId": str(room_id), "threadRootEventId": str(thread_root_event_id)},
  1874. retry=False,
  1875. )
  1876. if status != 200:
  1877. logger.debug(
  1878. "Chatto: FollowThread failed (%s): %s",
  1879. status,
  1880. resp.get("message") or resp.get("error") or "",
  1881. )
  1882. except Exception as e:
  1883. logger.debug("Chatto: FollowThread error: %s", e)
  1884. # ------------------------------------------------------------------ #
  1885. # Room creation (Chatto-unique)
  1886. # ------------------------------------------------------------------ #
  1887. async def create_room(
  1888. self,
  1889. name: str,
  1890. description: str = "",
  1891. group_id: str = "",
  1892. universal: bool = True,
  1893. ) -> Optional[str]:
  1894. """Create an ad-hoc room via RoomService/CreateRoom.
  1895. Returns the room ID on success, or None on failure.
  1896. """
  1897. body: Dict[str, Any] = {
  1898. "name": name,
  1899. "description": description,
  1900. "groupId": group_id,
  1901. "universal": universal,
  1902. }
  1903. try:
  1904. status, resp = await self._rpc(_PATH_CREATE_ROOM, body, retry=True)
  1905. if status != 200:
  1906. logger.debug(
  1907. "Chatto: CreateRoom failed (%s): %s",
  1908. status,
  1909. resp.get("message") or resp.get("error") or "",
  1910. )
  1911. return None
  1912. room = resp.get("room", {})
  1913. rid = str(room.get("id", "")) if room else ""
  1914. if rid:
  1915. self._room_names[rid] = name
  1916. self._room_kinds[rid] = "ROOM_KIND_GROUP"
  1917. return rid
  1918. logger.debug("Chatto: CreateRoom returned no room id: %s", resp)
  1919. return None
  1920. except Exception as e:
  1921. logger.debug("Chatto: CreateRoom error: %s", e)
  1922. return None
  1923. # ------------------------------------------------------------------ #
  1924. # Notification dismissal (Chatto-unique)
  1925. # ------------------------------------------------------------------ #
  1926. async def dismiss_all_notifications(self) -> bool:
  1927. """Dismiss all notifications via NotificationService/DismissAllNotifications."""
  1928. try:
  1929. status, resp = await self._rpc(
  1930. _PATH_DISMISS_ALL_NOTIFICATIONS, {}, retry=False
  1931. )
  1932. if status == 200:
  1933. return True
  1934. logger.debug(
  1935. "Chatto: DismissAllNotifications failed (%s): %s",
  1936. status,
  1937. resp.get("message") or resp.get("error") or "",
  1938. )
  1939. return False
  1940. except Exception as e:
  1941. logger.debug("Chatto: DismissAllNotifications error: %s", e)
  1942. return False
  1943. async def dismiss_notification(self, notification_id: str) -> bool:
  1944. """Dismiss a single notification via NotificationService/DismissNotification."""
  1945. try:
  1946. status, resp = await self._rpc(
  1947. _PATH_DISMISS_NOTIFICATION,
  1948. {"notificationId": str(notification_id)},
  1949. retry=False,
  1950. )
  1951. if status == 200:
  1952. return True
  1953. logger.debug(
  1954. "Chatto: DismissNotification failed (%s): %s",
  1955. status,
  1956. resp.get("message") or resp.get("error") or "",
  1957. )
  1958. return False
  1959. except Exception as e:
  1960. logger.debug("Chatto: DismissNotification error: %s", e)
  1961. return False
  1962. # ------------------------------------------------------------------ #
  1963. # Message editing and deletion
  1964. # ------------------------------------------------------------------ #
  1965. async def edit_message(
  1966. self,
  1967. chat_id: str,
  1968. message_id: str,
  1969. new_content: str,
  1970. metadata: Optional[Dict[str, Any]] = None,
  1971. ) -> bool:
  1972. """Edit a previously sent message via MessageService/UpdateMessage."""
  1973. body = {
  1974. "roomId": str(chat_id),
  1975. "eventId": str(message_id),
  1976. "body": new_content,
  1977. }
  1978. try:
  1979. status, resp = await self._rpc(_PATH_UPDATE_MESSAGE, body, retry=True)
  1980. if status == 200:
  1981. return True
  1982. logger.debug(
  1983. "Chatto: UpdateMessage failed (%s): %s",
  1984. status,
  1985. resp.get("message") or resp.get("error") or "",
  1986. )
  1987. return False
  1988. except Exception as e:
  1989. logger.debug("Chatto: UpdateMessage error: %s", e)
  1990. return False
  1991. async def delete_message(
  1992. self,
  1993. chat_id: str,
  1994. message_id: str,
  1995. metadata: Optional[Dict[str, Any]] = None,
  1996. ) -> bool:
  1997. """Delete a previously sent message via MessageService/DeleteMessage."""
  1998. body = {
  1999. "roomId": str(chat_id),
  2000. "eventId": str(message_id),
  2001. }
  2002. try:
  2003. status, resp = await self._rpc(_PATH_DELETE_MESSAGE, body, retry=True)
  2004. if status == 200:
  2005. return True
  2006. logger.debug(
  2007. "Chatto: DeleteMessage failed (%s): %s",
  2008. status,
  2009. resp.get("message") or resp.get("error") or "",
  2010. )
  2011. return False
  2012. except Exception as e:
  2013. logger.debug("Chatto: DeleteMessage error: %s", e)
  2014. return False
  2015. # ------------------------------------------------------------------ #
  2016. # Processing lifecycle hooks (reactions-based, like Discord)
  2017. # ------------------------------------------------------------------ #
  2018. def _reactions_enabled(self) -> bool:
  2019. """Check if processing reactions are enabled."""
  2020. return os.getenv("CHATTO_REACTIONS", "true").lower() not in {"false", "0", "no"}
  2021. def _event_room_and_message_id(self, event: MessageEvent) -> Tuple[str, str]:
  2022. """Extract room_id and message_id from a MessageEvent."""
  2023. chat_id = ""
  2024. message_id = str(event.message_id or "")
  2025. source = event.source
  2026. if source:
  2027. chat_id = str(getattr(source, "chat_id", "") or "")
  2028. # Fallback: try raw_message dict
  2029. if not chat_id or not message_id:
  2030. raw = event.raw_message
  2031. if isinstance(raw, dict):
  2032. if not chat_id:
  2033. chat_id = str(raw.get("roomId", "") or "")
  2034. if not message_id:
  2035. message_id = str(raw.get("id", "") or "")
  2036. return chat_id, message_id
  2037. async def on_processing_start(self, event: MessageEvent) -> None:
  2038. """Add an 👀 (eyes) reaction to the incoming message."""
  2039. if not self._reactions_enabled():
  2040. return
  2041. chat_id, message_id = self._event_room_and_message_id(event)
  2042. if not chat_id or not message_id:
  2043. return
  2044. await self.send_reaction(chat_id, message_id, "👀")
  2045. async def on_processing_complete(
  2046. self, event: MessageEvent, outcome: ProcessingOutcome
  2047. ) -> None:
  2048. """Swap the 👀 reaction for ✅ (success) or ❌ (failure)."""
  2049. if not self._reactions_enabled():
  2050. return
  2051. chat_id, message_id = self._event_room_and_message_id(event)
  2052. if not chat_id or not message_id:
  2053. return
  2054. # Remove the processing eyes reaction
  2055. await self.remove_reaction(chat_id, message_id, "👀")
  2056. # Add the outcome reaction
  2057. if outcome == ProcessingOutcome.SUCCESS:
  2058. await self.send_reaction(chat_id, message_id, "✅")
  2059. elif outcome == ProcessingOutcome.FAILURE:
  2060. await self.send_reaction(chat_id, message_id, "❌")
  2061. # ------------------------------------------------------------------ #
  2062. # Asset upload (chunked)
  2063. # ------------------------------------------------------------------ #
  2064. async def _upload_asset(self, room_id: str, file_path: str) -> Optional[str]:
  2065. """Upload a file via the chunked AssetUploadService.
  2066. Returns the asset ID on success, or None on failure.
  2067. """
  2068. try:
  2069. with open(file_path, "rb") as f:
  2070. file_data = f.read()
  2071. except Exception as e:
  2072. logger.error("Chatto: failed to read file %s — %s", file_path, e)
  2073. return None
  2074. if not file_data:
  2075. logger.error("Chatto: file %s is empty", file_path)
  2076. return None
  2077. file_size = len(file_data)
  2078. file_name = os.path.basename(file_path)
  2079. mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
  2080. sha256_hash = hashlib.sha256(file_data).hexdigest()
  2081. # Step 1: Create upload session
  2082. create_body = {
  2083. "roomId": room_id,
  2084. "filename": file_name,
  2085. "contentType": mime_type,
  2086. "size": file_size,
  2087. "sha256": sha256_hash,
  2088. }
  2089. status, resp = await self._rpc(_PATH_CREATE_UPLOAD, create_body)
  2090. if status != 200:
  2091. logger.error(
  2092. "Chatto: CreateUpload failed (%s): %s",
  2093. status,
  2094. resp.get("message") or resp.get("error") or "",
  2095. )
  2096. return None
  2097. upload_id = str(resp.get("upload", {}).get("id", ""))
  2098. if not upload_id:
  2099. logger.error("Chatto: CreateUpload returned no upload ID: %s", resp)
  2100. return None
  2101. # Step 2: Upload chunks
  2102. offset = 0
  2103. while offset < file_size:
  2104. chunk = file_data[offset:offset + _UPLOAD_CHUNK_SIZE]
  2105. chunk_b64 = base64.b64encode(chunk).decode("ascii")
  2106. chunk_sha256 = hashlib.sha256(chunk).hexdigest()
  2107. chunk_body = {
  2108. "uploadId": upload_id,
  2109. "offset": offset,
  2110. "content": chunk_b64,
  2111. "chunkSha256": chunk_sha256,
  2112. }
  2113. status, resp = await self._rpc(_PATH_UPLOAD_CHUNK, chunk_body)
  2114. if status != 200:
  2115. logger.error(
  2116. "Chatto: UploadChunk failed at offset %d (%s): %s",
  2117. offset,
  2118. status,
  2119. resp.get("message") or resp.get("error") or "",
  2120. )
  2121. return None
  2122. offset += len(chunk)
  2123. # Step 3: Complete upload
  2124. complete_body = {"uploadId": upload_id}
  2125. status, resp = await self._rpc(_PATH_COMPLETE_UPLOAD, complete_body)
  2126. if status != 200:
  2127. logger.error(
  2128. "Chatto: CompleteUpload failed (%s): %s",
  2129. status,
  2130. resp.get("message") or resp.get("error") or "",
  2131. )
  2132. return None
  2133. asset_id = str(resp.get("asset", {}).get("id", ""))
  2134. if not asset_id:
  2135. logger.error("Chatto: CompleteUpload returned no asset ID: %s", resp)
  2136. return None
  2137. logger.info("Chatto: uploaded %s as asset %s (%d bytes)", file_name, asset_id, file_size)
  2138. return asset_id
  2139. async def send_image_file(
  2140. self,
  2141. chat_id: str,
  2142. file_path: str,
  2143. caption: Optional[str] = None,
  2144. reply_to: Optional[str] = None,
  2145. metadata: Optional[Dict[str, Any]] = None,
  2146. ) -> SendResult:
  2147. """Send a local image file via the chunked upload API."""
  2148. # Validate the path is safe
  2149. safe_path = self.validate_media_delivery_path(file_path)
  2150. if not safe_path:
  2151. logger.warning("Chatto: send_image_file — unsafe path %s", file_path)
  2152. text = "⚠️ Couldn't deliver the image attachment."
  2153. if caption:
  2154. text = f"{caption}\n{text}"
  2155. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  2156. asset_id = await self._upload_asset(str(chat_id), safe_path)
  2157. if not asset_id:
  2158. # Fallback to a notice
  2159. text = "⚠️ Couldn't deliver the image attachment."
  2160. if caption:
  2161. text = f"{caption}\n{text}"
  2162. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  2163. body: Dict[str, Any] = {
  2164. "roomId": str(chat_id),
  2165. "body": caption or "",
  2166. "attachmentAssetIds": [asset_id],
  2167. }
  2168. thread_id = (metadata or {}).get("thread_id")
  2169. if reply_to:
  2170. thread_id = reply_to
  2171. if thread_id:
  2172. body["threadRootEventId"] = str(thread_id)
  2173. status, resp = await self._rpc(_PATH_CREATE_MESSAGE, body)
  2174. if status != 200:
  2175. err = resp.get("message") or resp.get("error") or f"HTTP {status}"
  2176. return SendResult(success=False, error=err, retryable=status >= 500 or status == 401)
  2177. msg = resp.get("message", {})
  2178. msg_id = str(msg.get("id", "")) if msg else ""
  2179. if msg_id:
  2180. self._mark_seen(str(chat_id), msg_id)
  2181. return SendResult(success=True, message_id=msg_id, raw_response=resp)
  2182. async def send_image(
  2183. self,
  2184. chat_id: str,
  2185. image_url: str,
  2186. caption: Optional[str] = None,
  2187. reply_to: Optional[str] = None,
  2188. metadata: Optional[Dict[str, Any]] = None,
  2189. ) -> SendResult:
  2190. """Send an image to a Chatto room.
  2191. Tries to download the image from the URL and upload it as a native
  2192. attachment. Falls back to sending the URL as a link (Chatto renders
  2193. link previews) if the download fails.
  2194. """
  2195. # Try downloading and uploading as attachment
  2196. try:
  2197. import tempfile
  2198. import urllib.request as _urllib_request
  2199. # Download to a temp file
  2200. parsed = urlsplit(image_url)
  2201. url_path = parsed.path
  2202. ext = os.path.splitext(url_path)[1] or ".png"
  2203. tmp_fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="chatto_img_")
  2204. try:
  2205. os.close(tmp_fd)
  2206. req = _urllib_request.Request(image_url, headers={"User-Agent": "Hermes/1.0"})
  2207. ctx = _ssl_context()
  2208. with _urllib_request.urlopen(req, timeout=_HTTP_TIMEOUT, context=ctx) as resp:
  2209. with open(tmp_path, "wb") as f:
  2210. f.write(resp.read())
  2211. # Upload as attachment
  2212. result = await self.send_image_file(
  2213. chat_id, tmp_path, caption=caption,
  2214. reply_to=reply_to, metadata=metadata,
  2215. )
  2216. if result.success:
  2217. return result
  2218. finally:
  2219. try:
  2220. os.unlink(tmp_path)
  2221. except OSError:
  2222. pass
  2223. except Exception as e:
  2224. logger.debug("Chatto: send_image download/upload failed, falling back to link: %s", e)
  2225. # Fallback: send as link (Chatto renders link previews)
  2226. text = image_url
  2227. if caption:
  2228. text = f"{caption}\n{image_url}"
  2229. return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
  2230. # ------------------------------------------------------------------ #
  2231. # Platform properties
  2232. # ------------------------------------------------------------------ #
  2233. @property
  2234. def platform_name(self) -> str:
  2235. return "chatto"
  2236. @property
  2237. def supports_markdown(self) -> bool:
  2238. return True
  2239. @property
  2240. def supports_reactions(self) -> bool:
  2241. return True
  2242. # ------------------------------------------------------------------ #
  2243. # Member directory — user lookup and mention resolution (Chatto-unique)
  2244. # ------------------------------------------------------------------ #
  2245. async def list_users(self) -> list:
  2246. """List all server members via UserService/ListUsers.
  2247. Returns a list of user dicts. Each dict typically contains
  2248. ``id``, ``login``, and ``displayName`` keys.
  2249. """
  2250. try:
  2251. status, resp = await self._rpc(_PATH_LIST_USERS, {}, retry=True)
  2252. if status != 200:
  2253. logger.debug(
  2254. "Chatto: ListUsers failed (%s): %s",
  2255. status,
  2256. resp.get("message") or resp.get("error") or "",
  2257. )
  2258. return []
  2259. users = resp.get("users", [])
  2260. # Cache all returned users
  2261. for u in users:
  2262. uid = str(u.get("id", ""))
  2263. if uid:
  2264. self._user_cache[uid] = u
  2265. return users
  2266. except Exception as e:
  2267. logger.debug("Chatto: ListUsers error: %s", e)
  2268. return []
  2269. async def get_user(self, user_id: str) -> Optional[dict]:
  2270. """Get a single user by ID via UserService/GetUser.
  2271. Returns the user dict (containing ``id``, ``login``,
  2272. ``displayName``) or ``None`` on failure. Results are cached in
  2273. ``self._user_cache``.
  2274. """
  2275. if not user_id:
  2276. return None
  2277. # Return cached entry if available
  2278. if user_id in self._user_cache:
  2279. return self._user_cache[user_id]
  2280. try:
  2281. status, resp = await self._rpc(
  2282. _PATH_GET_USER, {"userId": str(user_id)}, retry=True
  2283. )
  2284. if status != 200:
  2285. logger.debug(
  2286. "Chatto: GetUser failed (%s): %s",
  2287. status,
  2288. resp.get("message") or resp.get("error") or "",
  2289. )
  2290. return None
  2291. user = resp.get("user")
  2292. if user:
  2293. uid = str(user.get("id", ""))
  2294. if uid:
  2295. self._user_cache[uid] = user
  2296. return user
  2297. return None
  2298. except Exception as e:
  2299. logger.debug("Chatto: GetUser error: %s", e)
  2300. return None
  2301. async def batch_get_users(self, user_ids: list) -> list:
  2302. """Batch-fetch multiple users via UserService/BatchGetUsers.
  2303. Returns a list of user dicts. Cached entries are reused and only
  2304. uncached IDs are fetched from the server.
  2305. """
  2306. if not user_ids:
  2307. return []
  2308. # Separate cached from uncached
  2309. cached: list = []
  2310. uncached_ids: list = []
  2311. for uid in user_ids:
  2312. uid_str = str(uid)
  2313. if uid_str in self._user_cache:
  2314. cached.append(self._user_cache[uid_str])
  2315. else:
  2316. uncached_ids.append(uid_str)
  2317. if not uncached_ids:
  2318. return cached
  2319. try:
  2320. status, resp = await self._rpc(
  2321. _PATH_BATCH_GET_USERS, {"userIds": uncached_ids}, retry=True
  2322. )
  2323. if status != 200:
  2324. logger.debug(
  2325. "Chatto: BatchGetUsers failed (%s): %s",
  2326. status,
  2327. resp.get("message") or resp.get("error") or "",
  2328. )
  2329. return cached
  2330. fetched = resp.get("users", [])
  2331. for u in fetched:
  2332. uid = str(u.get("id", ""))
  2333. if uid:
  2334. self._user_cache[uid] = u
  2335. return cached + fetched
  2336. except Exception as e:
  2337. logger.debug("Chatto: BatchGetUsers error: %s", e)
  2338. return cached
  2339. # ------------------------------------------------------------------ #
  2340. # Presence broadcasting (Chatto-unique)
  2341. # ------------------------------------------------------------------ #
  2342. async def set_presence(self, status: str) -> bool:
  2343. """Update the bot's presence status via MyAccountService/UpdatePresence.
  2344. Accepts string values ``"online"``, ``"away"``, ``"dnd"`` (or
  2345. ``"do_not_disturb"``) and maps them to Chatto's integer status
  2346. codes: 1=ONLINE, 2=AWAY, 3=DO_NOT_DISTURB.
  2347. Returns ``True`` on success.
  2348. """
  2349. status_lower = status.lower().strip()
  2350. status_int = _PRESENCE_STATUS_MAP.get(status_lower)
  2351. if status_int is None:
  2352. logger.warning("Chatto: unknown presence status %r", status)
  2353. return False
  2354. try:
  2355. status_code, resp = await self._rpc(
  2356. _PATH_UPDATE_PRESENCE, {"status": status_int}, retry=False
  2357. )
  2358. if status_code == 200:
  2359. logger.debug("Chatto: presence set to %s (%d)", status_lower, status_int)
  2360. return True
  2361. logger.debug(
  2362. "Chatto: UpdatePresence failed (%s): %s",
  2363. status_code,
  2364. resp.get("message") or resp.get("error") or "",
  2365. )
  2366. return False
  2367. except Exception as e:
  2368. logger.debug("Chatto: UpdatePresence error: %s", e)
  2369. return False
  2370. # ------------------------------------------------------------------ #
  2371. # Custom status messages (Chatto-unique)
  2372. # ------------------------------------------------------------------ #
  2373. async def set_custom_status(self, text: str) -> bool:
  2374. """Set a custom status message via MyAccountService/UpdateCustomStatus.
  2375. The status text is a plain string (max ~100 chars). Useful for
  2376. indicating long-running operations, e.g. ``"Processing..."``.
  2377. Returns ``True`` on success.
  2378. """
  2379. if not text:
  2380. return False
  2381. # Truncate to a reasonable length
  2382. status_text = text.strip()[:100]
  2383. if not status_text:
  2384. return False
  2385. try:
  2386. status_code, resp = await self._rpc(
  2387. _PATH_UPDATE_CUSTOM_STATUS, {"status": status_text}, retry=False
  2388. )
  2389. if status_code == 200:
  2390. logger.debug("Chatto: custom status set to %r", status_text)
  2391. return True
  2392. logger.debug(
  2393. "Chatto: UpdateCustomStatus failed (%s): %s",
  2394. status_code,
  2395. resp.get("message") or resp.get("error") or "",
  2396. )
  2397. return False
  2398. except Exception as e:
  2399. logger.debug("Chatto: UpdateCustomStatus error: %s", e)
  2400. return False
  2401. async def clear_custom_status(self) -> bool:
  2402. """Clear the custom status message via MyAccountService/DeleteCustomStatus.
  2403. Returns ``True`` on success.
  2404. """
  2405. try:
  2406. status_code, resp = await self._rpc(
  2407. _PATH_DELETE_CUSTOM_STATUS, {}, retry=False
  2408. )
  2409. if status_code == 200:
  2410. logger.debug("Chatto: custom status cleared")
  2411. return True
  2412. logger.debug(
  2413. "Chatto: DeleteCustomStatus failed (%s): %s",
  2414. status_code,
  2415. resp.get("message") or resp.get("error") or "",
  2416. )
  2417. return False
  2418. except Exception as e:
  2419. logger.debug("Chatto: DeleteCustomStatus error: %s", e)
  2420. return False
  2421. @property
  2422. def supports_threads(self) -> bool:
  2423. return True
  2424. # --------------------------------------------------------------------------- #
  2425. # Plugin registration
  2426. # --------------------------------------------------------------------------- #
  2427. def check_requirements() -> bool:
  2428. """Check if Chatto is configured."""
  2429. return bool(
  2430. os.getenv("CHATTO_URL", "").strip()
  2431. and os.getenv("CHATTO_LOGIN", "").strip()
  2432. and os.getenv("CHATTO_PASSWORD", "").strip()
  2433. )
  2434. def validate_config(config) -> bool:
  2435. """Validate that the platform config has enough info to connect."""
  2436. extra = getattr(config, "extra", {}) or {}
  2437. url = os.getenv("CHATTO_URL") or str(extra.get("url", ""))
  2438. login = os.getenv("CHATTO_LOGIN", "").strip()
  2439. password = os.getenv("CHATTO_PASSWORD", "").strip()
  2440. return bool(url and login and password)
  2441. def is_connected(config) -> bool:
  2442. """Check whether Chatto is configured."""
  2443. return validate_config(config)
  2444. def _apply_yaml_config(yaml_cfg: dict, chatto_cfg: dict) -> Optional[dict]:
  2445. """Translate config.yaml chatto.extra keys into CHATTO_* env vars."""
  2446. extra = chatto_cfg.get("extra") if isinstance(chatto_cfg.get("extra"), dict) else {}
  2447. mapping = {
  2448. "url": "CHATTO_URL",
  2449. "home_channel": "CHATTO_HOME_CHANNEL",
  2450. "require_mention": "CHATTO_REQUIRE_MENTION",
  2451. "free_response_channels": "CHATTO_FREE_RESPONSE_CHANNELS",
  2452. "auto_thread": "CHATTO_AUTO_THREAD",
  2453. }
  2454. for yaml_key, env_key in mapping.items():
  2455. val = extra.get(yaml_key)
  2456. if val is not None and not os.getenv(env_key):
  2457. if isinstance(val, bool):
  2458. os.environ[env_key] = str(val).lower()
  2459. elif isinstance(val, list):
  2460. os.environ[env_key] = ",".join(str(v) for v in val)
  2461. else:
  2462. os.environ[env_key] = str(val)
  2463. channels = extra.get("channels")
  2464. if isinstance(channels, list) and not os.getenv("CHATTO_CHANNELS"):
  2465. os.environ["CHATTO_CHANNELS"] = ",".join(str(c) for c in channels)
  2466. allowed = extra.get("allowed_users")
  2467. if isinstance(allowed, list) and not os.getenv("CHATTO_ALLOWED_USERS"):
  2468. os.environ["CHATTO_ALLOWED_USERS"] = ",".join(str(u) for u in allowed)
  2469. if "allow_all_users" in extra and not os.getenv("CHATTO_ALLOW_ALL_USERS"):
  2470. os.environ["CHATTO_ALLOW_ALL_USERS"] = str(extra["allow_all_users"]).lower()
  2471. # Return nothing to merge — all config flows through env
  2472. return None
  2473. def _env_enablement() -> Optional[dict]:
  2474. """Seed PlatformConfig.extra from env vars for env-only setups."""
  2475. url = os.getenv("CHATTO_URL", "").strip()
  2476. if not url:
  2477. return None
  2478. extra = {"url": url}
  2479. home = os.getenv("CHATTO_HOME_CHANNEL", "").strip()
  2480. if home:
  2481. extra["home_channel"] = home
  2482. channels = os.getenv("CHATTO_CHANNELS", "").strip()
  2483. if channels:
  2484. extra["channels"] = [c.strip() for c in channels.split(",") if c.strip()]
  2485. rm = os.getenv("CHATTO_REQUIRE_MENTION", "").strip().lower()
  2486. if rm:
  2487. extra["require_mention"] = rm in ("true", "1", "yes")
  2488. home_dict = {"home_channel": home} if home else None
  2489. return {"extra": extra, "home_channel": home_dict}
  2490. async def _standalone_send(
  2491. base_url: str,
  2492. login: str,
  2493. password: str,
  2494. room_id: str,
  2495. content: str,
  2496. thread_id: Optional[str] = None,
  2497. ) -> dict:
  2498. """Out-of-process send for cron delivery (no live adapter needed)."""
  2499. token = _auth_login(base_url, login, password)
  2500. if not token:
  2501. return {"success": False, "error": "login failed"}
  2502. body: Dict[str, Any] = {"roomId": room_id, "body": content}
  2503. if thread_id:
  2504. body["threadRootEventId"] = thread_id
  2505. status, resp = _rpc_request(base_url, _PATH_CREATE_MESSAGE, token, body)
  2506. if status == 200:
  2507. return {"success": True, "response": resp}
  2508. return {"success": False, "error": resp.get("message", f"HTTP {status}"), "status": status}
  2509. def interactive_setup() -> None:
  2510. """Interactive setup wizard for Chatto."""
  2511. from hermes_cli.gateway import prompt_env, set_env_var
  2512. url = prompt_env("Chatto server URL (e.g. https://chat.example.com):")
  2513. if url:
  2514. set_env_var("CHATTO_URL", url)
  2515. login = prompt_env("Chatto login (username):")
  2516. if login:
  2517. set_env_var("CHATTO_LOGIN", login)
  2518. password = prompt_env("Chatto password:", password=True)
  2519. if password:
  2520. set_env_var("CHATTO_PASSWORD", password)
  2521. channels = prompt_env("Room IDs to watch (comma-separated, or empty for all):")
  2522. if channels:
  2523. set_env_var("CHATTO_CHANNELS", channels)
  2524. home = prompt_env("Home room ID for notifications (or empty):")
  2525. if home:
  2526. set_env_var("CHATTO_HOME_CHANNEL", home)
  2527. allow_all = prompt_env("Allow all users? (true/false):")
  2528. if allow_all:
  2529. set_env_var("CHATTO_ALLOW_ALL_USERS", allow_all)
  2530. print("\n✓ Chatto configured. Restart the gateway to activate.")
  2531. def register(ctx) -> None:
  2532. """Plugin entry point — called by the Hermes plugin system."""
  2533. ctx.register_platform(
  2534. name="chatto",
  2535. label="Chatto",
  2536. adapter_factory=lambda cfg: ChattoAdapter(cfg),
  2537. check_fn=check_requirements,
  2538. validate_config=validate_config,
  2539. is_connected=is_connected,
  2540. required_env=["CHATTO_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD"],
  2541. install_hint="Requires a Chatto server. See https://docs.chatto.run",
  2542. setup_fn=interactive_setup,
  2543. apply_yaml_config_fn=_apply_yaml_config,
  2544. cron_deliver_env_var="CHATTO_HOME_CHANNEL",
  2545. standalone_sender_fn=_standalone_send,
  2546. allowed_users_env="CHATTO_ALLOWED_USERS",
  2547. allow_all_env="CHATTO_ALLOW_ALL_USERS",
  2548. max_message_length=_MAX_MESSAGE_LENGTH,
  2549. emoji="💬",
  2550. allow_update_command=True,
  2551. pii_safe=False,
  2552. platform_hint=(
  2553. "You are chatting in Chatto (a self-hosted team chat server). "
  2554. "Markdown IS supported. Users address you by @-mentioning your name "
  2555. "in rooms; direct messages reach you without a mention. "
  2556. "Keep responses conversational."
  2557. ),
  2558. )