client.py 120 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270
  1. """Main async client for the Chatto Connect API.
  2. Chatto migrated from GraphQL to a protobuf-first Connect API in v0.4.x
  3. (see ADR-042). The client speaks Connect binary protobuf over a
  4. hand-rolled transport (``chattolib._connect``) and the generated service
  5. stubs under ``chattolib._pb`` for all request/response operations. Realtime
  6. events live in ``chattolib.realtime``.
  7. """
  8. # mypy: disable-error-code="no-any-return"
  9. # Rationale: attribute access on generated protobuf messages is Any-typed
  10. # from mypy's perspective (the generated modules skip type checking via
  11. # follow_imports=skip). The runtime types are exactly what the return-type
  12. # annotations claim.
  13. from __future__ import annotations
  14. import hashlib
  15. from collections.abc import Awaitable
  16. from datetime import datetime
  17. from pathlib import Path
  18. from typing import Any, TypeVar
  19. # ConnectError is raised by our hand-rolled Connect client (chattolib._connect);
  20. # catch it here to translate into the library's public exception hierarchy.
  21. from chattolib._connect import ConnectError # noqa: E402
  22. from chattolib._pb.chatto.admin.v1 import (
  23. event_log_pb2,
  24. room_layout_pb2,
  25. )
  26. from chattolib._pb.chatto.admin.v1 import (
  27. invitations_pb2 as admin_invitations_pb2,
  28. )
  29. from chattolib._pb.chatto.admin.v1 import (
  30. members_pb2 as admin_members_pb2,
  31. )
  32. from chattolib._pb.chatto.admin.v1 import (
  33. oauth_clients_pb2 as admin_oauth_clients_pb2,
  34. )
  35. from chattolib._pb.chatto.admin.v1 import (
  36. permissions_pb2 as admin_permissions_pb2,
  37. )
  38. from chattolib._pb.chatto.admin.v1 import (
  39. roles_pb2 as admin_roles_pb2,
  40. )
  41. from chattolib._pb.chatto.admin.v1 import (
  42. server_pb2 as admin_server_pb2,
  43. )
  44. from chattolib._pb.chatto.api.v1 import (
  45. account_pb2,
  46. asset_uploads_pb2,
  47. attachments_pb2,
  48. bots_pb2,
  49. common_pb2,
  50. link_previews_pb2,
  51. member_directory_pb2,
  52. message_search_pb2,
  53. messages_pb2,
  54. notifications_pb2,
  55. pagination_pb2,
  56. permissions_pb2,
  57. presence_pb2,
  58. push_notifications_pb2,
  59. reactions_pb2,
  60. read_state_pb2,
  61. roles_pb2,
  62. room_directory_pb2,
  63. room_timeline_pb2,
  64. rooms_pb2,
  65. server_state_pb2,
  66. threads_pb2,
  67. user_service_pb2,
  68. user_status_pb2,
  69. viewer_pb2,
  70. voice_calls_pb2,
  71. )
  72. from chattolib._pb.chatto.auth.v1 import (
  73. external_identity_auth_pb2,
  74. push_subscription_cleanup_pb2,
  75. setup_pb2,
  76. )
  77. from chattolib._pb.chatto.discovery.v1 import server_pb2 as discovery_server_pb2
  78. from chattolib._transport import (
  79. ServiceClients,
  80. build_service_clients,
  81. pb_to_dict,
  82. translate_connect_error,
  83. )
  84. from chattolib.exceptions import ChattoAuthError, ChattoError
  85. from chattolib.types import (
  86. ActiveCall,
  87. ActiveCallParticipant,
  88. AdminMember,
  89. AdminRole,
  90. AdminRoomLayoutGroup,
  91. Asset,
  92. AssetUpload,
  93. BotAccount,
  94. BotApiKey,
  95. BotOutboundWebhook,
  96. BotWebhookFailure,
  97. CallMediaPublisherKind,
  98. DirectoryMember,
  99. ExternalIdentityProvider,
  100. FollowedThread,
  101. FollowedThreadsPage,
  102. ImageTransformOptions,
  103. InviteLink,
  104. LinkedExternalIdentity,
  105. LinkPreview,
  106. Message,
  107. MessageSearchGroupBy,
  108. MessageSearchOrder,
  109. MessageSearchScope,
  110. Neighbor,
  111. NeighborhoodServersPage,
  112. NotificationOccurrence,
  113. NotificationOccurrencesPage,
  114. NotificationPolicy,
  115. OAuthClient,
  116. OauthClientPolicy,
  117. Page,
  118. PendingExternalIdentity,
  119. PermissionDecision,
  120. PermissionScopeKind,
  121. PinnedMessage,
  122. PinnedMessagesPage,
  123. PresencePreference,
  124. PresenceStatus,
  125. Role,
  126. Room,
  127. RoomDirectoryScope,
  128. RoomGroup,
  129. RoomReadState,
  130. RoomSuspension,
  131. RoomThreadingMode,
  132. RoomWithViewerState,
  133. SearchResultsPage,
  134. SearchStatus,
  135. ServerConfig,
  136. ServerLogin,
  137. ServerProfile,
  138. ServerRuntimeConfig,
  139. ThreadReadState,
  140. ThreadRootAuthorFilter,
  141. TimeFormat,
  142. TimelinePage,
  143. User,
  144. UserSettings,
  145. VerifiedEmail,
  146. ViewerUser,
  147. format_datetime,
  148. parse_datetime,
  149. )
  150. RES = TypeVar("RES")
  151. def _page_pb(limit: int | None, offset: int | None) -> pagination_pb2.PageRequest | None:
  152. if limit is None and offset is None:
  153. return None
  154. return pagination_pb2.PageRequest(limit=limit or 0, offset=offset or 0)
  155. def _policy_scope(*, room_id: str = "", room_group_id: str = "") -> Any:
  156. """Build a ``NotificationPolicyScope`` selecting server / group / room."""
  157. scope = notifications_pb2.NotificationPolicyScope()
  158. if room_id:
  159. scope.room_id = room_id
  160. elif room_group_id:
  161. scope.room_group_id = room_group_id
  162. else:
  163. scope.server.SetCachedValue()
  164. return scope
  165. def _layout_item(target: Any, kind: str, item_id: str) -> None:
  166. """Set the required ``oneof item`` on an ``AdminRoomLayoutItemInput``."""
  167. if kind == "room":
  168. target.room_id = item_id
  169. elif kind == "sidebar_link":
  170. target.sidebar_link_id = item_id
  171. else:
  172. raise ValueError(f"unknown room-layout item kind: {kind!r} (use 'room' or 'sidebar_link')")
  173. def _thumbnail_pb(
  174. opts: ImageTransformOptions | None,
  175. ) -> common_pb2.ImageTransformOptions | None:
  176. if opts is None:
  177. return None
  178. return common_pb2.ImageTransformOptions(
  179. width=opts.width, height=opts.height, fit=opts.fit.value
  180. )
  181. def _timestamp_pb(value: datetime | None) -> Any:
  182. from google.protobuf import timestamp_pb2
  183. if value is None:
  184. return None
  185. ts = timestamp_pb2.Timestamp()
  186. ts.FromJsonString(format_datetime(value))
  187. return ts
  188. def _permission_scope_pb(kind: PermissionScopeKind, scope_id: str) -> Any:
  189. """Build an ``admin.v1.PermissionScope`` (SERVER/DM take an empty ID)."""
  190. scope = admin_permissions_pb2.PermissionScope()
  191. scope.kind = admin_permissions_pb2.PermissionScopeKind.Value(kind.value)
  192. if scope_id:
  193. scope.id = scope_id
  194. return scope
  195. class ChattoClient:
  196. """Async client for the Chatto Connect API.
  197. Usage::
  198. async with await ChattoClient.login("user", "pass") as client:
  199. viewer = await client.get_viewer()
  200. rooms = await client.list_rooms()
  201. # Or with an existing token:
  202. async with ChattoClient(token="cht_...") as client:
  203. ...
  204. """
  205. DEFAULT_BASE_URL = "https://chat.chatto.run"
  206. def __init__(
  207. self,
  208. token: str | None = None,
  209. *,
  210. base_url: str = DEFAULT_BASE_URL,
  211. session_cookie: str | None = None,
  212. service_clients: ServiceClients | None = None,
  213. ) -> None:
  214. self._base_url = base_url.rstrip("/")
  215. self._token = token
  216. self._session_cookie = session_cookie
  217. self._svc = service_clients or build_service_clients(self._base_url)
  218. self._owns_clients = service_clients is None
  219. async def __aenter__(self) -> ChattoClient:
  220. return self
  221. async def __aexit__(self, *exc: Any) -> None:
  222. await self.close()
  223. async def close(self) -> None:
  224. if self._owns_clients:
  225. await self._svc.close()
  226. # --- Transport ------------------------------------------------------
  227. @property
  228. def base_url(self) -> str:
  229. return self._base_url
  230. @property
  231. def token(self) -> str | None:
  232. return self._token
  233. @property
  234. def session_cookie(self) -> str | None:
  235. return self._session_cookie
  236. @property
  237. def services(self) -> ServiceClients:
  238. """Direct access to the underlying ConnectRPC service clients.
  239. Useful when a caller wants to reach an RPC that this class doesn't
  240. expose yet, or wants full protobuf messages instead of the
  241. dataclass views returned by the high-level helpers.
  242. """
  243. return self._svc
  244. def _headers(self) -> dict[str, str]:
  245. headers: dict[str, str] = {}
  246. if self._token:
  247. headers["Authorization"] = f"Bearer {self._token}"
  248. if self._session_cookie:
  249. headers["Cookie"] = f"chatto_session={self._session_cookie}"
  250. return headers
  251. async def _rpc(self, coro: Awaitable[RES]) -> RES:
  252. """Await a ConnectRPC coroutine, translating errors."""
  253. try:
  254. return await coro
  255. except ConnectError as exc:
  256. raise translate_connect_error(exc) from exc
  257. # --- Server discovery ----------------------------------------------
  258. async def get_server(self) -> tuple[ServerProfile, ServerLogin]:
  259. """Public server profile and login options. Does not require auth."""
  260. resp = await self._rpc(
  261. self._svc.server_discovery.get_server(
  262. discovery_server_pb2.GetServerRequest(),
  263. headers=self._headers(),
  264. )
  265. )
  266. return (
  267. ServerProfile.parse(pb_to_dict(resp.profile)),
  268. ServerLogin.parse(pb_to_dict(resp.login)),
  269. )
  270. async def list_neighbors(self) -> list[str]:
  271. """Public Neighbor directory: the advertised canonical server origins.
  272. Does not require auth. The response has no ordering contract.
  273. """
  274. resp = await self._rpc(
  275. self._svc.server_discovery.list_neighbors(
  276. discovery_server_pb2.ListNeighborsRequest(),
  277. headers=self._headers(),
  278. )
  279. )
  280. return list(resp.origins)
  281. async def list_neighborhood_servers(self) -> NeighborhoodServersPage:
  282. """Servers in the Neighborhood the called server has discovered.
  283. Does not require auth. The server loads other servers' public profiles
  284. in the background and caches the result (refreshed at least hourly),
  285. so the call itself contacts no other server. The response has no
  286. ordering contract; ``refreshed_at`` is absent before the server's
  287. first completed discovery.
  288. """
  289. resp = await self._rpc(
  290. self._svc.server_discovery.list_neighborhood_servers(
  291. discovery_server_pb2.ListNeighborhoodServersRequest(),
  292. headers=self._headers(),
  293. )
  294. )
  295. return NeighborhoodServersPage.parse(pb_to_dict(resp))
  296. async def get_motd(self) -> str | None:
  297. resp = await self._rpc(
  298. self._svc.server.get_motd(server_state_pb2.GetMotdRequest(), headers=self._headers())
  299. )
  300. if not resp.HasField("motd"):
  301. return None
  302. return resp.motd
  303. async def get_runtime_config(self) -> ServerRuntimeConfig:
  304. resp = await self._rpc(
  305. self._svc.server.get_runtime_config(
  306. server_state_pb2.GetRuntimeConfigRequest(),
  307. headers=self._headers(),
  308. )
  309. )
  310. return ServerRuntimeConfig.parse(pb_to_dict(resp.runtime))
  311. # --- Viewer ---------------------------------------------------------
  312. async def get_viewer(self) -> dict[str, Any]:
  313. """Full authenticated viewer snapshot (camelCase JSON dict).
  314. The response contains ``user``, ``capabilities``, notification
  315. preferences, permissions and viewer state. Callers that only need the
  316. current user's public profile should use ``me()`` for a typed result.
  317. """
  318. resp = await self._rpc(
  319. self._svc.viewer.get_viewer(viewer_pb2.GetViewerRequest(), headers=self._headers())
  320. )
  321. return pb_to_dict(resp)
  322. async def viewer_user(self) -> ViewerUser | None:
  323. data = await self.get_viewer()
  324. return ViewerUser.parse(data.get("user"))
  325. async def me(self) -> User:
  326. """Return the authenticated user's public profile."""
  327. viewer = await self.viewer_user()
  328. if viewer is None or viewer.profile is None:
  329. raise ChattoAuthError("No authenticated viewer")
  330. return viewer.profile
  331. # --- MyAccount -----------------------------------------------------
  332. async def update_user_profile(
  333. self,
  334. user_id: str,
  335. *,
  336. display_name: str | None = None,
  337. login: str | None = None,
  338. bio: str | None = None,
  339. ) -> User:
  340. """Update a user's public profile via ``UserService.UpdateUserProfile``.
  341. This is the one wire path for profile edits in 0.5.0-beta.9. Users
  342. update their own account without extra authority; updating another
  343. human requires ``user.manage-accounts`` (and, for a bot target,
  344. ownership of the bot, ``user.manage-accounts``, or ``bot.manage``) —
  345. the server enforces that. A ``login`` change is rate-limited by the
  346. server's username-change cooldown. Returns the updated ``User``.
  347. """
  348. req = user_service_pb2.UpdateUserProfileRequest(user_id=user_id)
  349. if display_name is not None:
  350. req.display_name = display_name
  351. if login is not None:
  352. req.login = login
  353. if bio is not None:
  354. req.bio = bio
  355. resp = await self._rpc(self._svc.user.update_user_profile(req, headers=self._headers()))
  356. user = User.parse(pb_to_dict(resp.user))
  357. assert user is not None
  358. return user
  359. async def update_profile(
  360. self,
  361. *,
  362. display_name: str | None = None,
  363. login: str | None = None,
  364. bio: str | None = None,
  365. ) -> User:
  366. """Update the authenticated user's own public profile.
  367. The pre-0.5.0b9 ``MyAccountService.UpdateProfile`` RPC was removed;
  368. profile edits now go through ``UserService.UpdateUserProfile``, which
  369. needs a target ``user_id`` — so this resolves the caller's own id from
  370. the viewer snapshot first and then delegates to
  371. :meth:`update_user_profile`.
  372. """
  373. me = await self.me()
  374. return await self.update_user_profile(
  375. me.id, display_name=display_name, login=login, bio=bio
  376. )
  377. async def update_settings(
  378. self,
  379. *,
  380. timezone: str | None = None,
  381. time_format: TimeFormat | None = None,
  382. share_timezone: bool | None = None,
  383. ) -> UserSettings:
  384. req = account_pb2.UpdateSettingsRequest()
  385. if timezone is not None:
  386. req.timezone = timezone
  387. if time_format is not None:
  388. req.time_format = time_format.value
  389. if share_timezone is not None:
  390. req.share_timezone = share_timezone
  391. resp = await self._rpc(self._svc.account.update_settings(req, headers=self._headers()))
  392. return UserSettings.parse(pb_to_dict(resp.settings))
  393. async def set_presence(
  394. self,
  395. status: PresenceStatus,
  396. *,
  397. user_selected: bool = True,
  398. ) -> PresenceStatus:
  399. """Report a *live* presence status (the pre-0.5.0b6 mechanism).
  400. This is the legacy, transient report: it expires after 60 seconds
  401. without a refresh and a saved private choice (see
  402. :meth:`set_presence_preference`) overrides it, including when
  403. ``user_selected`` is set. New clients should prefer the
  404. :meth:`set_presence_preference` / :meth:`refresh_presence` pair.
  405. """
  406. if status in (PresenceStatus.UNSPECIFIED, PresenceStatus.OFFLINE):
  407. raise ValueError(
  408. "UNSPECIFIED and OFFLINE cannot be set as presence status; "
  409. "stop refreshing to go offline"
  410. )
  411. req = presence_pb2.SetPresenceRequest(status=status.value, user_selected=user_selected)
  412. resp = await self._rpc(self._svc.account.set_presence(req, headers=self._headers()))
  413. name = presence_pb2.PresenceStatus.Name(resp.status)
  414. return PresenceStatus(name)
  415. async def get_presence_preference(self) -> PresencePreference | None:
  416. """Read the authenticated account's private saved availability choice.
  417. Returns ``None`` until the account has saved a choice for the first
  418. time. This is the read side of the 0.5.0b6 preference pair; use
  419. :meth:`set_presence_preference` to change it.
  420. """
  421. resp = await self._rpc(
  422. self._svc.account.get_presence_preference(
  423. presence_pb2.GetPresencePreferenceRequest(), headers=self._headers()
  424. )
  425. )
  426. return PresencePreference.parse(pb_to_dict(resp.preference))
  427. async def set_presence_preference(
  428. self,
  429. status: PresenceStatus,
  430. *,
  431. expected_revision: str = "",
  432. ) -> PresencePreference:
  433. """Save an availability choice for every device on this server.
  434. Unlike :meth:`set_presence`, the choice persists across disconnects
  435. and ``OFFLINE`` is a valid value (it means "Appear Offline" and
  436. suppresses public presence and typing). Pass ``expected_revision``
  437. from a prior :meth:`get_presence_preference` to guard against a
  438. concurrent change; a stale revision is rejected with ``ABORTED``.
  439. Leave it empty only when initialising an absent choice.
  440. """
  441. if status is PresenceStatus.UNSPECIFIED:
  442. raise ValueError("UNSPECIFIED cannot be saved as a presence preference")
  443. req = presence_pb2.SetPresencePreferenceRequest(
  444. status=status.value, expected_revision=expected_revision
  445. )
  446. resp = await self._rpc(
  447. self._svc.account.set_presence_preference(req, headers=self._headers())
  448. )
  449. pref = PresencePreference.parse(pb_to_dict(resp.preference))
  450. assert pref is not None
  451. return pref
  452. async def refresh_presence(self) -> PresencePreference | None:
  453. """Refresh connection liveness without changing the saved choice.
  454. Call roughly every 30 seconds to stay "live" (liveness expires after
  455. 60 seconds). Accounts with a saved ``OFFLINE`` choice may refresh
  456. without emitting public presence signals. Returns the current private
  457. choice so a client can recover a choice it missed while disconnected.
  458. """
  459. resp = await self._rpc(
  460. self._svc.account.refresh_presence(
  461. presence_pb2.RefreshPresenceRequest(), headers=self._headers()
  462. )
  463. )
  464. return PresencePreference.parse(pb_to_dict(resp.preference))
  465. async def set_custom_status(
  466. self,
  467. emoji: str,
  468. text: str,
  469. *,
  470. expires_at: datetime | None = None,
  471. ) -> dict[str, Any]:
  472. req = user_status_pb2.SetCustomStatusRequest(emoji=emoji, text=text)
  473. if expires_at is not None:
  474. req.expires_at.CopyFrom(_timestamp_pb(expires_at))
  475. resp = await self._rpc(self._svc.account.set_custom_status(req, headers=self._headers()))
  476. return pb_to_dict(resp)
  477. async def delete_custom_status(self) -> dict[str, Any]:
  478. resp = await self._rpc(
  479. self._svc.account.delete_custom_status(
  480. user_status_pb2.DeleteCustomStatusRequest(), headers=self._headers()
  481. )
  482. )
  483. return pb_to_dict(resp)
  484. # --- Roles (public) -----------------------------------------------
  485. async def list_roles(self) -> list[Role]:
  486. resp = await self._rpc(
  487. self._svc.roles.list_roles(roles_pb2.ListRolesRequest(), headers=self._headers())
  488. )
  489. data = pb_to_dict(resp)
  490. return [r for r in (Role.parse(row) for row in data.get("roles") or []) if r is not None]
  491. async def get_role(self, name: str) -> Role | None:
  492. resp = await self._rpc(
  493. self._svc.roles.get_role(roles_pb2.GetRoleRequest(name=name), headers=self._headers())
  494. )
  495. return Role.parse(pb_to_dict(resp.role))
  496. async def batch_get_roles(self, names: list[str]) -> list[Role]:
  497. resp = await self._rpc(
  498. self._svc.roles.batch_get_roles(
  499. roles_pb2.BatchGetRolesRequest(names=names), headers=self._headers()
  500. )
  501. )
  502. data = pb_to_dict(resp)
  503. return [r for r in (Role.parse(row) for row in data.get("roles") or []) if r is not None]
  504. # --- Room directory ------------------------------------------------
  505. async def list_rooms(
  506. self, scope: RoomDirectoryScope = RoomDirectoryScope.ALL
  507. ) -> list[RoomWithViewerState]:
  508. resp = await self._rpc(
  509. self._svc.room_directory.list_rooms(
  510. room_directory_pb2.ListRoomsRequest(scope=scope.value),
  511. headers=self._headers(),
  512. )
  513. )
  514. data = pb_to_dict(resp)
  515. return [
  516. r
  517. for r in (RoomWithViewerState.parse(row) for row in data.get("rooms") or [])
  518. if r is not None
  519. ]
  520. async def list_room_groups(self) -> list[RoomGroup]:
  521. resp = await self._rpc(
  522. self._svc.room_directory.list_room_groups(
  523. room_directory_pb2.ListRoomGroupsRequest(),
  524. headers=self._headers(),
  525. )
  526. )
  527. data = pb_to_dict(resp)
  528. return [
  529. g for g in (RoomGroup.parse(row) for row in data.get("groups") or []) if g is not None
  530. ]
  531. async def get_room_group(self, group_id: str) -> RoomGroup | None:
  532. resp = await self._rpc(
  533. self._svc.room_directory.get_room_group(
  534. room_directory_pb2.GetRoomGroupRequest(group_id=group_id),
  535. headers=self._headers(),
  536. )
  537. )
  538. return RoomGroup.parse(pb_to_dict(resp.group))
  539. async def batch_get_room_groups(self, group_ids: list[str]) -> list[RoomGroup]:
  540. resp = await self._rpc(
  541. self._svc.room_directory.batch_get_room_groups(
  542. room_directory_pb2.BatchGetRoomGroupsRequest(group_ids=group_ids),
  543. headers=self._headers(),
  544. )
  545. )
  546. data = pb_to_dict(resp)
  547. return [
  548. g for g in (RoomGroup.parse(row) for row in data.get("groups") or []) if g is not None
  549. ]
  550. async def get_room(self, room_id: str) -> RoomWithViewerState | None:
  551. resp = await self._rpc(
  552. self._svc.room_directory.get_room(
  553. room_directory_pb2.GetRoomRequest(room_id=room_id),
  554. headers=self._headers(),
  555. )
  556. )
  557. return RoomWithViewerState.parse(pb_to_dict(resp.room))
  558. async def batch_get_rooms(self, room_ids: list[str]) -> list[RoomWithViewerState]:
  559. resp = await self._rpc(
  560. self._svc.room_directory.batch_get_rooms(
  561. room_directory_pb2.BatchGetRoomsRequest(room_ids=room_ids),
  562. headers=self._headers(),
  563. )
  564. )
  565. data = pb_to_dict(resp)
  566. return [
  567. r
  568. for r in (RoomWithViewerState.parse(row) for row in data.get("rooms") or [])
  569. if r is not None
  570. ]
  571. # --- Room lifecycle & membership -----------------------------------
  572. async def create_room(
  573. self,
  574. name: str,
  575. group_id: str,
  576. *,
  577. description: str = "",
  578. universal: bool = False,
  579. threading_mode: RoomThreadingMode | None = None,
  580. ) -> Room:
  581. req = rooms_pb2.CreateRoomRequest(
  582. name=name,
  583. group_id=group_id,
  584. description=description,
  585. universal=universal,
  586. )
  587. if threading_mode is not None:
  588. req.threading_mode = threading_mode.value
  589. resp = await self._rpc(self._svc.rooms.create_room(req, headers=self._headers()))
  590. room = Room.parse(pb_to_dict(resp.room))
  591. assert room is not None
  592. return room
  593. async def update_room(
  594. self,
  595. room_id: str,
  596. *,
  597. name: str | None = None,
  598. description: str | None = None,
  599. universal: bool | None = None,
  600. slow_mode_seconds: int | None = None,
  601. threading_mode: RoomThreadingMode | None = None,
  602. ) -> Room:
  603. req = rooms_pb2.UpdateRoomRequest(room_id=room_id)
  604. if name is not None:
  605. req.name = name
  606. if description is not None:
  607. req.description = description
  608. if universal is not None:
  609. req.universal = universal
  610. if slow_mode_seconds is not None:
  611. req.slow_mode_seconds = slow_mode_seconds
  612. if threading_mode is not None:
  613. req.threading_mode = threading_mode.value
  614. resp = await self._rpc(self._svc.rooms.update_room(req, headers=self._headers()))
  615. room = Room.parse(pb_to_dict(resp.room))
  616. assert room is not None
  617. return room
  618. async def archive_room(self, room_id: str) -> Room:
  619. resp = await self._rpc(
  620. self._svc.rooms.archive_room(
  621. rooms_pb2.ArchiveRoomRequest(room_id=room_id),
  622. headers=self._headers(),
  623. )
  624. )
  625. room = Room.parse(pb_to_dict(resp.room))
  626. assert room is not None
  627. return room
  628. async def unarchive_room(self, room_id: str) -> Room:
  629. resp = await self._rpc(
  630. self._svc.rooms.unarchive_room(
  631. rooms_pb2.UnarchiveRoomRequest(room_id=room_id),
  632. headers=self._headers(),
  633. )
  634. )
  635. room = Room.parse(pb_to_dict(resp.room))
  636. assert room is not None
  637. return room
  638. async def join_room(self, room_id: str) -> Room:
  639. resp = await self._rpc(
  640. self._svc.rooms.join_room(
  641. rooms_pb2.JoinRoomRequest(room_id=room_id),
  642. headers=self._headers(),
  643. )
  644. )
  645. room = Room.parse(pb_to_dict(resp.room))
  646. assert room is not None
  647. return room
  648. async def join_room_group(self, group_id: str) -> list[str]:
  649. resp = await self._rpc(
  650. self._svc.rooms.join_room_group(
  651. rooms_pb2.JoinRoomGroupRequest(group_id=group_id),
  652. headers=self._headers(),
  653. )
  654. )
  655. return list(resp.joined_room_ids)
  656. async def start_dm(self, participant_ids: list[str]) -> Room:
  657. resp = await self._rpc(
  658. self._svc.rooms.start_dm(
  659. rooms_pb2.StartDMRequest(participant_ids=participant_ids),
  660. headers=self._headers(),
  661. )
  662. )
  663. room = Room.parse(pb_to_dict(resp.room))
  664. assert room is not None
  665. return room
  666. async def leave_room(self, room_id: str) -> None:
  667. await self._rpc(
  668. self._svc.rooms.leave_room(
  669. rooms_pb2.LeaveRoomRequest(room_id=room_id),
  670. headers=self._headers(),
  671. )
  672. )
  673. async def add_member(self, room_id: str, user_id: str) -> DirectoryMember | None:
  674. resp = await self._rpc(
  675. self._svc.rooms.add_member(
  676. rooms_pb2.AddMemberRequest(room_id=room_id, user_id=user_id),
  677. headers=self._headers(),
  678. )
  679. )
  680. return DirectoryMember.parse(pb_to_dict(resp.member))
  681. async def remove_member(self, room_id: str, user_id: str) -> bool:
  682. resp = await self._rpc(
  683. self._svc.rooms.remove_member(
  684. rooms_pb2.RemoveMemberRequest(room_id=room_id, user_id=user_id),
  685. headers=self._headers(),
  686. )
  687. )
  688. return resp.removed
  689. async def list_room_members(
  690. self,
  691. room_id: str,
  692. *,
  693. search: str = "",
  694. presence_statuses: list[PresenceStatus] | None = None,
  695. limit: int | None = None,
  696. offset: int | None = None,
  697. ) -> tuple[list[str], Page]:
  698. """List a room's member IDs (plus page metadata).
  699. The server returns member IDs only, not full profiles; hydrate them
  700. with :meth:`batch_get_room_members`. ``presence_statuses`` filters to
  701. members in any of the given connected states (max 4).
  702. """
  703. req = member_directory_pb2.ListMembersRequest(room_id=room_id, search=search)
  704. if presence_statuses:
  705. req.presence_statuses.extend(
  706. presence_pb2.PresenceStatus.Value(p.value) for p in presence_statuses
  707. )
  708. page = _page_pb(limit, offset)
  709. if page is not None:
  710. req.page.CopyFrom(page)
  711. resp = await self._rpc(self._svc.rooms.list_members(req, headers=self._headers()))
  712. data = pb_to_dict(resp)
  713. return list(data.get("userIds") or []), Page.parse(data.get("page"))
  714. async def get_room_member(self, room_id: str, user_id: str) -> DirectoryMember | None:
  715. resp = await self._rpc(
  716. self._svc.rooms.get_member(
  717. member_directory_pb2.GetMemberRequest(room_id=room_id, user_id=user_id),
  718. headers=self._headers(),
  719. )
  720. )
  721. return DirectoryMember.parse(pb_to_dict(resp.member))
  722. async def batch_get_room_members(
  723. self, room_id: str, user_ids: list[str]
  724. ) -> list[DirectoryMember]:
  725. resp = await self._rpc(
  726. self._svc.rooms.batch_get_members(
  727. member_directory_pb2.BatchGetMembersRequest(room_id=room_id, user_ids=user_ids),
  728. headers=self._headers(),
  729. )
  730. )
  731. data = pb_to_dict(resp)
  732. return [
  733. m
  734. for m in (DirectoryMember.parse(row) for row in data.get("members") or [])
  735. if m is not None
  736. ]
  737. async def remove_user(
  738. self,
  739. room_id: str,
  740. user_id: str,
  741. reason: str,
  742. *,
  743. suspension_expires_at: datetime | None = None,
  744. suspend_indefinitely: bool = False,
  745. ) -> None:
  746. """Remove a current room member, optionally suspending them.
  747. Maps to ``RoomService.RemoveUser`` (the 0.5.0b7 replacement for
  748. ``BanMember``). Without a suspension the user may rejoin under normal
  749. room permissions; with one, they cannot rejoin until the suspension
  750. expires or ``lift_suspension`` is called. At most one of the two
  751. suspension choices may be given.
  752. """
  753. if suspend_indefinitely and suspension_expires_at is not None:
  754. raise ValueError(
  755. "choose either suspension_expires_at or suspend_indefinitely, not both"
  756. )
  757. req = rooms_pb2.RemoveUserRequest(room_id=room_id, user_id=user_id, reason=reason)
  758. if suspend_indefinitely:
  759. req.suspend_indefinitely = True
  760. elif suspension_expires_at is not None:
  761. req.suspension_expires_at.CopyFrom(_timestamp_pb(suspension_expires_at))
  762. await self._rpc(self._svc.rooms.remove_user(req, headers=self._headers()))
  763. async def lift_suspension(self, room_id: str, user_id: str, reason: str) -> None:
  764. await self._rpc(
  765. self._svc.rooms.lift_suspension(
  766. rooms_pb2.LiftSuspensionRequest(room_id=room_id, user_id=user_id, reason=reason),
  767. headers=self._headers(),
  768. )
  769. )
  770. async def list_suspensions(
  771. self,
  772. *,
  773. room_id: str = "",
  774. limit: int | None = None,
  775. offset: int | None = None,
  776. ) -> tuple[list[RoomSuspension], Page]:
  777. req = rooms_pb2.ListSuspensionsRequest(room_id=room_id)
  778. page = _page_pb(limit, offset)
  779. if page is not None:
  780. req.page.CopyFrom(page)
  781. resp = await self._rpc(self._svc.rooms.list_suspensions(req, headers=self._headers()))
  782. data = pb_to_dict(resp)
  783. suspensions = [
  784. s
  785. for s in (RoomSuspension.parse(row) for row in data.get("suspensions") or [])
  786. if s is not None
  787. ]
  788. return suspensions, Page.parse(data.get("page"))
  789. async def refresh_typing_indicator(
  790. self, room_id: str, *, thread_root_event_id: str = ""
  791. ) -> None:
  792. await self._rpc(
  793. self._svc.rooms.refresh_typing_indicator(
  794. rooms_pb2.RefreshTypingIndicatorRequest(
  795. room_id=room_id, thread_root_event_id=thread_root_event_id
  796. ),
  797. headers=self._headers(),
  798. )
  799. )
  800. # --- Room timeline / read state -----------------------------------
  801. async def get_room_events(
  802. self,
  803. room_id: str,
  804. *,
  805. limit: int | None = None,
  806. before: str | None = None,
  807. after: str | None = None,
  808. ) -> TimelinePage:
  809. req = room_timeline_pb2.GetRoomEventsRequest(room_id=room_id)
  810. if limit is not None:
  811. req.limit = limit
  812. if before is not None:
  813. req.before = before
  814. elif after is not None:
  815. req.after = after
  816. resp = await self._rpc(self._svc.rooms.get_room_events(req, headers=self._headers()))
  817. return TimelinePage.parse(pb_to_dict(resp.page))
  818. async def get_room_events_around(
  819. self,
  820. room_id: str,
  821. event_id: str,
  822. *,
  823. limit: int | None = None,
  824. ) -> tuple[TimelinePage, int]:
  825. req = room_timeline_pb2.GetRoomEventsAroundRequest(room_id=room_id, event_id=event_id)
  826. if limit is not None:
  827. req.limit = limit
  828. resp = await self._rpc(self._svc.rooms.get_room_events_around(req, headers=self._headers()))
  829. return TimelinePage.parse(pb_to_dict(resp.page)), resp.target_index
  830. async def mark_room_as_read(
  831. self, room_id: str, up_to_event_id: str = ""
  832. ) -> tuple[datetime | None, datetime | None]:
  833. resp = await self._rpc(
  834. self._svc.rooms.mark_room_as_read(
  835. read_state_pb2.MarkRoomAsReadRequest(
  836. room_id=room_id, up_to_event_id=up_to_event_id
  837. ),
  838. headers=self._headers(),
  839. )
  840. )
  841. d = pb_to_dict(resp)
  842. return (
  843. parse_datetime(d.get("lastReadAt")),
  844. parse_datetime(d.get("previousLastReadAt")),
  845. )
  846. async def list_room_attachments(
  847. self,
  848. room_id: str,
  849. *,
  850. thumbnail: ImageTransformOptions | None = None,
  851. limit: int | None = None,
  852. offset: int | None = None,
  853. ) -> tuple[list[dict[str, Any]], Page]:
  854. req = rooms_pb2.ListRoomAttachmentsRequest(room_id=room_id)
  855. thumb = _thumbnail_pb(thumbnail)
  856. if thumb is not None:
  857. req.thumbnail.CopyFrom(thumb)
  858. page = _page_pb(limit, offset)
  859. if page is not None:
  860. req.page.CopyFrom(page)
  861. resp = await self._rpc(self._svc.rooms.list_room_attachments(req, headers=self._headers()))
  862. data = pb_to_dict(resp)
  863. return list(data.get("attachments") or []), Page.parse(data.get("page"))
  864. async def list_pinned_messages(
  865. self,
  866. room_id: str,
  867. *,
  868. limit: int | None = None,
  869. offset: int | None = None,
  870. ) -> PinnedMessagesPage:
  871. req = rooms_pb2.ListPinnedMessagesRequest(room_id=room_id)
  872. page = _page_pb(limit, offset)
  873. if page is not None:
  874. req.page.CopyFrom(page)
  875. resp = await self._rpc(self._svc.rooms.list_pinned_messages(req, headers=self._headers()))
  876. return PinnedMessagesPage.parse(pb_to_dict(resp))
  877. async def pin_message(self, room_id: str, message_event_id: str) -> PinnedMessage:
  878. resp = await self._rpc(
  879. self._svc.rooms.create_pinned_message(
  880. rooms_pb2.CreatePinnedMessageRequest(
  881. room_id=room_id, message_event_id=message_event_id
  882. ),
  883. headers=self._headers(),
  884. )
  885. )
  886. pm = PinnedMessage.parse(pb_to_dict(resp.pinned_message))
  887. assert pm is not None
  888. return pm
  889. async def unpin_message(self, room_id: str, message_event_id: str) -> bool:
  890. resp = await self._rpc(
  891. self._svc.rooms.delete_pinned_message(
  892. rooms_pb2.DeletePinnedMessageRequest(
  893. room_id=room_id, message_event_id=message_event_id
  894. ),
  895. headers=self._headers(),
  896. )
  897. )
  898. return resp.deleted
  899. # --- Messages -------------------------------------------------------
  900. async def fetch_link_preview(self, url: str) -> tuple[LinkPreview | None, str]:
  901. resp = await self._rpc(
  902. self._svc.messages.fetch_link_preview(
  903. link_previews_pb2.FetchLinkPreviewRequest(url=url),
  904. headers=self._headers(),
  905. )
  906. )
  907. return LinkPreview.parse(pb_to_dict(resp.preview)), resp.preview_token
  908. async def post_message(
  909. self,
  910. room_id: str,
  911. body: str = "",
  912. *,
  913. attachment_asset_ids: list[str] | None = None,
  914. thread_root_event_id: str = "",
  915. in_reply_to: str = "",
  916. also_send_to_channel: bool = False,
  917. link_preview_token: str = "",
  918. ) -> Message:
  919. req = messages_pb2.CreateMessageRequest(
  920. room_id=room_id,
  921. body=body,
  922. thread_root_event_id=thread_root_event_id,
  923. in_reply_to=in_reply_to,
  924. also_send_to_channel=also_send_to_channel,
  925. link_preview_token=link_preview_token,
  926. )
  927. if attachment_asset_ids:
  928. req.attachment_asset_ids.extend(attachment_asset_ids)
  929. resp = await self._rpc(self._svc.messages.create_message(req, headers=self._headers()))
  930. message = Message.parse(pb_to_dict(resp.message))
  931. assert message is not None
  932. return message
  933. async def update_message(
  934. self,
  935. room_id: str,
  936. event_id: str,
  937. *,
  938. body: str | None = None,
  939. also_send_to_channel: bool | None = None,
  940. ) -> Message:
  941. req = messages_pb2.UpdateMessageRequest(room_id=room_id, event_id=event_id)
  942. if body is not None:
  943. req.body = body
  944. if also_send_to_channel is not None:
  945. req.also_send_to_channel = also_send_to_channel
  946. resp = await self._rpc(self._svc.messages.update_message(req, headers=self._headers()))
  947. message = Message.parse(pb_to_dict(resp.message))
  948. assert message is not None
  949. return message
  950. async def delete_message(self, room_id: str, event_id: str) -> None:
  951. await self._rpc(
  952. self._svc.messages.delete_message(
  953. messages_pb2.DeleteMessageRequest(room_id=room_id, event_id=event_id),
  954. headers=self._headers(),
  955. )
  956. )
  957. async def delete_attachment(self, room_id: str, event_id: str, attachment_id: str) -> None:
  958. await self._rpc(
  959. self._svc.messages.delete_attachment(
  960. messages_pb2.DeleteAttachmentRequest(
  961. room_id=room_id, event_id=event_id, attachment_id=attachment_id
  962. ),
  963. headers=self._headers(),
  964. )
  965. )
  966. async def delete_link_preview(self, room_id: str, event_id: str, url: str) -> None:
  967. await self._rpc(
  968. self._svc.messages.delete_link_preview(
  969. messages_pb2.DeleteLinkPreviewRequest(room_id=room_id, event_id=event_id, url=url),
  970. headers=self._headers(),
  971. )
  972. )
  973. async def get_message(self, room_id: str, event_id: str) -> Message | None:
  974. resp = await self._rpc(
  975. self._svc.messages.get_message(
  976. messages_pb2.GetMessageRequest(room_id=room_id, event_id=event_id),
  977. headers=self._headers(),
  978. )
  979. )
  980. return Message.parse(pb_to_dict(resp.message))
  981. async def batch_get_messages(self, room_id: str, event_ids: list[str]) -> list[Message]:
  982. resp = await self._rpc(
  983. self._svc.messages.batch_get_messages(
  984. messages_pb2.BatchGetMessagesRequest(room_id=room_id, event_ids=event_ids),
  985. headers=self._headers(),
  986. )
  987. )
  988. data = pb_to_dict(resp)
  989. return [
  990. m for m in (Message.parse(row) for row in data.get("messages") or []) if m is not None
  991. ]
  992. async def add_reaction(self, room_id: str, message_event_id: str, emoji: str) -> bool:
  993. resp = await self._rpc(
  994. self._svc.messages.add_reaction(
  995. reactions_pb2.AddReactionRequest(
  996. room_id=room_id, message_event_id=message_event_id, emoji=emoji
  997. ),
  998. headers=self._headers(),
  999. )
  1000. )
  1001. return resp.added
  1002. async def remove_reaction(self, room_id: str, message_event_id: str, emoji: str) -> bool:
  1003. resp = await self._rpc(
  1004. self._svc.messages.remove_reaction(
  1005. reactions_pb2.RemoveReactionRequest(
  1006. room_id=room_id, message_event_id=message_event_id, emoji=emoji
  1007. ),
  1008. headers=self._headers(),
  1009. )
  1010. )
  1011. return resp.removed
  1012. # --- Threads --------------------------------------------------------
  1013. async def follow_thread(self, room_id: str, thread_root_event_id: str) -> None:
  1014. await self._rpc(
  1015. self._svc.threads.follow_thread(
  1016. threads_pb2.FollowThreadRequest(
  1017. room_id=room_id, thread_root_event_id=thread_root_event_id
  1018. ),
  1019. headers=self._headers(),
  1020. )
  1021. )
  1022. async def unfollow_thread(self, room_id: str, thread_root_event_id: str) -> None:
  1023. await self._rpc(
  1024. self._svc.threads.unfollow_thread(
  1025. threads_pb2.UnfollowThreadRequest(
  1026. room_id=room_id, thread_root_event_id=thread_root_event_id
  1027. ),
  1028. headers=self._headers(),
  1029. )
  1030. )
  1031. async def list_followed_threads(
  1032. self,
  1033. *,
  1034. limit: int | None = None,
  1035. offset: int | None = None,
  1036. include_direct_message_threads: bool | None = None,
  1037. unread_only: bool | None = None,
  1038. root_author_filter: ThreadRootAuthorFilter | None = None,
  1039. ) -> FollowedThreadsPage:
  1040. """Followed threads, newest activity first.
  1041. ``unread_only`` returns only threads with unread replies (older
  1042. servers ignore it); ``include_direct_message_threads`` also lists
  1043. followed direct-message threads; ``root_author_filter`` (since
  1044. 0.5.0b16) filters by the author of the thread *root* —
  1045. ``VIEWER`` for threads you started, ``OTHERS`` for threads started
  1046. by someone else (older servers ignore it).
  1047. """
  1048. req = threads_pb2.ListFollowedThreadsRequest()
  1049. page = _page_pb(limit, offset)
  1050. if page is not None:
  1051. req.page.CopyFrom(page)
  1052. if include_direct_message_threads is not None:
  1053. req.include_direct_message_threads = include_direct_message_threads
  1054. if unread_only is not None:
  1055. req.unread_only = unread_only
  1056. if root_author_filter is not None:
  1057. req.root_author_filter = threads_pb2.ThreadRootAuthorFilter.Value(
  1058. root_author_filter.value
  1059. )
  1060. resp = await self._rpc(
  1061. self._svc.threads.list_followed_threads(req, headers=self._headers())
  1062. )
  1063. data = pb_to_dict(resp)
  1064. threads = [FollowedThread.parse(t) for t in data.get("threads") or []]
  1065. users: dict[str, User] = {}
  1066. includes = data.get("includes") or {}
  1067. for uid, user_data in (includes.get("users") or {}).items():
  1068. parsed = User.parse(user_data)
  1069. if parsed is not None:
  1070. users[uid] = parsed
  1071. return FollowedThreadsPage(
  1072. threads=threads, page=Page.parse(data.get("page")), users_by_id=users
  1073. )
  1074. async def get_thread_events(
  1075. self,
  1076. room_id: str,
  1077. thread_root_event_id: str,
  1078. *,
  1079. limit: int | None = None,
  1080. before: str | None = None,
  1081. after: str | None = None,
  1082. ) -> TimelinePage:
  1083. req = room_timeline_pb2.GetThreadEventsRequest(
  1084. room_id=room_id, thread_root_event_id=thread_root_event_id
  1085. )
  1086. if limit is not None:
  1087. req.limit = limit
  1088. if before is not None:
  1089. req.before = before
  1090. elif after is not None:
  1091. req.after = after
  1092. resp = await self._rpc(self._svc.threads.get_thread_events(req, headers=self._headers()))
  1093. return TimelinePage.parse(pb_to_dict(resp.page))
  1094. async def get_thread_events_around(
  1095. self,
  1096. room_id: str,
  1097. thread_root_event_id: str,
  1098. event_id: str,
  1099. *,
  1100. limit: int | None = None,
  1101. ) -> tuple[TimelinePage, int]:
  1102. req = room_timeline_pb2.GetThreadEventsAroundRequest(
  1103. room_id=room_id,
  1104. thread_root_event_id=thread_root_event_id,
  1105. event_id=event_id,
  1106. )
  1107. if limit is not None:
  1108. req.limit = limit
  1109. resp = await self._rpc(
  1110. self._svc.threads.get_thread_events_around(req, headers=self._headers())
  1111. )
  1112. return TimelinePage.parse(pb_to_dict(resp.page)), resp.target_index
  1113. async def mark_thread_as_read(
  1114. self,
  1115. room_id: str,
  1116. thread_root_event_id: str,
  1117. up_to_event_id: str = "",
  1118. ) -> datetime | None:
  1119. resp = await self._rpc(
  1120. self._svc.threads.mark_thread_as_read(
  1121. read_state_pb2.MarkThreadAsReadRequest(
  1122. room_id=room_id,
  1123. thread_root_event_id=thread_root_event_id,
  1124. up_to_event_id=up_to_event_id,
  1125. ),
  1126. headers=self._headers(),
  1127. )
  1128. )
  1129. d = pb_to_dict(resp)
  1130. return parse_datetime(d.get("previousLastReadAt"))
  1131. # --- Notifications --------------------------------------------------
  1132. async def list_notification_occurrences(
  1133. self, *, limit: int | None = None, offset: int | None = None
  1134. ) -> NotificationOccurrencesPage:
  1135. req = notifications_pb2.ListNotificationOccurrencesRequest()
  1136. page = _page_pb(limit, offset)
  1137. if page is not None:
  1138. req.page.CopyFrom(page)
  1139. resp = await self._rpc(
  1140. self._svc.notifications.list_notification_occurrences(req, headers=self._headers())
  1141. )
  1142. return NotificationOccurrencesPage.parse(pb_to_dict(resp))
  1143. async def get_notification_occurrence(
  1144. self, notification_id: str
  1145. ) -> NotificationOccurrence | None:
  1146. resp = await self._rpc(
  1147. self._svc.notifications.get_notification_occurrence(
  1148. notifications_pb2.GetNotificationOccurrenceRequest(notification_id=notification_id),
  1149. headers=self._headers(),
  1150. )
  1151. )
  1152. raw = pb_to_dict(resp).get("occurrence")
  1153. return NotificationOccurrence.parse(raw) if raw else None
  1154. async def batch_get_notification_occurrences(
  1155. self, notification_ids: list[str]
  1156. ) -> list[NotificationOccurrence]:
  1157. resp = await self._rpc(
  1158. self._svc.notifications.batch_get_notification_occurrences(
  1159. notifications_pb2.BatchGetNotificationOccurrencesRequest(
  1160. notification_ids=notification_ids
  1161. ),
  1162. headers=self._headers(),
  1163. )
  1164. )
  1165. data = pb_to_dict(resp)
  1166. return [
  1167. o
  1168. for o in (NotificationOccurrence.parse(n) for n in data.get("occurrences") or [])
  1169. if o is not None
  1170. ]
  1171. async def mark_notification_read(self, notification_id: str) -> NotificationOccurrence | None:
  1172. resp = await self._rpc(
  1173. self._svc.notifications.mark_notification_read(
  1174. notifications_pb2.MarkNotificationReadRequest(notification_id=notification_id),
  1175. headers=self._headers(),
  1176. )
  1177. )
  1178. raw = pb_to_dict(resp).get("occurrence")
  1179. return NotificationOccurrence.parse(raw) if raw else None
  1180. async def delete_notification_occurrence(self, notification_id: str) -> bool:
  1181. resp = await self._rpc(
  1182. self._svc.notifications.delete_notification_occurrence(
  1183. notifications_pb2.DeleteNotificationOccurrenceRequest(
  1184. notification_id=notification_id
  1185. ),
  1186. headers=self._headers(),
  1187. )
  1188. )
  1189. return resp.deleted
  1190. async def batch_delete_notification_occurrences(self, notification_ids: list[str]) -> int:
  1191. resp = await self._rpc(
  1192. self._svc.notifications.batch_delete_notification_occurrences(
  1193. notifications_pb2.BatchDeleteNotificationOccurrencesRequest(
  1194. notification_ids=notification_ids
  1195. ),
  1196. headers=self._headers(),
  1197. )
  1198. )
  1199. return resp.deleted_count
  1200. async def delete_all_notification_occurrences(self) -> int:
  1201. resp = await self._rpc(
  1202. self._svc.notifications.delete_all_notification_occurrences(
  1203. notifications_pb2.DeleteAllNotificationOccurrencesRequest(),
  1204. headers=self._headers(),
  1205. )
  1206. )
  1207. return resp.deleted_count
  1208. async def get_notification_policy(
  1209. self, *, room_id: str = "", room_group_id: str = ""
  1210. ) -> NotificationPolicy:
  1211. """Get the notification policy for one scope (server / group / room)."""
  1212. req = notifications_pb2.NotificationPolicyServiceGetNotificationPolicyRequest(
  1213. scope=_policy_scope(room_id=room_id, room_group_id=room_group_id)
  1214. )
  1215. resp = await self._rpc(
  1216. self._svc.notification_policy.get_notification_policy(req, headers=self._headers())
  1217. )
  1218. return NotificationPolicy.parse(pb_to_dict(resp.policy.policy))
  1219. async def batch_get_notification_policies(
  1220. self, scopes: list[dict[str, str]]
  1221. ) -> list[NotificationPolicy]:
  1222. """Get a bounded set of notification policies (one per scope).
  1223. Each scope is a dict with ``room_id`` and/or ``room_group_id`` (empty
  1224. dict selects the server scope).
  1225. """
  1226. req = notifications_pb2.BatchGetNotificationPoliciesRequest()
  1227. for scope in scopes:
  1228. req.scopes.add().CopyFrom(
  1229. _policy_scope(
  1230. room_id=scope.get("room_id", ""),
  1231. room_group_id=scope.get("room_group_id", ""),
  1232. )
  1233. )
  1234. resp = await self._rpc(
  1235. self._svc.notification_policy.batch_get_notification_policies(
  1236. req, headers=self._headers()
  1237. )
  1238. )
  1239. return [NotificationPolicy.parse(pb_to_dict(p.policy)) for p in resp.policies]
  1240. async def update_notification_policy(
  1241. self,
  1242. *,
  1243. room_id: str = "",
  1244. room_group_id: str = "",
  1245. overrides: dict[str, Any] | None = None,
  1246. update_mask: str | None = None,
  1247. ) -> NotificationPolicy:
  1248. """Sparsely set notification-policy delivery-mode overrides at one scope.
  1249. ``overrides`` maps delivery-mode fields (e.g. ``direct_mentions``,
  1250. ``all_mentions``) to their mode names; ``update_mask`` lists the fields
  1251. to set or clear (``*`` selects all).
  1252. """
  1253. req = notifications_pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest(
  1254. scope=_policy_scope(room_id=room_id, room_group_id=room_group_id)
  1255. )
  1256. if overrides:
  1257. from google.protobuf.json_format import ParseDict
  1258. ParseDict(overrides, req.overrides)
  1259. if update_mask:
  1260. req.update_mask.paths.extend(p for p in update_mask.split(",") if p)
  1261. resp = await self._rpc(
  1262. self._svc.notification_policy.update_notification_policy(req, headers=self._headers())
  1263. )
  1264. return NotificationPolicy.parse(pb_to_dict(resp.policy.policy))
  1265. # --- Push notifications --------------------------------------------
  1266. async def subscribe_push(
  1267. self,
  1268. endpoint: str,
  1269. p256dh: str,
  1270. auth: str,
  1271. *,
  1272. user_agent: str | None = None,
  1273. ) -> bool:
  1274. req = push_notifications_pb2.SubscribeRequest(endpoint=endpoint, p256dh=p256dh, auth=auth)
  1275. if user_agent is not None:
  1276. req.user_agent = user_agent
  1277. await self._rpc(self._svc.push.subscribe(req, headers=self._headers()))
  1278. return True
  1279. async def unsubscribe_push(self, endpoint: str) -> bool:
  1280. await self._rpc(
  1281. self._svc.push.unsubscribe(
  1282. push_notifications_pb2.UnsubscribeRequest(endpoint=endpoint),
  1283. headers=self._headers(),
  1284. )
  1285. )
  1286. return True
  1287. # --- Assets ---------------------------------------------------------
  1288. async def get_asset(
  1289. self,
  1290. room_id: str,
  1291. asset_id: str,
  1292. *,
  1293. thumbnail: ImageTransformOptions | None = None,
  1294. ) -> Asset | None:
  1295. req = attachments_pb2.GetAssetRequest(room_id=room_id, asset_id=asset_id)
  1296. thumb = _thumbnail_pb(thumbnail)
  1297. if thumb is not None:
  1298. req.thumbnail.CopyFrom(thumb)
  1299. resp = await self._rpc(self._svc.assets.get_asset(req, headers=self._headers()))
  1300. return Asset.parse(pb_to_dict(resp.asset))
  1301. async def batch_get_assets(
  1302. self,
  1303. room_id: str,
  1304. asset_ids: list[str],
  1305. *,
  1306. thumbnail: ImageTransformOptions | None = None,
  1307. ) -> list[Asset]:
  1308. req = attachments_pb2.BatchGetAssetsRequest(room_id=room_id, asset_ids=asset_ids)
  1309. thumb = _thumbnail_pb(thumbnail)
  1310. if thumb is not None:
  1311. req.thumbnail.CopyFrom(thumb)
  1312. resp = await self._rpc(self._svc.assets.batch_get_assets(req, headers=self._headers()))
  1313. data = pb_to_dict(resp)
  1314. return [a for a in (Asset.parse(row) for row in data.get("assets") or []) if a is not None]
  1315. # --- Asset uploads ------------------------------------------------
  1316. async def create_upload(
  1317. self,
  1318. room_id: str,
  1319. filename: str,
  1320. size: int,
  1321. sha256: str,
  1322. *,
  1323. content_type: str = "",
  1324. ) -> AssetUpload:
  1325. resp = await self._rpc(
  1326. self._svc.asset_uploads.create_upload(
  1327. asset_uploads_pb2.CreateUploadRequest(
  1328. room_id=room_id,
  1329. filename=filename,
  1330. content_type=content_type,
  1331. size=size,
  1332. sha256=sha256,
  1333. ),
  1334. headers=self._headers(),
  1335. )
  1336. )
  1337. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1338. assert upload is not None
  1339. return upload
  1340. async def upload_chunk(
  1341. self, upload_id: str, offset: int, content: bytes, chunk_sha256: str
  1342. ) -> AssetUpload:
  1343. resp = await self._rpc(
  1344. self._svc.asset_uploads.upload_chunk(
  1345. asset_uploads_pb2.UploadChunkRequest(
  1346. upload_id=upload_id,
  1347. offset=offset,
  1348. content=content,
  1349. chunk_sha256=chunk_sha256,
  1350. ),
  1351. headers=self._headers(),
  1352. )
  1353. )
  1354. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1355. assert upload is not None
  1356. return upload
  1357. async def get_upload(self, upload_id: str) -> AssetUpload:
  1358. resp = await self._rpc(
  1359. self._svc.asset_uploads.get_upload(
  1360. asset_uploads_pb2.GetUploadRequest(upload_id=upload_id),
  1361. headers=self._headers(),
  1362. )
  1363. )
  1364. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1365. assert upload is not None
  1366. return upload
  1367. async def complete_upload(self, upload_id: str) -> tuple[AssetUpload, Asset | None]:
  1368. resp = await self._rpc(
  1369. self._svc.asset_uploads.complete_upload(
  1370. asset_uploads_pb2.CompleteUploadRequest(upload_id=upload_id),
  1371. headers=self._headers(),
  1372. )
  1373. )
  1374. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1375. assert upload is not None
  1376. return upload, Asset.parse(pb_to_dict(resp.asset))
  1377. async def cancel_upload(self, upload_id: str) -> AssetUpload:
  1378. resp = await self._rpc(
  1379. self._svc.asset_uploads.cancel_upload(
  1380. asset_uploads_pb2.CancelUploadRequest(upload_id=upload_id),
  1381. headers=self._headers(),
  1382. )
  1383. )
  1384. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1385. assert upload is not None
  1386. return upload
  1387. async def upload_attachment(
  1388. self,
  1389. room_id: str,
  1390. file_path: str | Path,
  1391. *,
  1392. content_type: str = "",
  1393. filename: str | None = None,
  1394. ) -> Asset:
  1395. """Upload a file as a room attachment and return the resulting Asset."""
  1396. path = Path(file_path)
  1397. data = path.read_bytes()
  1398. size = len(data)
  1399. sha = hashlib.sha256(data).hexdigest()
  1400. upload = await self.create_upload(
  1401. room_id, filename or path.name, size, sha, content_type=content_type
  1402. )
  1403. chunk_size = upload.max_chunk_size or 512 * 1024
  1404. offset = upload.committed_offset
  1405. while offset < size:
  1406. end = min(offset + chunk_size, size)
  1407. chunk = data[offset:end]
  1408. chunk_sha = hashlib.sha256(chunk).hexdigest()
  1409. upload = await self.upload_chunk(upload.upload_id, offset, chunk, chunk_sha)
  1410. if upload.committed_offset <= offset:
  1411. raise ChattoError(
  1412. f"upload stalled at offset {offset} (server reported "
  1413. f"committed_offset={upload.committed_offset})"
  1414. )
  1415. offset = upload.committed_offset
  1416. upload, asset = await self.complete_upload(upload.upload_id)
  1417. if asset is None:
  1418. raise ChattoError("upload completed but server returned no asset")
  1419. return asset
  1420. # --- Voice calls ----------------------------------------------------
  1421. async def list_active_calls(self) -> list[ActiveCall]:
  1422. resp = await self._rpc(
  1423. self._svc.voice_calls.list_active_calls(
  1424. voice_calls_pb2.ListActiveCallsRequest(), headers=self._headers()
  1425. )
  1426. )
  1427. data = pb_to_dict(resp)
  1428. return [ActiveCall.parse(c) for c in data.get("calls") or []]
  1429. async def get_active_call(self, room_id: str) -> ActiveCall | None:
  1430. resp = await self._rpc(
  1431. self._svc.voice_calls.get_active_call(
  1432. voice_calls_pb2.GetActiveCallRequest(room_id=room_id),
  1433. headers=self._headers(),
  1434. )
  1435. )
  1436. raw = pb_to_dict(resp).get("call")
  1437. return ActiveCall.parse(raw) if raw else None
  1438. async def batch_get_active_calls(self, room_ids: list[str]) -> list[ActiveCall]:
  1439. resp = await self._rpc(
  1440. self._svc.voice_calls.batch_get_active_calls(
  1441. voice_calls_pb2.BatchGetActiveCallsRequest(room_ids=room_ids),
  1442. headers=self._headers(),
  1443. )
  1444. )
  1445. data = pb_to_dict(resp)
  1446. return [ActiveCall.parse(c) for c in data.get("calls") or []]
  1447. async def join_call(self, room_id: str) -> bool:
  1448. resp = await self._rpc(
  1449. self._svc.voice_calls.join_call(
  1450. voice_calls_pb2.JoinCallRequest(room_id=room_id),
  1451. headers=self._headers(),
  1452. )
  1453. )
  1454. return resp.joined
  1455. async def leave_call(self, room_id: str) -> bool:
  1456. resp = await self._rpc(
  1457. self._svc.voice_calls.leave_call(
  1458. voice_calls_pb2.LeaveCallRequest(room_id=room_id),
  1459. headers=self._headers(),
  1460. )
  1461. )
  1462. return resp.left
  1463. async def create_call_token(self, room_id: str) -> str:
  1464. resp = await self._rpc(
  1465. self._svc.voice_calls.create_call_token(
  1466. voice_calls_pb2.CreateCallTokenRequest(room_id=room_id),
  1467. headers=self._headers(),
  1468. )
  1469. )
  1470. return resp.token
  1471. # --- Permissions ----------------------------------------------------
  1472. async def list_effective_permissions(self, user_id: str) -> list[dict[str, Any]]:
  1473. """List every permission decision that applies to one user.
  1474. Returns raw protobuf JSON dicts (the decision shape is
  1475. server-version-dependent).
  1476. """
  1477. resp = await self._rpc(
  1478. self._svc.permissions.list_effective_permissions(
  1479. permissions_pb2.ListEffectivePermissionsRequest(user_id=user_id),
  1480. headers=self._headers(),
  1481. )
  1482. )
  1483. data = pb_to_dict(resp)
  1484. return list(data.get("permissions") or [])
  1485. # --- Admin: server --------------------------------------------------
  1486. async def admin_get_server_config(self) -> tuple[ServerConfig, ServerProfile]:
  1487. resp = await self._rpc(
  1488. self._svc.admin_server.get_server_config(
  1489. admin_server_pb2.GetServerConfigRequest(),
  1490. headers=self._headers(),
  1491. )
  1492. )
  1493. return (
  1494. ServerConfig.parse(pb_to_dict(resp.config)),
  1495. ServerProfile.parse(pb_to_dict(resp.public_profile)),
  1496. )
  1497. async def admin_update_server_config(
  1498. self,
  1499. *,
  1500. server_name: str | None = None,
  1501. description: str | None = None,
  1502. motd: str | None = None,
  1503. welcome_message: str | None = None,
  1504. ) -> tuple[ServerConfig, ServerProfile]:
  1505. req = admin_server_pb2.UpdateServerConfigRequest()
  1506. if server_name is not None:
  1507. req.server_name = server_name
  1508. if description is not None:
  1509. req.description = description
  1510. if motd is not None:
  1511. req.motd = motd
  1512. if welcome_message is not None:
  1513. req.welcome_message = welcome_message
  1514. resp = await self._rpc(
  1515. self._svc.admin_server.update_server_config(req, headers=self._headers())
  1516. )
  1517. return (
  1518. ServerConfig.parse(pb_to_dict(resp.config)),
  1519. ServerProfile.parse(pb_to_dict(resp.public_profile)),
  1520. )
  1521. async def admin_upload_server_logo(
  1522. self,
  1523. file_path: str | Path,
  1524. *,
  1525. content_type: str = "image/png",
  1526. ) -> ServerProfile:
  1527. p = Path(file_path)
  1528. req = admin_server_pb2.UploadServerLogoRequest(
  1529. image=common_pb2.ImageUpload(
  1530. image=p.read_bytes(), filename=p.name, content_type=content_type
  1531. )
  1532. )
  1533. resp = await self._rpc(
  1534. self._svc.admin_server.upload_server_logo(req, headers=self._headers())
  1535. )
  1536. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1537. async def admin_delete_server_logo(self) -> ServerProfile:
  1538. resp = await self._rpc(
  1539. self._svc.admin_server.delete_server_logo(
  1540. admin_server_pb2.DeleteServerLogoRequest(),
  1541. headers=self._headers(),
  1542. )
  1543. )
  1544. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1545. async def admin_upload_server_banner(
  1546. self,
  1547. file_path: str | Path,
  1548. *,
  1549. content_type: str = "image/png",
  1550. ) -> ServerProfile:
  1551. p = Path(file_path)
  1552. req = admin_server_pb2.UploadServerBannerRequest(
  1553. image=common_pb2.ImageUpload(
  1554. image=p.read_bytes(), filename=p.name, content_type=content_type
  1555. )
  1556. )
  1557. resp = await self._rpc(
  1558. self._svc.admin_server.upload_server_banner(req, headers=self._headers())
  1559. )
  1560. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1561. async def admin_delete_server_banner(self) -> ServerProfile:
  1562. resp = await self._rpc(
  1563. self._svc.admin_server.delete_server_banner(
  1564. admin_server_pb2.DeleteServerBannerRequest(),
  1565. headers=self._headers(),
  1566. )
  1567. )
  1568. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1569. async def admin_get_server_security_config(self) -> list[str]:
  1570. resp = await self._rpc(
  1571. self._svc.admin_server.get_server_security_config(
  1572. admin_server_pb2.GetServerSecurityConfigRequest(),
  1573. headers=self._headers(),
  1574. )
  1575. )
  1576. return list(resp.blocked_usernames)
  1577. async def admin_update_blocked_usernames(self, usernames: list[str]) -> list[str]:
  1578. resp = await self._rpc(
  1579. self._svc.admin_server.update_blocked_usernames(
  1580. admin_server_pb2.UpdateBlockedUsernamesRequest(blocked_usernames=usernames),
  1581. headers=self._headers(),
  1582. )
  1583. )
  1584. return list(resp.blocked_usernames)
  1585. # --- Admin: neighbors ---------------------------------------------
  1586. async def admin_list_neighbors(self) -> list[Neighbor]:
  1587. """List configured Neighbors. Requires ``server.manage-neighbors``."""
  1588. resp = await self._rpc(
  1589. self._svc.admin_server.list_neighbors(
  1590. admin_server_pb2.ListNeighborsRequest(),
  1591. headers=self._headers(),
  1592. )
  1593. )
  1594. data = pb_to_dict(resp)
  1595. return [n for n in (Neighbor.parse(x) for x in data.get("neighbors") or []) if n]
  1596. async def admin_get_neighbor(self, neighbor_id: str) -> Neighbor | None:
  1597. """Get one configured Neighbor. Requires ``server.manage-neighbors``."""
  1598. resp = await self._rpc(
  1599. self._svc.admin_server.get_neighbor(
  1600. admin_server_pb2.GetNeighborRequest(neighbor_id=neighbor_id),
  1601. headers=self._headers(),
  1602. )
  1603. )
  1604. return Neighbor.parse(pb_to_dict(resp).get("neighbor"))
  1605. async def admin_create_neighbor(self, origin: str) -> Neighbor:
  1606. """Advertise one server origin. Requires ``server.manage-neighbors``."""
  1607. resp = await self._rpc(
  1608. self._svc.admin_server.create_neighbor(
  1609. admin_server_pb2.CreateNeighborRequest(origin=origin),
  1610. headers=self._headers(),
  1611. )
  1612. )
  1613. neighbor = Neighbor.parse(pb_to_dict(resp).get("neighbor"))
  1614. assert neighbor is not None
  1615. return neighbor
  1616. async def admin_update_neighbor(self, neighbor_id: str, origin: str, revision: str) -> Neighbor:
  1617. """Change one advertised origin. Requires ``server.manage-neighbors``."""
  1618. resp = await self._rpc(
  1619. self._svc.admin_server.update_neighbor(
  1620. admin_server_pb2.UpdateNeighborRequest(
  1621. neighbor_id=neighbor_id, origin=origin, revision=revision
  1622. ),
  1623. headers=self._headers(),
  1624. )
  1625. )
  1626. neighbor = Neighbor.parse(pb_to_dict(resp).get("neighbor"))
  1627. assert neighbor is not None
  1628. return neighbor
  1629. async def admin_delete_neighbor(self, neighbor_id: str, revision: str) -> None:
  1630. """Stop advertising one origin. Requires ``server.manage-neighbors``."""
  1631. await self._rpc(
  1632. self._svc.admin_server.delete_neighbor(
  1633. admin_server_pb2.DeleteNeighborRequest(neighbor_id=neighbor_id, revision=revision),
  1634. headers=self._headers(),
  1635. )
  1636. )
  1637. # --- Admin: room layout & sidebar links ---------------------------
  1638. async def admin_list_room_groups(self) -> list[AdminRoomLayoutGroup]:
  1639. resp = await self._rpc(
  1640. self._svc.admin_room_layout.list_room_groups(
  1641. room_layout_pb2.ListRoomGroupsRequest(),
  1642. headers=self._headers(),
  1643. )
  1644. )
  1645. data = pb_to_dict(resp)
  1646. return [
  1647. g
  1648. for g in (AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or [])
  1649. if g is not None
  1650. ]
  1651. async def admin_create_room_group(
  1652. self, name: str, description: str = ""
  1653. ) -> AdminRoomLayoutGroup:
  1654. resp = await self._rpc(
  1655. self._svc.admin_room_layout.create_room_group(
  1656. room_layout_pb2.CreateRoomGroupRequest(name=name, description=description),
  1657. headers=self._headers(),
  1658. )
  1659. )
  1660. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1661. assert group is not None
  1662. return group
  1663. async def admin_update_room_group(
  1664. self,
  1665. group_id: str,
  1666. *,
  1667. name: str | None = None,
  1668. description: str | None = None,
  1669. ) -> AdminRoomLayoutGroup:
  1670. req = room_layout_pb2.UpdateRoomGroupRequest(group_id=group_id)
  1671. if name is not None:
  1672. req.name = name
  1673. if description is not None:
  1674. req.description = description
  1675. resp = await self._rpc(
  1676. self._svc.admin_room_layout.update_room_group(req, headers=self._headers())
  1677. )
  1678. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1679. assert group is not None
  1680. return group
  1681. async def admin_delete_room_group(self, group_id: str) -> bool:
  1682. resp = await self._rpc(
  1683. self._svc.admin_room_layout.delete_room_group(
  1684. room_layout_pb2.DeleteRoomGroupRequest(group_id=group_id),
  1685. headers=self._headers(),
  1686. )
  1687. )
  1688. return resp.deleted
  1689. async def admin_reorder_room_groups(
  1690. self, ordered_group_ids: list[str]
  1691. ) -> list[AdminRoomLayoutGroup]:
  1692. resp = await self._rpc(
  1693. self._svc.admin_room_layout.reorder_room_groups(
  1694. room_layout_pb2.ReorderRoomGroupsRequest(ordered_group_ids=ordered_group_ids),
  1695. headers=self._headers(),
  1696. )
  1697. )
  1698. data = pb_to_dict(resp)
  1699. return [
  1700. g
  1701. for g in (AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or [])
  1702. if g is not None
  1703. ]
  1704. async def admin_move_room_group(
  1705. self, group_id: str, before_group_id: str | None = None
  1706. ) -> list[AdminRoomLayoutGroup]:
  1707. """Move one room group before another (or to the end if omitted)."""
  1708. req = room_layout_pb2.MoveRoomGroupRequest(group_id=group_id)
  1709. if before_group_id is not None:
  1710. req.before_group_id = before_group_id
  1711. resp = await self._rpc(
  1712. self._svc.admin_room_layout.move_room_group(req, headers=self._headers())
  1713. )
  1714. data = pb_to_dict(resp)
  1715. return [
  1716. g
  1717. for g in (AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or [])
  1718. if g is not None
  1719. ]
  1720. async def admin_move_room_to_group(self, room_id: str, group_id: str) -> Room:
  1721. resp = await self._rpc(
  1722. self._svc.admin_room_layout.move_room_to_group(
  1723. room_layout_pb2.MoveRoomToGroupRequest(room_id=room_id, group_id=group_id),
  1724. headers=self._headers(),
  1725. )
  1726. )
  1727. room = Room.parse(pb_to_dict(resp.room))
  1728. assert room is not None
  1729. return room
  1730. async def admin_reorder_sidebar_items_in_group(
  1731. self,
  1732. group_id: str,
  1733. items: list[tuple[str, str]],
  1734. ) -> AdminRoomLayoutGroup:
  1735. req = room_layout_pb2.ReorderSidebarItemsInGroupRequest(group_id=group_id)
  1736. for kind, item_id in items:
  1737. _layout_item(req.items.add(), kind, item_id)
  1738. resp = await self._rpc(
  1739. self._svc.admin_room_layout.reorder_sidebar_items_in_group(req, headers=self._headers())
  1740. )
  1741. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1742. assert group is not None
  1743. return group
  1744. async def admin_move_sidebar_item(
  1745. self,
  1746. item: tuple[str, str],
  1747. group_id: str,
  1748. before: tuple[str, str] | None = None,
  1749. ) -> AdminRoomLayoutGroup:
  1750. """Move one room or sidebar link to a position in a room group.
  1751. ``item`` is ``(kind, id)`` where kind is ``"room"`` or ``"sidebar_link"``;
  1752. ``before`` is the same tuple for the entry to place ahead of (omit to
  1753. place it last in ``group_id``).
  1754. """
  1755. req = room_layout_pb2.MoveSidebarItemRequest(group_id=group_id)
  1756. _layout_item(req.item, item[0], item[1])
  1757. if before is not None:
  1758. _layout_item(req.before, before[0], before[1])
  1759. resp = await self._rpc(
  1760. self._svc.admin_room_layout.move_sidebar_item(req, headers=self._headers())
  1761. )
  1762. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1763. assert group is not None
  1764. return group
  1765. async def admin_create_sidebar_link(
  1766. self, group_id: str, label: str, url: str
  1767. ) -> dict[str, Any]:
  1768. resp = await self._rpc(
  1769. self._svc.admin_room_layout.create_sidebar_link(
  1770. room_layout_pb2.CreateSidebarLinkRequest(group_id=group_id, label=label, url=url),
  1771. headers=self._headers(),
  1772. )
  1773. )
  1774. sl = resp.sidebar_link
  1775. return {"id": sl.id, "label": sl.label, "url": sl.url}
  1776. async def admin_update_sidebar_link(
  1777. self,
  1778. link_id: str,
  1779. *,
  1780. label: str | None = None,
  1781. url: str | None = None,
  1782. ) -> dict[str, Any]:
  1783. req = room_layout_pb2.UpdateSidebarLinkRequest(link_id=link_id)
  1784. if label is not None:
  1785. req.label = label
  1786. if url is not None:
  1787. req.url = url
  1788. resp = await self._rpc(
  1789. self._svc.admin_room_layout.update_sidebar_link(req, headers=self._headers())
  1790. )
  1791. sl = resp.sidebar_link
  1792. return {"id": sl.id, "label": sl.label, "url": sl.url}
  1793. async def admin_delete_sidebar_link(self, link_id: str) -> bool:
  1794. resp = await self._rpc(
  1795. self._svc.admin_room_layout.delete_sidebar_link(
  1796. room_layout_pb2.DeleteSidebarLinkRequest(link_id=link_id),
  1797. headers=self._headers(),
  1798. )
  1799. )
  1800. return resp.deleted
  1801. async def admin_move_sidebar_link_to_group(self, link_id: str, group_id: str) -> dict[str, Any]:
  1802. resp = await self._rpc(
  1803. self._svc.admin_room_layout.move_sidebar_link_to_group(
  1804. room_layout_pb2.MoveSidebarLinkToGroupRequest(link_id=link_id, group_id=group_id),
  1805. headers=self._headers(),
  1806. )
  1807. )
  1808. sl = resp.sidebar_link
  1809. return {"id": sl.id, "label": sl.label, "url": sl.url}
  1810. # --- Admin: users --------------------------------------------------
  1811. async def admin_list_members(
  1812. self,
  1813. *,
  1814. search: str = "",
  1815. limit: int | None = None,
  1816. offset: int | None = None,
  1817. ) -> tuple[list[str], Page]:
  1818. """List server member IDs (plus page metadata).
  1819. The server returns member IDs only; hydrate full rows with
  1820. :meth:`admin_batch_get_members`.
  1821. """
  1822. req = admin_members_pb2.ListMembersRequest(search=search)
  1823. page = _page_pb(limit, offset)
  1824. if page is not None:
  1825. req.page.CopyFrom(page)
  1826. resp = await self._rpc(self._svc.admin_users.list_members(req, headers=self._headers()))
  1827. data = pb_to_dict(resp)
  1828. return list(data.get("userIds") or []), Page.parse(data.get("page"))
  1829. async def admin_get_member(
  1830. self,
  1831. *,
  1832. user_id: str | None = None,
  1833. login: str | None = None,
  1834. ) -> dict[str, Any]:
  1835. if bool(user_id) == bool(login):
  1836. raise ValueError("admin_get_member requires exactly one of user_id or login")
  1837. req = admin_members_pb2.GetMemberRequest()
  1838. if user_id:
  1839. req.user_id = user_id
  1840. else:
  1841. assert login is not None
  1842. req.login = login
  1843. resp = await self._rpc(self._svc.admin_users.get_member(req, headers=self._headers()))
  1844. return pb_to_dict(resp)
  1845. async def admin_batch_get_members(self, user_ids: list[str]) -> list[AdminMember]:
  1846. resp = await self._rpc(
  1847. self._svc.admin_users.batch_get_members(
  1848. admin_members_pb2.BatchGetMembersRequest(user_ids=user_ids),
  1849. headers=self._headers(),
  1850. )
  1851. )
  1852. data = pb_to_dict(resp)
  1853. return [
  1854. m
  1855. for m in (AdminMember.parse(row) for row in data.get("members") or [])
  1856. if m is not None
  1857. ]
  1858. async def admin_assign_role(self, user_id: str, role_name: str) -> AdminMember | None:
  1859. resp = await self._rpc(
  1860. self._svc.admin_users.assign_role(
  1861. admin_members_pb2.AssignRoleRequest(user_id=user_id, role_name=role_name),
  1862. headers=self._headers(),
  1863. )
  1864. )
  1865. return AdminMember.parse(pb_to_dict(resp.member))
  1866. async def admin_revoke_role(self, user_id: str, role_name: str) -> AdminMember | None:
  1867. resp = await self._rpc(
  1868. self._svc.admin_users.revoke_role(
  1869. admin_members_pb2.RevokeRoleRequest(user_id=user_id, role_name=role_name),
  1870. headers=self._headers(),
  1871. )
  1872. )
  1873. return AdminMember.parse(pb_to_dict(resp.member))
  1874. async def admin_update_user(
  1875. self,
  1876. user_id: str,
  1877. *,
  1878. display_name: str | None = None,
  1879. login: str | None = None,
  1880. bio: str | None = None,
  1881. ) -> tuple[User | None, AdminMember | None]:
  1882. """Update a user's login / display name / bio as a server-admin action.
  1883. The pre-0.5.0b9 ``AdminUserService.UpdateUser`` RPC was removed; admin
  1884. profile edits are now just a privileged
  1885. :meth:`update_user_profile` (``user.manage-accounts`` bypasses the
  1886. username-change cooldown and does not start one), so the second
  1887. returned value is always ``None`` — re-fetch the member row with
  1888. :meth:`admin_get_member` when it is needed.
  1889. """
  1890. user = await self.update_user_profile(
  1891. user_id, display_name=display_name, login=login, bio=bio
  1892. )
  1893. return user, None
  1894. async def admin_change_user_password(self, user_id: str, password: str) -> AdminMember | None:
  1895. resp = await self._rpc(
  1896. self._svc.admin_users.change_user_password(
  1897. admin_members_pb2.ChangeUserPasswordRequest(user_id=user_id, password=password),
  1898. headers=self._headers(),
  1899. )
  1900. )
  1901. return AdminMember.parse(pb_to_dict(resp.member))
  1902. async def admin_clear_username_cooldown(self, user_id: str) -> None:
  1903. await self._rpc(
  1904. self._svc.admin_users.clear_username_cooldown(
  1905. admin_members_pb2.ClearUsernameCooldownRequest(user_id=user_id),
  1906. headers=self._headers(),
  1907. )
  1908. )
  1909. async def admin_delete_user(self, user_id: str) -> None:
  1910. await self._rpc(
  1911. self._svc.admin_users.delete_user(
  1912. admin_members_pb2.DeleteUserRequest(user_id=user_id),
  1913. headers=self._headers(),
  1914. )
  1915. )
  1916. # --- Admin: roles --------------------------------------------------
  1917. async def admin_list_roles(self) -> list[AdminRole]:
  1918. resp = await self._rpc(
  1919. self._svc.admin_roles.list_roles(
  1920. admin_roles_pb2.ListRolesRequest(), headers=self._headers()
  1921. )
  1922. )
  1923. data = pb_to_dict(resp)
  1924. return [
  1925. r for r in (AdminRole.parse(row) for row in data.get("roles") or []) if r is not None
  1926. ]
  1927. async def admin_get_role(self, name: str) -> dict[str, Any]:
  1928. resp = await self._rpc(
  1929. self._svc.admin_roles.get_role(
  1930. admin_roles_pb2.GetRoleRequest(name=name), headers=self._headers()
  1931. )
  1932. )
  1933. return pb_to_dict(resp)
  1934. async def admin_create_role(
  1935. self,
  1936. name: str,
  1937. *,
  1938. display_name: str = "",
  1939. description: str = "",
  1940. pingable: bool = False,
  1941. ) -> AdminRole | None:
  1942. resp = await self._rpc(
  1943. self._svc.admin_roles.create_role(
  1944. admin_roles_pb2.CreateRoleRequest(
  1945. name=name,
  1946. display_name=display_name,
  1947. description=description,
  1948. pingable=pingable,
  1949. ),
  1950. headers=self._headers(),
  1951. )
  1952. )
  1953. return AdminRole.parse(pb_to_dict(resp.role))
  1954. async def admin_update_role(
  1955. self,
  1956. name: str,
  1957. *,
  1958. display_name: str | None = None,
  1959. description: str | None = None,
  1960. pingable: bool | None = None,
  1961. ) -> AdminRole | None:
  1962. req = admin_roles_pb2.UpdateRoleRequest(name=name)
  1963. if display_name is not None:
  1964. req.display_name = display_name
  1965. if description is not None:
  1966. req.description = description
  1967. if pingable is not None:
  1968. req.pingable = pingable
  1969. resp = await self._rpc(self._svc.admin_roles.update_role(req, headers=self._headers()))
  1970. return AdminRole.parse(pb_to_dict(resp.role))
  1971. async def admin_delete_role(self, name: str) -> None:
  1972. await self._rpc(
  1973. self._svc.admin_roles.delete_role(
  1974. admin_roles_pb2.DeleteRoleRequest(name=name),
  1975. headers=self._headers(),
  1976. )
  1977. )
  1978. async def admin_reorder_roles(self, role_names: list[str]) -> list[AdminRole]:
  1979. resp = await self._rpc(
  1980. self._svc.admin_roles.reorder_roles(
  1981. admin_roles_pb2.ReorderRolesRequest(role_names=role_names),
  1982. headers=self._headers(),
  1983. )
  1984. )
  1985. data = pb_to_dict(resp)
  1986. return [
  1987. r for r in (AdminRole.parse(row) for row in data.get("roles") or []) if r is not None
  1988. ]
  1989. async def admin_role_list_members(
  1990. self,
  1991. name: str,
  1992. *,
  1993. limit: int | None = None,
  1994. offset: int | None = None,
  1995. ) -> tuple[list[User], Page]:
  1996. """List a role's explicitly-assigned members (one page)."""
  1997. req = admin_roles_pb2.AdminRoleServiceListMembersRequest(name=name)
  1998. page = _page_pb(limit, offset)
  1999. if page is not None:
  2000. req.page.CopyFrom(page)
  2001. resp = await self._rpc(self._svc.admin_roles.list_members(req, headers=self._headers()))
  2002. data = pb_to_dict(resp)
  2003. members = [
  2004. u for u in (User.parse(row) for row in data.get("members") or []) if u is not None
  2005. ]
  2006. return members, Page.parse(data.get("page"))
  2007. # --- Admin: event log / diagnostics / permissions ----------------
  2008. async def admin_list_events(
  2009. self,
  2010. *,
  2011. event_types: list[str] | None = None,
  2012. limit: int | None = None,
  2013. offset: int | None = None,
  2014. ) -> dict[str, Any]:
  2015. req = event_log_pb2.ListEventsRequest()
  2016. if event_types:
  2017. req.event_types.extend(event_types)
  2018. page = _page_pb(limit, offset)
  2019. if page is not None:
  2020. req.page.CopyFrom(page)
  2021. resp = await self._rpc(self._svc.admin_event_log.list_events(req, headers=self._headers()))
  2022. return pb_to_dict(resp)
  2023. async def admin_list_event_types(self) -> list[str]:
  2024. resp = await self._rpc(
  2025. self._svc.admin_event_log.list_event_types(
  2026. event_log_pb2.ListEventTypesRequest(),
  2027. headers=self._headers(),
  2028. )
  2029. )
  2030. return list(resp.event_types)
  2031. async def admin_get_event(self, event_id: str) -> dict[str, Any]:
  2032. resp = await self._rpc(
  2033. self._svc.admin_event_log.get_event(
  2034. event_log_pb2.GetEventRequest(event_id=event_id),
  2035. headers=self._headers(),
  2036. )
  2037. )
  2038. return pb_to_dict(resp)
  2039. async def admin_get_system_info(self) -> dict[str, Any]:
  2040. from chattolib._pb.chatto.admin.v1 import diagnostics_pb2
  2041. resp = await self._rpc(
  2042. self._svc.admin_diagnostics.get_system_info(
  2043. diagnostics_pb2.GetSystemInfoRequest(),
  2044. headers=self._headers(),
  2045. )
  2046. )
  2047. return pb_to_dict(resp)
  2048. async def admin_get_role_permission_matrix(self) -> dict[str, Any]:
  2049. resp = await self._rpc(
  2050. self._svc.admin_permissions.get_role_permission_matrix(
  2051. admin_permissions_pb2.GetRolePermissionMatrixRequest(),
  2052. headers=self._headers(),
  2053. )
  2054. )
  2055. return pb_to_dict(resp)
  2056. async def admin_list_role_permission_decisions(self, role_name: str) -> dict[str, Any]:
  2057. resp = await self._rpc(
  2058. self._svc.admin_permissions.list_role_permission_decisions(
  2059. admin_permissions_pb2.ListRolePermissionDecisionsRequest(role_name=role_name),
  2060. headers=self._headers(),
  2061. )
  2062. )
  2063. return pb_to_dict(resp)
  2064. async def admin_get_user_permission_matrix(self, user_id: str) -> dict[str, Any]:
  2065. resp = await self._rpc(
  2066. self._svc.admin_permissions.get_user_permission_matrix(
  2067. admin_permissions_pb2.GetUserPermissionMatrixRequest(user_id=user_id),
  2068. headers=self._headers(),
  2069. )
  2070. )
  2071. return pb_to_dict(resp)
  2072. async def admin_list_user_permission_decisions(self, user_id: str) -> dict[str, Any]:
  2073. resp = await self._rpc(
  2074. self._svc.admin_permissions.list_user_permission_decisions(
  2075. admin_permissions_pb2.ListUserPermissionDecisionsRequest(user_id=user_id),
  2076. headers=self._headers(),
  2077. )
  2078. )
  2079. return pb_to_dict(resp)
  2080. # --- Message search -------------------------------------------------
  2081. async def get_search_status(self) -> SearchStatus:
  2082. """Current message-search availability on this server.
  2083. Available even when search is disabled. ``FAILED_PRECONDITION``
  2084. means the operator disabled search; ``UNAVAILABLE`` means the
  2085. search provider is not ready to answer queries yet.
  2086. """
  2087. resp = await self._rpc(
  2088. self._svc.message_search.get_status(
  2089. message_search_pb2.GetStatusRequest(),
  2090. headers=self._headers(),
  2091. )
  2092. )
  2093. return SearchStatus.parse(pb_to_dict(resp))
  2094. async def search_messages(
  2095. self,
  2096. query: str,
  2097. *,
  2098. room_id: str | None = None,
  2099. author_id: str | None = None,
  2100. created_after: datetime | None = None,
  2101. created_before: datetime | None = None,
  2102. has_attachments: bool | None = None,
  2103. order: MessageSearchOrder | None = None,
  2104. page_size: int | None = None,
  2105. cursor: str | None = None,
  2106. scope: MessageSearchScope | None = None,
  2107. group_by: MessageSearchGroupBy | None = None,
  2108. root_author_filter: ThreadRootAuthorFilter | None = None,
  2109. ) -> SearchResultsPage:
  2110. """Full-text search over current message bodies you can read.
  2111. ``query`` supports required words, quoted exact phrases, and the
  2112. ``in:``, ``from:``, ``before:``, ``after:``, and ``has:attachment``
  2113. filters. ``group_by=THREAD`` returns one row per matching thread
  2114. (with ``thread_context`` and ``thread_total_count`` populated).
  2115. Scope, grouping, and ordering are part of the cursor: keep them
  2116. identical when paging with ``cursor``. Raises ``ChattoConnectError``
  2117. with ``code='failed_precondition'`` when search is disabled and
  2118. ``code='unavailable'`` while the provider is catching up.
  2119. """
  2120. req = message_search_pb2.SearchMessagesRequest(query=query)
  2121. if room_id is not None:
  2122. req.room_id = room_id
  2123. if author_id is not None:
  2124. req.author_id = author_id
  2125. if created_after is not None:
  2126. req.created_after.CopyFrom(_timestamp_pb(created_after))
  2127. if created_before is not None:
  2128. req.created_before.CopyFrom(_timestamp_pb(created_before))
  2129. if has_attachments is not None:
  2130. req.has_attachments = has_attachments
  2131. if order is not None:
  2132. req.order = message_search_pb2.MessageSearchOrder.Value(order.value)
  2133. if page_size is not None:
  2134. req.page_size = page_size
  2135. if cursor is not None:
  2136. req.cursor = cursor
  2137. if scope is not None:
  2138. req.scope = message_search_pb2.MessageSearchScope.Value(scope.value)
  2139. if group_by is not None:
  2140. req.group_by = message_search_pb2.MessageSearchGroupBy.Value(group_by.value)
  2141. if root_author_filter is not None:
  2142. req.root_author_filter = threads_pb2.ThreadRootAuthorFilter.Value(
  2143. root_author_filter.value
  2144. )
  2145. resp = await self._rpc(
  2146. self._svc.message_search.search_messages(req, headers=self._headers())
  2147. )
  2148. return SearchResultsPage.parse(pb_to_dict(resp))
  2149. # --- Read state ------------------------------------------------------
  2150. async def get_room_read_state(self, room_id: str) -> RoomReadState | None:
  2151. resp = await self._rpc(
  2152. self._svc.rooms.get_room_read_state(
  2153. read_state_pb2.GetRoomReadStateRequest(room_id=room_id),
  2154. headers=self._headers(),
  2155. )
  2156. )
  2157. return RoomReadState.parse(pb_to_dict(resp.state))
  2158. async def batch_get_room_read_states(self, room_ids: list[str]) -> list[RoomReadState]:
  2159. resp = await self._rpc(
  2160. self._svc.rooms.batch_get_room_read_states(
  2161. read_state_pb2.BatchGetRoomReadStatesRequest(room_ids=room_ids),
  2162. headers=self._headers(),
  2163. )
  2164. )
  2165. data = pb_to_dict(resp)
  2166. return [
  2167. s
  2168. for s in (RoomReadState.parse(row) for row in data.get("states") or [])
  2169. if s is not None
  2170. ]
  2171. async def get_thread_read_state(
  2172. self, room_id: str, thread_root_event_id: str
  2173. ) -> ThreadReadState | None:
  2174. resp = await self._rpc(
  2175. self._svc.threads.get_thread_read_state(
  2176. read_state_pb2.GetThreadReadStateRequest(
  2177. room_id=room_id, thread_root_event_id=thread_root_event_id
  2178. ),
  2179. headers=self._headers(),
  2180. )
  2181. )
  2182. return ThreadReadState.parse(pb_to_dict(resp.state))
  2183. async def batch_get_thread_read_states(
  2184. self, targets: list[tuple[str, str]]
  2185. ) -> list[ThreadReadState]:
  2186. """Thread read states for (room_id, thread_root_event_id) pairs."""
  2187. req = read_state_pb2.BatchGetThreadReadStatesRequest()
  2188. for room_id, thread_root_event_id in targets:
  2189. req.targets.add(room_id=room_id, thread_root_event_id=thread_root_event_id)
  2190. resp = await self._rpc(
  2191. self._svc.threads.batch_get_thread_read_states(req, headers=self._headers())
  2192. )
  2193. data = pb_to_dict(resp)
  2194. return [
  2195. s
  2196. for s in (ThreadReadState.parse(row) for row in data.get("states") or [])
  2197. if s is not None
  2198. ]
  2199. # --- Messages: extras -------------------------------------------------
  2200. async def list_reaction_users(
  2201. self,
  2202. room_id: str,
  2203. message_event_id: str,
  2204. emoji: str,
  2205. *,
  2206. limit: int | None = None,
  2207. offset: int | None = None,
  2208. ) -> tuple[list[str], Page]:
  2209. """User IDs who added ``emoji`` to a message (ordered by user ID).
  2210. Use :meth:`batch_get_users` to hydrate profiles.
  2211. """
  2212. req = reactions_pb2.ListReactionUsersRequest(
  2213. room_id=room_id, message_event_id=message_event_id, emoji=emoji
  2214. )
  2215. page = _page_pb(limit, offset)
  2216. if page is not None:
  2217. req.page.CopyFrom(page)
  2218. resp = await self._rpc(self._svc.messages.list_reaction_users(req, headers=self._headers()))
  2219. data = pb_to_dict(resp)
  2220. return list(data.get("userIds") or []), Page.parse(data.get("page"))
  2221. async def set_attachment_description(
  2222. self, room_id: str, event_id: str, attachment_id: str, description: str
  2223. ) -> Message:
  2224. """Set or clear (empty string) one attachment's alt description."""
  2225. resp = await self._rpc(
  2226. self._svc.messages.set_attachment_description(
  2227. messages_pb2.SetAttachmentDescriptionRequest(
  2228. room_id=room_id,
  2229. event_id=event_id,
  2230. attachment_id=attachment_id,
  2231. description=description,
  2232. ),
  2233. headers=self._headers(),
  2234. )
  2235. )
  2236. message = Message.parse(pb_to_dict(resp.message))
  2237. assert message is not None
  2238. return message
  2239. # --- Threads: extras --------------------------------------------------
  2240. async def list_thread_participants(
  2241. self,
  2242. room_id: str,
  2243. thread_root_event_id: str,
  2244. *,
  2245. limit: int | None = None,
  2246. offset: int | None = None,
  2247. ) -> tuple[list[str], Page]:
  2248. """Distinct authors of non-retracted replies in a thread."""
  2249. req = threads_pb2.ListThreadParticipantsRequest(
  2250. room_id=room_id, thread_root_event_id=thread_root_event_id
  2251. )
  2252. page = _page_pb(limit, offset)
  2253. if page is not None:
  2254. req.page.CopyFrom(page)
  2255. resp = await self._rpc(
  2256. self._svc.threads.list_thread_participants(req, headers=self._headers())
  2257. )
  2258. data = pb_to_dict(resp)
  2259. return list(data.get("userIds") or []), Page.parse(data.get("page"))
  2260. # --- Server -----------------------------------------------------------
  2261. async def get_server_profile(self) -> ServerProfile:
  2262. """Public server profile for an authenticated caller."""
  2263. resp = await self._rpc(
  2264. self._svc.server.get_server_profile(
  2265. server_state_pb2.GetServerProfileRequest(),
  2266. headers=self._headers(),
  2267. )
  2268. )
  2269. return ServerProfile.parse(pb_to_dict(resp.profile))
  2270. # --- Users ------------------------------------------------------------
  2271. async def list_users(
  2272. self,
  2273. *,
  2274. search: str = "",
  2275. limit: int | None = None,
  2276. offset: int | None = None,
  2277. ) -> tuple[list[DirectoryMember], Page]:
  2278. """User directory page, oldest account first."""
  2279. req = user_service_pb2.ListUsersRequest(search=search)
  2280. page = _page_pb(limit, offset)
  2281. if page is not None:
  2282. req.page.CopyFrom(page)
  2283. resp = await self._rpc(self._svc.user.list_users(req, headers=self._headers()))
  2284. data = pb_to_dict(resp)
  2285. users = [
  2286. u
  2287. for u in (DirectoryMember.parse(row) for row in data.get("users") or [])
  2288. if u is not None
  2289. ]
  2290. return users, Page.parse(data.get("page"))
  2291. async def batch_get_users(self, user_ids: list[str]) -> list[DirectoryMember]:
  2292. """Directory rows for several user IDs; unknown IDs are omitted."""
  2293. resp = await self._rpc(
  2294. self._svc.user.batch_get_users(
  2295. user_service_pb2.BatchGetUsersRequest(user_ids=user_ids),
  2296. headers=self._headers(),
  2297. )
  2298. )
  2299. data = pb_to_dict(resp)
  2300. return [
  2301. u
  2302. for u in (DirectoryMember.parse(row) for row in data.get("users") or [])
  2303. if u is not None
  2304. ]
  2305. async def upload_avatar(
  2306. self,
  2307. user_id: str,
  2308. file_path: str | Path,
  2309. *,
  2310. content_type: str = "image/png",
  2311. ) -> User:
  2312. """Upload and set one user's avatar (self, or managed accounts)."""
  2313. p = Path(file_path)
  2314. req = user_service_pb2.UploadAvatarRequest(
  2315. user_id=user_id,
  2316. image=common_pb2.ImageUpload(
  2317. image=p.read_bytes(), filename=p.name, content_type=content_type
  2318. ),
  2319. )
  2320. resp = await self._rpc(self._svc.user.upload_avatar(req, headers=self._headers()))
  2321. user = User.parse(pb_to_dict(resp.user))
  2322. assert user is not None
  2323. return user
  2324. async def delete_avatar(self, user_id: str) -> User:
  2325. resp = await self._rpc(
  2326. self._svc.user.delete_avatar(
  2327. user_service_pb2.DeleteAvatarRequest(user_id=user_id),
  2328. headers=self._headers(),
  2329. )
  2330. )
  2331. user = User.parse(pb_to_dict(resp.user))
  2332. assert user is not None
  2333. return user
  2334. # --- My account: password, email, settings, identities, lifecycle -----
  2335. async def change_password(self, password: str, current_password: str = "") -> User:
  2336. """Set or change the authenticated account's password.
  2337. ``current_password`` is required when the account already has a
  2338. password and omitted when adding the first one.
  2339. """
  2340. req = account_pb2.ChangePasswordRequest(password=password)
  2341. if current_password:
  2342. req.current_password = current_password
  2343. resp = await self._rpc(self._svc.account.change_password(req, headers=self._headers()))
  2344. user = User.parse(pb_to_dict(resp.user))
  2345. assert user is not None
  2346. return user
  2347. async def list_verified_emails(self, expected_user_id: str) -> list[VerifiedEmail]:
  2348. resp = await self._rpc(
  2349. self._svc.account.list_verified_emails(
  2350. account_pb2.ListVerifiedEmailsRequest(expected_user_id=expected_user_id),
  2351. headers=self._headers(),
  2352. )
  2353. )
  2354. data = pb_to_dict(resp)
  2355. return [VerifiedEmail.parse(e) for e in data.get("verifiedEmails") or []]
  2356. async def request_email_verification(self, email: str, expected_user_id: str) -> None:
  2357. """Send a six-digit verification code to a new email address."""
  2358. await self._rpc(
  2359. self._svc.account.request_email_verification(
  2360. account_pb2.RequestEmailVerificationRequest(
  2361. email=email, expected_user_id=expected_user_id
  2362. ),
  2363. headers=self._headers(),
  2364. )
  2365. )
  2366. async def confirm_email_verification(
  2367. self, email: str, code: str, expected_user_id: str
  2368. ) -> list[VerifiedEmail]:
  2369. resp = await self._rpc(
  2370. self._svc.account.confirm_email_verification(
  2371. account_pb2.ConfirmEmailVerificationRequest(
  2372. email=email, code=code, expected_user_id=expected_user_id
  2373. ),
  2374. headers=self._headers(),
  2375. )
  2376. )
  2377. data = pb_to_dict(resp)
  2378. return [VerifiedEmail.parse(e) for e in data.get("verifiedEmails") or []]
  2379. async def set_primary_email(self, email: str, expected_user_id: str) -> list[VerifiedEmail]:
  2380. resp = await self._rpc(
  2381. self._svc.account.set_primary_email(
  2382. account_pb2.SetPrimaryEmailRequest(email=email, expected_user_id=expected_user_id),
  2383. headers=self._headers(),
  2384. )
  2385. )
  2386. data = pb_to_dict(resp)
  2387. return [VerifiedEmail.parse(e) for e in data.get("verifiedEmails") or []]
  2388. async def get_settings(self) -> UserSettings:
  2389. """The authenticated user's display preferences."""
  2390. resp = await self._rpc(
  2391. self._svc.account.get_settings(
  2392. account_pb2.GetSettingsRequest(), headers=self._headers()
  2393. )
  2394. )
  2395. return UserSettings.parse(pb_to_dict(resp.settings))
  2396. async def list_external_identities(
  2397. self,
  2398. ) -> tuple[list[ExternalIdentityProvider], list[LinkedExternalIdentity]]:
  2399. """Configured providers and identities linked to this account."""
  2400. resp = await self._rpc(
  2401. self._svc.account.list_external_identities(
  2402. account_pb2.ListExternalIdentitiesRequest(), headers=self._headers()
  2403. )
  2404. )
  2405. data = pb_to_dict(resp)
  2406. providers = [ExternalIdentityProvider.parse(p) for p in data.get("providers") or []]
  2407. linked = [LinkedExternalIdentity.parse(x) for x in data.get("linkedIdentities") or []]
  2408. return providers, linked
  2409. async def start_external_identity_link(
  2410. self,
  2411. provider_id: str,
  2412. *,
  2413. redirect_path: str = "",
  2414. current_password: str = "",
  2415. ) -> str:
  2416. """Begin linking a provider; returns the browser start URL."""
  2417. req = account_pb2.StartExternalIdentityLinkRequest(provider_id=provider_id)
  2418. if redirect_path:
  2419. req.redirect_path = redirect_path
  2420. if current_password:
  2421. req.current_password = current_password
  2422. resp = await self._rpc(
  2423. self._svc.account.start_external_identity_link(req, headers=self._headers())
  2424. )
  2425. return resp.start_url
  2426. async def disconnect_external_identity(
  2427. self, subject_hash: str, *, current_password: str = ""
  2428. ) -> None:
  2429. req = account_pb2.DisconnectExternalIdentityRequest(subject_hash=subject_hash)
  2430. if current_password:
  2431. req.current_password = current_password
  2432. await self._rpc(
  2433. self._svc.account.disconnect_external_identity(req, headers=self._headers())
  2434. )
  2435. async def request_account_deletion(self) -> str:
  2436. """Get the short-lived confirmation token for account deletion."""
  2437. resp = await self._rpc(
  2438. self._svc.account.request_account_deletion(
  2439. account_pb2.RequestAccountDeletionRequest(), headers=self._headers()
  2440. )
  2441. )
  2442. return resp.confirmation_token
  2443. async def delete_my_account(self, confirmation_token: str) -> None:
  2444. """Permanently delete the authenticated account. Irreversible."""
  2445. await self._rpc(
  2446. self._svc.account.delete_my_account(
  2447. account_pb2.DeleteMyAccountRequest(confirmation_token=confirmation_token),
  2448. headers=self._headers(),
  2449. )
  2450. )
  2451. # --- Viewer: privileged mode ------------------------------------------
  2452. async def activate_privileged_mode(self) -> dict[str, Any]:
  2453. """Elevate entitled permissions for a fixed window (humans only)."""
  2454. resp = await self._rpc(
  2455. self._svc.viewer.activate_privileged_mode(
  2456. viewer_pb2.ActivatePrivilegedModeRequest(), headers=self._headers()
  2457. )
  2458. )
  2459. return pb_to_dict(resp)
  2460. async def deactivate_privileged_mode(self) -> dict[str, Any]:
  2461. resp = await self._rpc(
  2462. self._svc.viewer.deactivate_privileged_mode(
  2463. viewer_pb2.DeactivatePrivilegedModeRequest(), headers=self._headers()
  2464. )
  2465. )
  2466. return pb_to_dict(resp)
  2467. # --- Push ---------------------------------------------------------------
  2468. async def send_test_notification(self) -> None:
  2469. """Send a test Web Push notification to this account's subscriptions."""
  2470. await self._rpc(
  2471. self._svc.push.send_test_notification(
  2472. push_notifications_pb2.SendTestNotificationRequest(),
  2473. headers=self._headers(),
  2474. )
  2475. )
  2476. # --- Voice calls ---------------------------------------------------------
  2477. async def list_call_participants(self, room_id: str) -> list[ActiveCallParticipant]:
  2478. resp = await self._rpc(
  2479. self._svc.voice_calls.list_call_participants(
  2480. voice_calls_pb2.ListCallParticipantsRequest(room_id=room_id),
  2481. headers=self._headers(),
  2482. )
  2483. )
  2484. data = pb_to_dict(resp)
  2485. return [ActiveCallParticipant.parse(p) for p in data.get("participants") or []]
  2486. async def create_call_media_publisher_token(
  2487. self, room_id: str, kind: CallMediaPublisherKind
  2488. ) -> dict[str, Any]:
  2489. """Native companion publisher credential (token, e2ee_key, call_id)."""
  2490. resp = await self._rpc(
  2491. self._svc.voice_calls.create_call_media_publisher_token(
  2492. voice_calls_pb2.CreateCallMediaPublisherTokenRequest(
  2493. room_id=room_id,
  2494. kind=voice_calls_pb2.CallMediaPublisherKind.Value(kind.value),
  2495. ),
  2496. headers=self._headers(),
  2497. )
  2498. )
  2499. return pb_to_dict(resp)
  2500. # --- Bots ---------------------------------------------------------------
  2501. async def list_bots(
  2502. self,
  2503. *,
  2504. search: str = "",
  2505. limit: int | None = None,
  2506. offset: int | None = None,
  2507. ) -> tuple[list[BotAccount], Page]:
  2508. """Bots visible to the caller (owners see their own; managers all)."""
  2509. req = bots_pb2.ListBotsRequest(search=search)
  2510. page = _page_pb(limit, offset)
  2511. if page is not None:
  2512. req.page.CopyFrom(page)
  2513. resp = await self._rpc(self._svc.bots.list_bots(req, headers=self._headers()))
  2514. data = pb_to_dict(resp)
  2515. bots = [
  2516. b for b in (BotAccount.parse(row) for row in data.get("bots") or []) if b is not None
  2517. ]
  2518. return bots, Page.parse(data.get("page"))
  2519. async def get_bot(self, bot_user_id: str) -> BotAccount | None:
  2520. resp = await self._rpc(
  2521. self._svc.bots.get_bot(
  2522. bots_pb2.GetBotRequest(bot_user_id=bot_user_id),
  2523. headers=self._headers(),
  2524. )
  2525. )
  2526. return BotAccount.parse(pb_to_dict(resp.bot))
  2527. async def batch_get_bots(self, bot_user_ids: list[str]) -> list[BotAccount]:
  2528. resp = await self._rpc(
  2529. self._svc.bots.batch_get_bots(
  2530. bots_pb2.BatchGetBotsRequest(bot_user_ids=bot_user_ids),
  2531. headers=self._headers(),
  2532. )
  2533. )
  2534. data = pb_to_dict(resp)
  2535. return [
  2536. b for b in (BotAccount.parse(row) for row in data.get("bots") or []) if b is not None
  2537. ]
  2538. async def create_bot(
  2539. self, login: str, display_name: str, *, api_key_name: str | None = None
  2540. ) -> tuple[BotAccount, str, BotApiKey]:
  2541. """Create a bot owned by the authenticated human. Requires bot.create.
  2542. Returns (bot, api_key, api_key_metadata). ``api_key`` is shown once.
  2543. """
  2544. req = bots_pb2.CreateBotRequest(login=login, display_name=display_name)
  2545. if api_key_name is not None:
  2546. req.api_key_name = api_key_name
  2547. resp = await self._rpc(self._svc.bots.create_bot(req, headers=self._headers()))
  2548. bot = BotAccount.parse(pb_to_dict(resp.bot))
  2549. assert bot is not None
  2550. meta = BotApiKey.parse(pb_to_dict(resp.api_key_metadata))
  2551. return bot, resp.api_key, meta
  2552. async def delete_bot(self, bot_user_id: str) -> bool:
  2553. resp = await self._rpc(
  2554. self._svc.bots.delete_bot(
  2555. bots_pb2.DeleteBotRequest(bot_user_id=bot_user_id),
  2556. headers=self._headers(),
  2557. )
  2558. )
  2559. return resp.deleted
  2560. async def create_bot_api_key(
  2561. self, bot_user_id: str, name: str
  2562. ) -> tuple[BotAccount, str, BotApiKey]:
  2563. """Issue a named API key. ``api_key`` is shown once."""
  2564. resp = await self._rpc(
  2565. self._svc.bots.create_bot_api_key(
  2566. bots_pb2.CreateBotApiKeyRequest(bot_user_id=bot_user_id, name=name),
  2567. headers=self._headers(),
  2568. )
  2569. )
  2570. bot = BotAccount.parse(pb_to_dict(resp.bot))
  2571. assert bot is not None
  2572. meta = BotApiKey.parse(pb_to_dict(resp.api_key_metadata))
  2573. return bot, resp.api_key, meta
  2574. async def revoke_bot_api_key(self, bot_user_id: str, key_id: str) -> BotAccount:
  2575. resp = await self._rpc(
  2576. self._svc.bots.revoke_bot_api_key(
  2577. bots_pb2.RevokeBotApiKeyRequest(bot_user_id=bot_user_id, key_id=key_id),
  2578. headers=self._headers(),
  2579. )
  2580. )
  2581. bot = BotAccount.parse(pb_to_dict(resp.bot))
  2582. assert bot is not None
  2583. return bot
  2584. async def create_bot_incoming_webhook(
  2585. self, bot_user_id: str, name: str
  2586. ) -> tuple[BotAccount, str]:
  2587. """Create a named incoming webhook. Returns (bot, webhook_url-once)."""
  2588. resp = await self._rpc(
  2589. self._svc.bots.create_bot_incoming_webhook(
  2590. bots_pb2.CreateBotIncomingWebhookRequest(bot_user_id=bot_user_id, name=name),
  2591. headers=self._headers(),
  2592. )
  2593. )
  2594. bot = BotAccount.parse(pb_to_dict(resp.bot))
  2595. assert bot is not None
  2596. return bot, resp.webhook_url
  2597. async def revoke_bot_incoming_webhook(self, bot_user_id: str, webhook_id: str) -> BotAccount:
  2598. resp = await self._rpc(
  2599. self._svc.bots.revoke_bot_incoming_webhook(
  2600. bots_pb2.RevokeBotIncomingWebhookRequest(
  2601. bot_user_id=bot_user_id, webhook_id=webhook_id
  2602. ),
  2603. headers=self._headers(),
  2604. )
  2605. )
  2606. bot = BotAccount.parse(pb_to_dict(resp.bot))
  2607. assert bot is not None
  2608. return bot
  2609. async def reassign_bot_owner(self, bot_user_id: str, owner_user_id: str) -> BotAccount:
  2610. resp = await self._rpc(
  2611. self._svc.bots.reassign_bot_owner(
  2612. bots_pb2.ReassignBotOwnerRequest(
  2613. bot_user_id=bot_user_id, owner_user_id=owner_user_id
  2614. ),
  2615. headers=self._headers(),
  2616. )
  2617. )
  2618. bot = BotAccount.parse(pb_to_dict(resp.bot))
  2619. assert bot is not None
  2620. return bot
  2621. async def list_bot_outbound_webhooks(self, bot_user_id: str) -> list[BotOutboundWebhook]:
  2622. resp = await self._rpc(
  2623. self._svc.bots.list_bot_outbound_webhooks(
  2624. bots_pb2.ListBotOutboundWebhooksRequest(bot_user_id=bot_user_id),
  2625. headers=self._headers(),
  2626. )
  2627. )
  2628. data = pb_to_dict(resp)
  2629. return [BotOutboundWebhook.parse(w) for w in data.get("webhooks") or []]
  2630. async def get_bot_outbound_webhook(
  2631. self, bot_user_id: str, webhook_id: str
  2632. ) -> BotOutboundWebhook | None:
  2633. resp = await self._rpc(
  2634. self._svc.bots.get_bot_outbound_webhook(
  2635. bots_pb2.GetBotOutboundWebhookRequest(
  2636. bot_user_id=bot_user_id, webhook_id=webhook_id
  2637. ),
  2638. headers=self._headers(),
  2639. )
  2640. )
  2641. return BotOutboundWebhook.parse(pb_to_dict(resp.webhook))
  2642. async def create_bot_outbound_webhook(
  2643. self,
  2644. bot_user_id: str,
  2645. url: str,
  2646. name: str,
  2647. *,
  2648. enabled: bool = True,
  2649. authorization: str = "",
  2650. ) -> tuple[BotOutboundWebhook, str]:
  2651. """Create an outbound webhook. Returns (webhook, signing_secret-once)."""
  2652. resp = await self._rpc(
  2653. self._svc.bots.create_bot_outbound_webhook(
  2654. bots_pb2.CreateBotOutboundWebhookRequest(
  2655. bot_user_id=bot_user_id,
  2656. url=url,
  2657. authorization=authorization,
  2658. enabled=enabled,
  2659. name=name,
  2660. ),
  2661. headers=self._headers(),
  2662. )
  2663. )
  2664. webhook = BotOutboundWebhook.parse(pb_to_dict(resp.webhook))
  2665. assert webhook is not None
  2666. return webhook, resp.signing_secret
  2667. async def update_bot_outbound_webhook(
  2668. self,
  2669. bot_user_id: str,
  2670. webhook_id: str,
  2671. *,
  2672. enabled: bool | None = None,
  2673. url: str | None = None,
  2674. authorization: str | None = None,
  2675. ) -> BotOutboundWebhook:
  2676. req = bots_pb2.UpdateBotOutboundWebhookRequest(
  2677. bot_user_id=bot_user_id, webhook_id=webhook_id
  2678. )
  2679. fields: list[str] = []
  2680. if enabled is not None:
  2681. req.enabled = enabled
  2682. fields.append("enabled")
  2683. if url is not None:
  2684. req.url = url
  2685. fields.append("url")
  2686. if authorization is not None:
  2687. req.authorization = authorization
  2688. fields.append("authorization")
  2689. if fields:
  2690. req.update_mask.paths.extend(fields)
  2691. resp = await self._rpc(
  2692. self._svc.bots.update_bot_outbound_webhook(req, headers=self._headers())
  2693. )
  2694. webhook = BotOutboundWebhook.parse(pb_to_dict(resp.webhook))
  2695. assert webhook is not None
  2696. return webhook
  2697. async def revoke_bot_outbound_webhook(self, bot_user_id: str, webhook_id: str) -> None:
  2698. await self._rpc(
  2699. self._svc.bots.revoke_bot_outbound_webhook(
  2700. bots_pb2.RevokeBotOutboundWebhookRequest(
  2701. bot_user_id=bot_user_id, webhook_id=webhook_id
  2702. ),
  2703. headers=self._headers(),
  2704. )
  2705. )
  2706. async def list_bot_webhook_failures(
  2707. self,
  2708. bot_user_id: str,
  2709. webhook_id: str,
  2710. *,
  2711. page_size: int | None = None,
  2712. cursor: str | None = None,
  2713. ) -> tuple[list[BotWebhookFailure], str]:
  2714. """Retained outbound delivery failures, oldest first.
  2715. Returns (failures, next_cursor); empty ``next_cursor`` means done.
  2716. """
  2717. req = bots_pb2.ListBotWebhookFailuresRequest(bot_user_id=bot_user_id, webhook_id=webhook_id)
  2718. if page_size is not None:
  2719. req.page_size = page_size
  2720. if cursor is not None:
  2721. req.cursor = cursor
  2722. resp = await self._rpc(
  2723. self._svc.bots.list_bot_webhook_failures(req, headers=self._headers())
  2724. )
  2725. data = pb_to_dict(resp)
  2726. return (
  2727. [BotWebhookFailure.parse(f) for f in data.get("failures") or []],
  2728. data.get("nextCursor", ""),
  2729. )
  2730. # --- External identity auth (public, capability-token flows) ----------
  2731. async def get_pending_external_identity(self, token: str) -> PendingExternalIdentity | None:
  2732. resp = await self._rpc(
  2733. self._svc.external_identity_auth.get_pending_external_identity(
  2734. external_identity_auth_pb2.GetPendingExternalIdentityRequest(token=token),
  2735. headers=self._headers(),
  2736. )
  2737. )
  2738. return PendingExternalIdentity.parse(pb_to_dict(resp.pending))
  2739. async def create_external_identity_account(
  2740. self, token: str, login: str, display_name: str = ""
  2741. ) -> dict[str, Any]:
  2742. """Create a passwordless account from a pending provider identity."""
  2743. req = external_identity_auth_pb2.CreateExternalIdentityAccountRequest(
  2744. token=token, login=login
  2745. )
  2746. if display_name:
  2747. req.display_name = display_name
  2748. resp = await self._rpc(
  2749. self._svc.external_identity_auth.create_external_identity_account(
  2750. req, headers=self._headers()
  2751. )
  2752. )
  2753. return pb_to_dict(resp)
  2754. async def confirm_external_identity_link(self, token: str) -> LinkedExternalIdentity | None:
  2755. resp = await self._rpc(
  2756. self._svc.external_identity_auth.confirm_external_identity_link(
  2757. external_identity_auth_pb2.ConfirmExternalIdentityLinkRequest(token=token),
  2758. headers=self._headers(),
  2759. )
  2760. )
  2761. return LinkedExternalIdentity.parse(pb_to_dict(resp.linked_identity))
  2762. async def cancel_external_identity_flow(self, token: str) -> None:
  2763. await self._rpc(
  2764. self._svc.external_identity_auth.cancel_external_identity_flow(
  2765. external_identity_auth_pb2.CancelExternalIdentityFlowRequest(token=token),
  2766. headers=self._headers(),
  2767. )
  2768. )
  2769. # --- Admin: permission writes ------------------------------------------
  2770. async def admin_get_role_permission_tier_matrix(
  2771. self,
  2772. *,
  2773. scope_kind: PermissionScopeKind = PermissionScopeKind.SERVER,
  2774. scope_id: str = "",
  2775. ) -> dict[str, Any]:
  2776. req = admin_permissions_pb2.GetRolePermissionTierMatrixRequest()
  2777. if scope_kind != PermissionScopeKind.SERVER or scope_id:
  2778. req.scope.CopyFrom(_permission_scope_pb(scope_kind, scope_id))
  2779. resp = await self._rpc(
  2780. self._svc.admin_permissions.get_role_permission_tier_matrix(
  2781. req, headers=self._headers()
  2782. )
  2783. )
  2784. return pb_to_dict(resp)
  2785. async def admin_explain_permissions(
  2786. self,
  2787. user_id: str,
  2788. *,
  2789. room_id: str = "",
  2790. scope_kind: PermissionScopeKind | None = None,
  2791. scope_id: str = "",
  2792. ) -> list[dict[str, Any]]:
  2793. req = admin_permissions_pb2.ExplainPermissionsRequest(user_id=user_id)
  2794. if room_id:
  2795. req.room_id = room_id
  2796. if scope_kind is not None:
  2797. req.scope.CopyFrom(_permission_scope_pb(scope_kind, scope_id))
  2798. resp = await self._rpc(
  2799. self._svc.admin_permissions.explain_permissions(req, headers=self._headers())
  2800. )
  2801. data = pb_to_dict(resp)
  2802. return list(data.get("explanations") or [])
  2803. async def admin_set_role_permission(
  2804. self,
  2805. role_name: str,
  2806. permission: str,
  2807. decision: PermissionDecision,
  2808. *,
  2809. scope_kind: PermissionScopeKind = PermissionScopeKind.SERVER,
  2810. scope_id: str = "",
  2811. ) -> dict[str, Any]:
  2812. req = admin_permissions_pb2.SetRolePermissionRequest(
  2813. role_name=role_name,
  2814. permission=permission,
  2815. decision=admin_permissions_pb2.PermissionDecision.Value(decision.value),
  2816. )
  2817. if scope_kind != PermissionScopeKind.SERVER or scope_id:
  2818. req.scope.CopyFrom(_permission_scope_pb(scope_kind, scope_id))
  2819. resp = await self._rpc(
  2820. self._svc.admin_permissions.set_role_permission(req, headers=self._headers())
  2821. )
  2822. return pb_to_dict(resp)
  2823. async def admin_set_user_permission(
  2824. self,
  2825. user_id: str,
  2826. permission: str,
  2827. decision: PermissionDecision,
  2828. *,
  2829. scope_kind: PermissionScopeKind = PermissionScopeKind.SERVER,
  2830. scope_id: str = "",
  2831. ) -> dict[str, Any]:
  2832. req = admin_permissions_pb2.SetUserPermissionRequest(
  2833. user_id=user_id,
  2834. permission=permission,
  2835. decision=admin_permissions_pb2.PermissionDecision.Value(decision.value),
  2836. )
  2837. if scope_kind != PermissionScopeKind.SERVER or scope_id:
  2838. req.scope.CopyFrom(_permission_scope_pb(scope_kind, scope_id))
  2839. resp = await self._rpc(
  2840. self._svc.admin_permissions.set_user_permission(req, headers=self._headers())
  2841. )
  2842. return pb_to_dict(resp)
  2843. # --- Admin: invite links ------------------------------------------------
  2844. async def admin_list_invite_links(
  2845. self, *, limit: int | None = None, offset: int | None = None
  2846. ) -> tuple[list[InviteLink], Page]:
  2847. req = admin_invitations_pb2.ListInviteLinksRequest()
  2848. page = _page_pb(limit, offset)
  2849. if page is not None:
  2850. req.page.CopyFrom(page)
  2851. resp = await self._rpc(
  2852. self._svc.admin_invite_links.list_invite_links(req, headers=self._headers())
  2853. )
  2854. data = pb_to_dict(resp)
  2855. return (
  2856. [
  2857. link
  2858. for link in (InviteLink.parse(row) for row in data.get("inviteLinks") or [])
  2859. if link is not None
  2860. ],
  2861. Page.parse(data.get("page")),
  2862. )
  2863. async def admin_get_invite_link(self, link_id: str) -> InviteLink | None:
  2864. resp = await self._rpc(
  2865. self._svc.admin_invite_links.get_invite_link(
  2866. admin_invitations_pb2.GetInviteLinkRequest(id=link_id),
  2867. headers=self._headers(),
  2868. )
  2869. )
  2870. return InviteLink.parse(pb_to_dict(resp.invite_link))
  2871. async def admin_create_invite_link(
  2872. self, *, max_uses: int | None = None, expires_at: datetime | None = None
  2873. ) -> InviteLink:
  2874. req = admin_invitations_pb2.CreateInviteLinkRequest()
  2875. if max_uses is not None:
  2876. req.max_uses = max_uses
  2877. if expires_at is not None:
  2878. req.expires_at.CopyFrom(_timestamp_pb(expires_at))
  2879. resp = await self._rpc(
  2880. self._svc.admin_invite_links.create_invite_link(req, headers=self._headers())
  2881. )
  2882. link = InviteLink.parse(pb_to_dict(resp.invite_link))
  2883. assert link is not None
  2884. return link
  2885. async def admin_revoke_invite_link(self, link_id: str) -> InviteLink:
  2886. resp = await self._rpc(
  2887. self._svc.admin_invite_links.revoke_invite_link(
  2888. admin_invitations_pb2.RevokeInviteLinkRequest(id=link_id),
  2889. headers=self._headers(),
  2890. )
  2891. )
  2892. link = InviteLink.parse(pb_to_dict(resp.invite_link))
  2893. assert link is not None
  2894. return link
  2895. # --- Admin: OAuth clients -------------------------------------------------
  2896. async def admin_list_oauth_clients(
  2897. self, *, limit: int | None = None, offset: int | None = None
  2898. ) -> tuple[list[OAuthClient], Page]:
  2899. req = admin_oauth_clients_pb2.ListOAuthClientsRequest()
  2900. page = _page_pb(limit, offset)
  2901. if page is not None:
  2902. req.page.CopyFrom(page)
  2903. resp = await self._rpc(
  2904. self._svc.admin_oauth_clients.list_oauth_clients(req, headers=self._headers())
  2905. )
  2906. data = pb_to_dict(resp)
  2907. return (
  2908. [
  2909. c
  2910. for c in (OAuthClient.parse(row) for row in data.get("oauthClients") or [])
  2911. if c is not None
  2912. ],
  2913. Page.parse(data.get("page")),
  2914. )
  2915. async def admin_get_oauth_client(self, client_id: str) -> OAuthClient | None:
  2916. resp = await self._rpc(
  2917. self._svc.admin_oauth_clients.get_oauth_client(
  2918. admin_oauth_clients_pb2.GetOAuthClientRequest(client_id=client_id),
  2919. headers=self._headers(),
  2920. )
  2921. )
  2922. return OAuthClient.parse(pb_to_dict(resp.oauth_client))
  2923. async def admin_update_oauth_client_policy(
  2924. self, client_id: str, policy: OauthClientPolicy
  2925. ) -> OAuthClient:
  2926. resp = await self._rpc(
  2927. self._svc.admin_oauth_clients.update_oauth_client_policy(
  2928. admin_oauth_clients_pb2.UpdateOAuthClientPolicyRequest(
  2929. client_id=client_id,
  2930. policy=admin_oauth_clients_pb2.OauthClientPolicy.Value(policy.value),
  2931. ),
  2932. headers=self._headers(),
  2933. )
  2934. )
  2935. client = OAuthClient.parse(pb_to_dict(resp.oauth_client))
  2936. assert client is not None
  2937. return client
  2938. # --- Auth: setup & push cleanup (public, unauthenticated) --------------
  2939. async def complete_server_setup(
  2940. self,
  2941. server_name: str,
  2942. login: str,
  2943. display_name: str,
  2944. password: str,
  2945. *,
  2946. description: str = "",
  2947. ) -> None:
  2948. """Create the first owner on a fresh server (public until used)."""
  2949. req = setup_pb2.CompleteSetupRequest(
  2950. server_name=server_name, login=login, display_name=display_name, password=password
  2951. )
  2952. if description:
  2953. req.description = description
  2954. await self._rpc(self._svc.setup.complete_setup(req, headers=self._headers()))
  2955. async def delete_push_subscription(self, endpoint: str, auth: str, cleanup_token: str) -> None:
  2956. """Capability-authenticated cleanup of a browser push subscription."""
  2957. await self._rpc(
  2958. self._svc.push_cleanup.delete_subscription(
  2959. push_subscription_cleanup_pb2.DeleteSubscriptionRequest(
  2960. endpoint=endpoint, auth=auth, cleanup_token=cleanup_token
  2961. ),
  2962. headers=self._headers(),
  2963. )
  2964. )