client.py 125 KB

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