client.py 84 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305
  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. members_pb2 as admin_members_pb2,
  28. )
  29. from chattolib._pb.chatto.admin.v1 import (
  30. permissions_pb2 as admin_permissions_pb2,
  31. )
  32. from chattolib._pb.chatto.admin.v1 import (
  33. roles_pb2 as admin_roles_pb2,
  34. )
  35. from chattolib._pb.chatto.admin.v1 import (
  36. server_pb2 as admin_server_pb2,
  37. )
  38. from chattolib._pb.chatto.api.v1 import (
  39. account_pb2,
  40. asset_uploads_pb2,
  41. attachments_pb2,
  42. common_pb2,
  43. link_previews_pb2,
  44. member_directory_pb2,
  45. messages_pb2,
  46. notification_preferences_pb2,
  47. notifications_pb2,
  48. pagination_pb2,
  49. permissions_pb2,
  50. presence_pb2,
  51. push_notifications_pb2,
  52. reactions_pb2,
  53. read_state_pb2,
  54. roles_pb2,
  55. room_directory_pb2,
  56. room_timeline_pb2,
  57. rooms_pb2,
  58. server_state_pb2,
  59. threads_pb2,
  60. user_service_pb2,
  61. user_status_pb2,
  62. viewer_pb2,
  63. voice_calls_pb2,
  64. )
  65. from chattolib._pb.chatto.discovery.v1 import server_pb2 as discovery_server_pb2
  66. from chattolib._transport import (
  67. ServiceClients,
  68. build_service_clients,
  69. pb_to_dict,
  70. translate_connect_error,
  71. )
  72. from chattolib.exceptions import ChattoAuthError, ChattoError
  73. from chattolib.types import (
  74. ActiveCall,
  75. AdminMember,
  76. AdminRole,
  77. AdminRoomLayoutGroup,
  78. Asset,
  79. AssetUpload,
  80. DirectoryMember,
  81. FollowedThread,
  82. FollowedThreadsPage,
  83. ImageTransformOptions,
  84. LinkPreview,
  85. Message,
  86. Neighbor,
  87. NeighborhoodServersPage,
  88. NotificationLevel,
  89. NotificationOccurrence,
  90. NotificationOccurrencesPage,
  91. NotificationPolicy,
  92. NotificationPreference,
  93. Page,
  94. PinnedMessage,
  95. PinnedMessagesPage,
  96. PresencePreference,
  97. PresenceStatus,
  98. Role,
  99. Room,
  100. RoomDirectoryScope,
  101. RoomGroup,
  102. RoomSuspension,
  103. RoomThreadingMode,
  104. RoomWithViewerState,
  105. ServerConfig,
  106. ServerLogin,
  107. ServerProfile,
  108. ServerRuntimeConfig,
  109. ThreadRootAuthorFilter,
  110. TimeFormat,
  111. TimelinePage,
  112. User,
  113. UserSettings,
  114. ViewerUser,
  115. format_datetime,
  116. parse_datetime,
  117. )
  118. RES = TypeVar("RES")
  119. def _page_pb(limit: int | None, offset: int | None) -> pagination_pb2.PageRequest | None:
  120. if limit is None and offset is None:
  121. return None
  122. return pagination_pb2.PageRequest(limit=limit or 0, offset=offset or 0)
  123. def _policy_scope(*, room_id: str = "", room_group_id: str = "") -> Any:
  124. """Build a ``NotificationPolicyScope`` selecting server / group / room."""
  125. scope = notifications_pb2.NotificationPolicyScope()
  126. if room_id:
  127. scope.room_id = room_id
  128. elif room_group_id:
  129. scope.room_group_id = room_group_id
  130. else:
  131. scope.server.SetCachedValue()
  132. return scope
  133. def _layout_item(target: Any, kind: str, item_id: str) -> None:
  134. """Set the required ``oneof item`` on an ``AdminRoomLayoutItemInput``."""
  135. if kind == "room":
  136. target.room_id = item_id
  137. elif kind == "sidebar_link":
  138. target.sidebar_link_id = item_id
  139. else:
  140. raise ValueError(f"unknown room-layout item kind: {kind!r} (use 'room' or 'sidebar_link')")
  141. def _thumbnail_pb(
  142. opts: ImageTransformOptions | None,
  143. ) -> common_pb2.ImageTransformOptions | None:
  144. if opts is None:
  145. return None
  146. return common_pb2.ImageTransformOptions(
  147. width=opts.width, height=opts.height, fit=opts.fit.value
  148. )
  149. def _timestamp_pb(value: datetime | None) -> Any:
  150. from google.protobuf import timestamp_pb2
  151. if value is None:
  152. return None
  153. ts = timestamp_pb2.Timestamp()
  154. ts.FromJsonString(format_datetime(value))
  155. return ts
  156. class ChattoClient:
  157. """Async client for the Chatto Connect API.
  158. Usage::
  159. async with await ChattoClient.login("user", "pass") as client:
  160. viewer = await client.get_viewer()
  161. rooms = await client.list_rooms()
  162. # Or with an existing token:
  163. async with ChattoClient(token="cht_...") as client:
  164. ...
  165. """
  166. DEFAULT_BASE_URL = "https://chat.chatto.run"
  167. def __init__(
  168. self,
  169. token: str | None = None,
  170. *,
  171. base_url: str = DEFAULT_BASE_URL,
  172. session_cookie: str | None = None,
  173. service_clients: ServiceClients | None = None,
  174. ) -> None:
  175. self._base_url = base_url.rstrip("/")
  176. self._token = token
  177. self._session_cookie = session_cookie
  178. self._svc = service_clients or build_service_clients(self._base_url)
  179. self._owns_clients = service_clients is None
  180. async def __aenter__(self) -> ChattoClient:
  181. return self
  182. async def __aexit__(self, *exc: Any) -> None:
  183. await self.close()
  184. async def close(self) -> None:
  185. if self._owns_clients:
  186. await self._svc.close()
  187. # --- Transport ------------------------------------------------------
  188. @property
  189. def base_url(self) -> str:
  190. return self._base_url
  191. @property
  192. def token(self) -> str | None:
  193. return self._token
  194. @property
  195. def session_cookie(self) -> str | None:
  196. return self._session_cookie
  197. @property
  198. def services(self) -> ServiceClients:
  199. """Direct access to the underlying ConnectRPC service clients.
  200. Useful when a caller wants to reach an RPC that this class doesn't
  201. expose yet, or wants full protobuf messages instead of the
  202. dataclass views returned by the high-level helpers.
  203. """
  204. return self._svc
  205. def _headers(self) -> dict[str, str]:
  206. headers: dict[str, str] = {}
  207. if self._token:
  208. headers["Authorization"] = f"Bearer {self._token}"
  209. if self._session_cookie:
  210. headers["Cookie"] = f"chatto_session={self._session_cookie}"
  211. return headers
  212. async def _rpc(self, coro: Awaitable[RES]) -> RES:
  213. """Await a ConnectRPC coroutine, translating errors."""
  214. try:
  215. return await coro
  216. except ConnectError as exc:
  217. raise translate_connect_error(exc) from exc
  218. # --- Server discovery ----------------------------------------------
  219. async def get_server(self) -> tuple[ServerProfile, ServerLogin]:
  220. """Public server profile and login options. Does not require auth."""
  221. resp = await self._rpc(
  222. self._svc.server_discovery.get_server(
  223. discovery_server_pb2.GetServerRequest(),
  224. headers=self._headers(),
  225. )
  226. )
  227. return (
  228. ServerProfile.parse(pb_to_dict(resp.profile)),
  229. ServerLogin.parse(pb_to_dict(resp.login)),
  230. )
  231. async def list_neighbors(self) -> list[str]:
  232. """Public Neighbor directory: the advertised canonical server origins.
  233. Does not require auth. The response has no ordering contract.
  234. """
  235. resp = await self._rpc(
  236. self._svc.server_discovery.list_neighbors(
  237. discovery_server_pb2.ListNeighborsRequest(),
  238. headers=self._headers(),
  239. )
  240. )
  241. return list(resp.origins)
  242. async def list_neighborhood_servers(self) -> NeighborhoodServersPage:
  243. """Servers in the Neighborhood the called server has discovered.
  244. Does not require auth. The server loads other servers' public profiles
  245. in the background and caches the result (refreshed at least hourly),
  246. so the call itself contacts no other server. The response has no
  247. ordering contract; ``refreshed_at`` is absent before the server's
  248. first completed discovery.
  249. """
  250. resp = await self._rpc(
  251. self._svc.server_discovery.list_neighborhood_servers(
  252. discovery_server_pb2.ListNeighborhoodServersRequest(),
  253. headers=self._headers(),
  254. )
  255. )
  256. return NeighborhoodServersPage.parse(pb_to_dict(resp))
  257. async def get_motd(self) -> str | None:
  258. resp = await self._rpc(
  259. self._svc.server.get_motd(server_state_pb2.GetMotdRequest(), headers=self._headers())
  260. )
  261. if not resp.HasField("motd"):
  262. return None
  263. return resp.motd
  264. async def get_runtime_config(self) -> ServerRuntimeConfig:
  265. resp = await self._rpc(
  266. self._svc.server.get_runtime_config(
  267. server_state_pb2.GetRuntimeConfigRequest(),
  268. headers=self._headers(),
  269. )
  270. )
  271. return ServerRuntimeConfig.parse(pb_to_dict(resp.runtime))
  272. # --- Viewer ---------------------------------------------------------
  273. async def get_viewer(self) -> dict[str, Any]:
  274. """Full authenticated viewer snapshot (camelCase JSON dict).
  275. The response contains ``user``, ``capabilities``, notification
  276. preferences, permissions and viewer state. Callers that only need the
  277. current user's public profile should use ``me()`` for a typed result.
  278. """
  279. resp = await self._rpc(
  280. self._svc.viewer.get_viewer(viewer_pb2.GetViewerRequest(), headers=self._headers())
  281. )
  282. return pb_to_dict(resp)
  283. async def viewer_user(self) -> ViewerUser | None:
  284. data = await self.get_viewer()
  285. return ViewerUser.parse(data.get("user"))
  286. async def me(self) -> User:
  287. """Return the authenticated user's public profile."""
  288. viewer = await self.viewer_user()
  289. if viewer is None or viewer.profile is None:
  290. raise ChattoAuthError("No authenticated viewer")
  291. return viewer.profile
  292. # --- MyAccount -----------------------------------------------------
  293. async def update_user_profile(
  294. self,
  295. user_id: str,
  296. *,
  297. display_name: str | None = None,
  298. login: str | None = None,
  299. bio: str | None = None,
  300. ) -> User:
  301. """Update a user's public profile via ``UserService.UpdateUserProfile``.
  302. This is the one wire path for profile edits in 0.5.0-beta.9. Users
  303. update their own account without extra authority; updating another
  304. human requires ``user.manage-accounts`` (and, for a bot target,
  305. ownership of the bot, ``user.manage-accounts``, or ``bot.manage``) —
  306. the server enforces that. A ``login`` change is rate-limited by the
  307. server's username-change cooldown. Returns the updated ``User``.
  308. """
  309. req = user_service_pb2.UpdateUserProfileRequest(user_id=user_id)
  310. if display_name is not None:
  311. req.display_name = display_name
  312. if login is not None:
  313. req.login = login
  314. if bio is not None:
  315. req.bio = bio
  316. resp = await self._rpc(self._svc.user.update_user_profile(req, headers=self._headers()))
  317. user = User.parse(pb_to_dict(resp.user))
  318. assert user is not None
  319. return user
  320. async def update_profile(
  321. self,
  322. *,
  323. display_name: str | None = None,
  324. login: str | None = None,
  325. bio: str | None = None,
  326. ) -> User:
  327. """Update the authenticated user's own public profile.
  328. The pre-0.5.0b9 ``MyAccountService.UpdateProfile`` RPC was removed;
  329. profile edits now go through ``UserService.UpdateUserProfile``, which
  330. needs a target ``user_id`` — so this resolves the caller's own id from
  331. the viewer snapshot first and then delegates to
  332. :meth:`update_user_profile`.
  333. """
  334. me = await self.me()
  335. return await self.update_user_profile(
  336. me.id, display_name=display_name, login=login, bio=bio
  337. )
  338. async def update_settings(
  339. self,
  340. *,
  341. timezone: str | None = None,
  342. time_format: TimeFormat | None = None,
  343. share_timezone: bool | None = None,
  344. ) -> UserSettings:
  345. req = account_pb2.UpdateSettingsRequest()
  346. if timezone is not None:
  347. req.timezone = timezone
  348. if time_format is not None:
  349. req.time_format = time_format.value
  350. if share_timezone is not None:
  351. req.share_timezone = share_timezone
  352. resp = await self._rpc(self._svc.account.update_settings(req, headers=self._headers()))
  353. return UserSettings.parse(pb_to_dict(resp.settings))
  354. async def set_presence(
  355. self,
  356. status: PresenceStatus,
  357. *,
  358. user_selected: bool = True,
  359. ) -> PresenceStatus:
  360. """Report a *live* presence status (the pre-0.5.0b6 mechanism).
  361. This is the legacy, transient report: it expires after 60 seconds
  362. without a refresh and a saved private choice (see
  363. :meth:`set_presence_preference`) overrides it, including when
  364. ``user_selected`` is set. New clients should prefer the
  365. :meth:`set_presence_preference` / :meth:`refresh_presence` pair.
  366. """
  367. if status in (PresenceStatus.UNSPECIFIED, PresenceStatus.OFFLINE):
  368. raise ValueError(
  369. "UNSPECIFIED and OFFLINE cannot be set as presence status; "
  370. "stop refreshing to go offline"
  371. )
  372. req = presence_pb2.SetPresenceRequest(status=status.value, user_selected=user_selected)
  373. resp = await self._rpc(self._svc.account.set_presence(req, headers=self._headers()))
  374. name = presence_pb2.PresenceStatus.Name(resp.status)
  375. return PresenceStatus(name)
  376. async def get_presence_preference(self) -> PresencePreference | None:
  377. """Read the authenticated account's private saved availability choice.
  378. Returns ``None`` until the account has saved a choice for the first
  379. time. This is the read side of the 0.5.0b6 preference pair; use
  380. :meth:`set_presence_preference` to change it.
  381. """
  382. resp = await self._rpc(
  383. self._svc.account.get_presence_preference(
  384. presence_pb2.GetPresencePreferenceRequest(), headers=self._headers()
  385. )
  386. )
  387. return PresencePreference.parse(pb_to_dict(resp.preference))
  388. async def set_presence_preference(
  389. self,
  390. status: PresenceStatus,
  391. *,
  392. expected_revision: str = "",
  393. ) -> PresencePreference:
  394. """Save an availability choice for every device on this server.
  395. Unlike :meth:`set_presence`, the choice persists across disconnects
  396. and ``OFFLINE`` is a valid value (it means "Appear Offline" and
  397. suppresses public presence and typing). Pass ``expected_revision``
  398. from a prior :meth:`get_presence_preference` to guard against a
  399. concurrent change; a stale revision is rejected with ``ABORTED``.
  400. Leave it empty only when initialising an absent choice.
  401. """
  402. if status is PresenceStatus.UNSPECIFIED:
  403. raise ValueError("UNSPECIFIED cannot be saved as a presence preference")
  404. req = presence_pb2.SetPresencePreferenceRequest(
  405. status=status.value, expected_revision=expected_revision
  406. )
  407. resp = await self._rpc(
  408. self._svc.account.set_presence_preference(req, headers=self._headers())
  409. )
  410. pref = PresencePreference.parse(pb_to_dict(resp.preference))
  411. assert pref is not None
  412. return pref
  413. async def refresh_presence(self) -> PresencePreference | None:
  414. """Refresh connection liveness without changing the saved choice.
  415. Call roughly every 30 seconds to stay "live" (liveness expires after
  416. 60 seconds). Accounts with a saved ``OFFLINE`` choice may refresh
  417. without emitting public presence signals. Returns the current private
  418. choice so a client can recover a choice it missed while disconnected.
  419. """
  420. resp = await self._rpc(
  421. self._svc.account.refresh_presence(
  422. presence_pb2.RefreshPresenceRequest(), headers=self._headers()
  423. )
  424. )
  425. return PresencePreference.parse(pb_to_dict(resp.preference))
  426. async def set_custom_status(
  427. self,
  428. emoji: str,
  429. text: str,
  430. *,
  431. expires_at: datetime | None = None,
  432. ) -> dict[str, Any]:
  433. req = user_status_pb2.SetCustomStatusRequest(emoji=emoji, text=text)
  434. if expires_at is not None:
  435. req.expires_at.CopyFrom(_timestamp_pb(expires_at))
  436. resp = await self._rpc(self._svc.account.set_custom_status(req, headers=self._headers()))
  437. return pb_to_dict(resp)
  438. async def delete_custom_status(self) -> dict[str, Any]:
  439. resp = await self._rpc(
  440. self._svc.account.delete_custom_status(
  441. user_status_pb2.DeleteCustomStatusRequest(), headers=self._headers()
  442. )
  443. )
  444. return pb_to_dict(resp)
  445. # --- Roles (public) -----------------------------------------------
  446. async def list_roles(self) -> list[Role]:
  447. resp = await self._rpc(
  448. self._svc.roles.list_roles(roles_pb2.ListRolesRequest(), headers=self._headers())
  449. )
  450. data = pb_to_dict(resp)
  451. return [r for r in (Role.parse(row) for row in data.get("roles") or []) if r is not None]
  452. async def get_role(self, name: str) -> Role | None:
  453. resp = await self._rpc(
  454. self._svc.roles.get_role(roles_pb2.GetRoleRequest(name=name), headers=self._headers())
  455. )
  456. return Role.parse(pb_to_dict(resp.role))
  457. async def batch_get_roles(self, names: list[str]) -> list[Role]:
  458. resp = await self._rpc(
  459. self._svc.roles.batch_get_roles(
  460. roles_pb2.BatchGetRolesRequest(names=names), headers=self._headers()
  461. )
  462. )
  463. data = pb_to_dict(resp)
  464. return [r for r in (Role.parse(row) for row in data.get("roles") or []) if r is not None]
  465. # --- Room directory ------------------------------------------------
  466. async def list_rooms(
  467. self, scope: RoomDirectoryScope = RoomDirectoryScope.ALL
  468. ) -> list[RoomWithViewerState]:
  469. resp = await self._rpc(
  470. self._svc.room_directory.list_rooms(
  471. room_directory_pb2.ListRoomsRequest(scope=scope.value),
  472. headers=self._headers(),
  473. )
  474. )
  475. data = pb_to_dict(resp)
  476. return [
  477. r
  478. for r in (RoomWithViewerState.parse(row) for row in data.get("rooms") or [])
  479. if r is not None
  480. ]
  481. async def list_room_groups(self) -> list[RoomGroup]:
  482. resp = await self._rpc(
  483. self._svc.room_directory.list_room_groups(
  484. room_directory_pb2.ListRoomGroupsRequest(),
  485. headers=self._headers(),
  486. )
  487. )
  488. data = pb_to_dict(resp)
  489. return [
  490. g for g in (RoomGroup.parse(row) for row in data.get("groups") or []) if g is not None
  491. ]
  492. async def get_room_group(self, group_id: str) -> RoomGroup | None:
  493. resp = await self._rpc(
  494. self._svc.room_directory.get_room_group(
  495. room_directory_pb2.GetRoomGroupRequest(group_id=group_id),
  496. headers=self._headers(),
  497. )
  498. )
  499. return RoomGroup.parse(pb_to_dict(resp.group))
  500. async def batch_get_room_groups(self, group_ids: list[str]) -> list[RoomGroup]:
  501. resp = await self._rpc(
  502. self._svc.room_directory.batch_get_room_groups(
  503. room_directory_pb2.BatchGetRoomGroupsRequest(group_ids=group_ids),
  504. headers=self._headers(),
  505. )
  506. )
  507. data = pb_to_dict(resp)
  508. return [
  509. g for g in (RoomGroup.parse(row) for row in data.get("groups") or []) if g is not None
  510. ]
  511. async def get_room(self, room_id: str) -> RoomWithViewerState | None:
  512. resp = await self._rpc(
  513. self._svc.room_directory.get_room(
  514. room_directory_pb2.GetRoomRequest(room_id=room_id),
  515. headers=self._headers(),
  516. )
  517. )
  518. return RoomWithViewerState.parse(pb_to_dict(resp.room))
  519. async def batch_get_rooms(self, room_ids: list[str]) -> list[RoomWithViewerState]:
  520. resp = await self._rpc(
  521. self._svc.room_directory.batch_get_rooms(
  522. room_directory_pb2.BatchGetRoomsRequest(room_ids=room_ids),
  523. headers=self._headers(),
  524. )
  525. )
  526. data = pb_to_dict(resp)
  527. return [
  528. r
  529. for r in (RoomWithViewerState.parse(row) for row in data.get("rooms") or [])
  530. if r is not None
  531. ]
  532. # --- Room lifecycle & membership -----------------------------------
  533. async def create_room(
  534. self,
  535. name: str,
  536. group_id: str,
  537. *,
  538. description: str = "",
  539. universal: bool = False,
  540. threading_mode: RoomThreadingMode | None = None,
  541. ) -> Room:
  542. req = rooms_pb2.CreateRoomRequest(
  543. name=name,
  544. group_id=group_id,
  545. description=description,
  546. universal=universal,
  547. )
  548. if threading_mode is not None:
  549. req.threading_mode = threading_mode.value
  550. resp = await self._rpc(self._svc.rooms.create_room(req, headers=self._headers()))
  551. room = Room.parse(pb_to_dict(resp.room))
  552. assert room is not None
  553. return room
  554. async def update_room(
  555. self,
  556. room_id: str,
  557. *,
  558. name: str | None = None,
  559. description: str | None = None,
  560. universal: bool | None = None,
  561. slow_mode_seconds: int | None = None,
  562. threading_mode: RoomThreadingMode | None = None,
  563. ) -> Room:
  564. req = rooms_pb2.UpdateRoomRequest(room_id=room_id)
  565. if name is not None:
  566. req.name = name
  567. if description is not None:
  568. req.description = description
  569. if universal is not None:
  570. req.universal = universal
  571. if slow_mode_seconds is not None:
  572. req.slow_mode_seconds = slow_mode_seconds
  573. if threading_mode is not None:
  574. req.threading_mode = threading_mode.value
  575. resp = await self._rpc(self._svc.rooms.update_room(req, headers=self._headers()))
  576. room = Room.parse(pb_to_dict(resp.room))
  577. assert room is not None
  578. return room
  579. async def archive_room(self, room_id: str) -> Room:
  580. resp = await self._rpc(
  581. self._svc.rooms.archive_room(
  582. rooms_pb2.ArchiveRoomRequest(room_id=room_id),
  583. headers=self._headers(),
  584. )
  585. )
  586. room = Room.parse(pb_to_dict(resp.room))
  587. assert room is not None
  588. return room
  589. async def unarchive_room(self, room_id: str) -> Room:
  590. resp = await self._rpc(
  591. self._svc.rooms.unarchive_room(
  592. rooms_pb2.UnarchiveRoomRequest(room_id=room_id),
  593. headers=self._headers(),
  594. )
  595. )
  596. room = Room.parse(pb_to_dict(resp.room))
  597. assert room is not None
  598. return room
  599. async def join_room(self, room_id: str) -> Room:
  600. resp = await self._rpc(
  601. self._svc.rooms.join_room(
  602. rooms_pb2.JoinRoomRequest(room_id=room_id),
  603. headers=self._headers(),
  604. )
  605. )
  606. room = Room.parse(pb_to_dict(resp.room))
  607. assert room is not None
  608. return room
  609. async def join_room_group(self, group_id: str) -> list[str]:
  610. resp = await self._rpc(
  611. self._svc.rooms.join_room_group(
  612. rooms_pb2.JoinRoomGroupRequest(group_id=group_id),
  613. headers=self._headers(),
  614. )
  615. )
  616. return list(resp.joined_room_ids)
  617. async def start_dm(self, participant_ids: list[str]) -> Room:
  618. resp = await self._rpc(
  619. self._svc.rooms.start_dm(
  620. rooms_pb2.StartDMRequest(participant_ids=participant_ids),
  621. headers=self._headers(),
  622. )
  623. )
  624. room = Room.parse(pb_to_dict(resp.room))
  625. assert room is not None
  626. return room
  627. async def leave_room(self, room_id: str) -> None:
  628. await self._rpc(
  629. self._svc.rooms.leave_room(
  630. rooms_pb2.LeaveRoomRequest(room_id=room_id),
  631. headers=self._headers(),
  632. )
  633. )
  634. async def add_member(self, room_id: str, user_id: str) -> DirectoryMember | None:
  635. resp = await self._rpc(
  636. self._svc.rooms.add_member(
  637. rooms_pb2.AddMemberRequest(room_id=room_id, user_id=user_id),
  638. headers=self._headers(),
  639. )
  640. )
  641. return DirectoryMember.parse(pb_to_dict(resp.member))
  642. async def remove_member(self, room_id: str, user_id: str) -> bool:
  643. resp = await self._rpc(
  644. self._svc.rooms.remove_member(
  645. rooms_pb2.RemoveMemberRequest(room_id=room_id, user_id=user_id),
  646. headers=self._headers(),
  647. )
  648. )
  649. return resp.removed
  650. async def list_room_members(
  651. self,
  652. room_id: str,
  653. *,
  654. search: str = "",
  655. presence_statuses: list[PresenceStatus] | None = None,
  656. limit: int | None = None,
  657. offset: int | None = None,
  658. ) -> tuple[list[str], Page]:
  659. """List a room's member IDs (plus page metadata).
  660. The server returns member IDs only, not full profiles; hydrate them
  661. with :meth:`batch_get_room_members`. ``presence_statuses`` filters to
  662. members in any of the given connected states (max 4).
  663. """
  664. req = member_directory_pb2.ListMembersRequest(room_id=room_id, search=search)
  665. if presence_statuses:
  666. req.presence_statuses.extend(
  667. presence_pb2.PresenceStatus.Value(p.value) for p in presence_statuses
  668. )
  669. page = _page_pb(limit, offset)
  670. if page is not None:
  671. req.page.CopyFrom(page)
  672. resp = await self._rpc(self._svc.rooms.list_members(req, headers=self._headers()))
  673. data = pb_to_dict(resp)
  674. return list(data.get("userIds") or []), Page.parse(data.get("page"))
  675. async def get_room_member(self, room_id: str, user_id: str) -> DirectoryMember | None:
  676. resp = await self._rpc(
  677. self._svc.rooms.get_member(
  678. member_directory_pb2.GetMemberRequest(room_id=room_id, user_id=user_id),
  679. headers=self._headers(),
  680. )
  681. )
  682. return DirectoryMember.parse(pb_to_dict(resp.member))
  683. async def batch_get_room_members(
  684. self, room_id: str, user_ids: list[str]
  685. ) -> list[DirectoryMember]:
  686. resp = await self._rpc(
  687. self._svc.rooms.batch_get_members(
  688. member_directory_pb2.BatchGetMembersRequest(room_id=room_id, user_ids=user_ids),
  689. headers=self._headers(),
  690. )
  691. )
  692. data = pb_to_dict(resp)
  693. return [
  694. m
  695. for m in (DirectoryMember.parse(row) for row in data.get("members") or [])
  696. if m is not None
  697. ]
  698. async def remove_user(
  699. self,
  700. room_id: str,
  701. user_id: str,
  702. reason: str,
  703. *,
  704. suspension_expires_at: datetime | None = None,
  705. suspend_indefinitely: bool = False,
  706. ) -> None:
  707. """Remove a current room member, optionally suspending them.
  708. Maps to ``RoomService.RemoveUser`` (the 0.5.0b7 replacement for
  709. ``BanMember``). Without a suspension the user may rejoin under normal
  710. room permissions; with one, they cannot rejoin until the suspension
  711. expires or ``lift_suspension`` is called. At most one of the two
  712. suspension choices may be given.
  713. """
  714. if suspend_indefinitely and suspension_expires_at is not None:
  715. raise ValueError(
  716. "choose either suspension_expires_at or suspend_indefinitely, not both"
  717. )
  718. req = rooms_pb2.RemoveUserRequest(room_id=room_id, user_id=user_id, reason=reason)
  719. if suspend_indefinitely:
  720. req.suspend_indefinitely = True
  721. elif suspension_expires_at is not None:
  722. req.suspension_expires_at.CopyFrom(_timestamp_pb(suspension_expires_at))
  723. await self._rpc(self._svc.rooms.remove_user(req, headers=self._headers()))
  724. async def lift_suspension(self, room_id: str, user_id: str, reason: str) -> None:
  725. await self._rpc(
  726. self._svc.rooms.lift_suspension(
  727. rooms_pb2.LiftSuspensionRequest(room_id=room_id, user_id=user_id, reason=reason),
  728. headers=self._headers(),
  729. )
  730. )
  731. async def list_suspensions(
  732. self,
  733. *,
  734. room_id: str = "",
  735. limit: int | None = None,
  736. offset: int | None = None,
  737. ) -> tuple[list[RoomSuspension], Page]:
  738. req = rooms_pb2.ListSuspensionsRequest(room_id=room_id)
  739. page = _page_pb(limit, offset)
  740. if page is not None:
  741. req.page.CopyFrom(page)
  742. resp = await self._rpc(self._svc.rooms.list_suspensions(req, headers=self._headers()))
  743. data = pb_to_dict(resp)
  744. suspensions = [
  745. s
  746. for s in (RoomSuspension.parse(row) for row in data.get("suspensions") or [])
  747. if s is not None
  748. ]
  749. return suspensions, Page.parse(data.get("page"))
  750. async def refresh_typing_indicator(
  751. self, room_id: str, *, thread_root_event_id: str = ""
  752. ) -> None:
  753. await self._rpc(
  754. self._svc.rooms.refresh_typing_indicator(
  755. rooms_pb2.RefreshTypingIndicatorRequest(
  756. room_id=room_id, thread_root_event_id=thread_root_event_id
  757. ),
  758. headers=self._headers(),
  759. )
  760. )
  761. # --- Room timeline / read state -----------------------------------
  762. async def get_room_events(
  763. self,
  764. room_id: str,
  765. *,
  766. limit: int | None = None,
  767. before: str | None = None,
  768. after: str | None = None,
  769. ) -> TimelinePage:
  770. req = room_timeline_pb2.GetRoomEventsRequest(room_id=room_id)
  771. if limit is not None:
  772. req.limit = limit
  773. if before is not None:
  774. req.before = before
  775. elif after is not None:
  776. req.after = after
  777. resp = await self._rpc(self._svc.rooms.get_room_events(req, headers=self._headers()))
  778. return TimelinePage.parse(pb_to_dict(resp.page))
  779. async def get_room_events_around(
  780. self,
  781. room_id: str,
  782. event_id: str,
  783. *,
  784. limit: int | None = None,
  785. ) -> tuple[TimelinePage, int]:
  786. req = room_timeline_pb2.GetRoomEventsAroundRequest(room_id=room_id, event_id=event_id)
  787. if limit is not None:
  788. req.limit = limit
  789. resp = await self._rpc(self._svc.rooms.get_room_events_around(req, headers=self._headers()))
  790. return TimelinePage.parse(pb_to_dict(resp.page)), resp.target_index
  791. async def mark_room_as_read(
  792. self, room_id: str, up_to_event_id: str = ""
  793. ) -> tuple[datetime | None, datetime | None]:
  794. resp = await self._rpc(
  795. self._svc.rooms.mark_room_as_read(
  796. read_state_pb2.MarkRoomAsReadRequest(
  797. room_id=room_id, up_to_event_id=up_to_event_id
  798. ),
  799. headers=self._headers(),
  800. )
  801. )
  802. d = pb_to_dict(resp)
  803. return (
  804. parse_datetime(d.get("lastReadAt")),
  805. parse_datetime(d.get("previousLastReadAt")),
  806. )
  807. async def list_room_attachments(
  808. self,
  809. room_id: str,
  810. *,
  811. thumbnail: ImageTransformOptions | None = None,
  812. limit: int | None = None,
  813. offset: int | None = None,
  814. ) -> tuple[list[dict[str, Any]], Page]:
  815. req = rooms_pb2.ListRoomAttachmentsRequest(room_id=room_id)
  816. thumb = _thumbnail_pb(thumbnail)
  817. if thumb is not None:
  818. req.thumbnail.CopyFrom(thumb)
  819. page = _page_pb(limit, offset)
  820. if page is not None:
  821. req.page.CopyFrom(page)
  822. resp = await self._rpc(self._svc.rooms.list_room_attachments(req, headers=self._headers()))
  823. data = pb_to_dict(resp)
  824. return list(data.get("attachments") or []), Page.parse(data.get("page"))
  825. async def list_pinned_messages(
  826. self,
  827. room_id: str,
  828. *,
  829. limit: int | None = None,
  830. offset: int | None = None,
  831. ) -> PinnedMessagesPage:
  832. req = rooms_pb2.ListPinnedMessagesRequest(room_id=room_id)
  833. page = _page_pb(limit, offset)
  834. if page is not None:
  835. req.page.CopyFrom(page)
  836. resp = await self._rpc(self._svc.rooms.list_pinned_messages(req, headers=self._headers()))
  837. return PinnedMessagesPage.parse(pb_to_dict(resp))
  838. async def pin_message(self, room_id: str, message_event_id: str) -> PinnedMessage:
  839. resp = await self._rpc(
  840. self._svc.rooms.create_pinned_message(
  841. rooms_pb2.CreatePinnedMessageRequest(
  842. room_id=room_id, message_event_id=message_event_id
  843. ),
  844. headers=self._headers(),
  845. )
  846. )
  847. pm = PinnedMessage.parse(pb_to_dict(resp.pinned_message))
  848. assert pm is not None
  849. return pm
  850. async def unpin_message(self, room_id: str, message_event_id: str) -> bool:
  851. resp = await self._rpc(
  852. self._svc.rooms.delete_pinned_message(
  853. rooms_pb2.DeletePinnedMessageRequest(
  854. room_id=room_id, message_event_id=message_event_id
  855. ),
  856. headers=self._headers(),
  857. )
  858. )
  859. return resp.deleted
  860. # --- Messages -------------------------------------------------------
  861. async def fetch_link_preview(self, url: str) -> tuple[LinkPreview | None, str]:
  862. resp = await self._rpc(
  863. self._svc.messages.fetch_link_preview(
  864. link_previews_pb2.FetchLinkPreviewRequest(url=url),
  865. headers=self._headers(),
  866. )
  867. )
  868. return LinkPreview.parse(pb_to_dict(resp.preview)), resp.preview_token
  869. async def post_message(
  870. self,
  871. room_id: str,
  872. body: str = "",
  873. *,
  874. attachment_asset_ids: list[str] | None = None,
  875. thread_root_event_id: str = "",
  876. in_reply_to: str = "",
  877. also_send_to_channel: bool = False,
  878. link_preview_token: str = "",
  879. ) -> Message:
  880. req = messages_pb2.CreateMessageRequest(
  881. room_id=room_id,
  882. body=body,
  883. thread_root_event_id=thread_root_event_id,
  884. in_reply_to=in_reply_to,
  885. also_send_to_channel=also_send_to_channel,
  886. link_preview_token=link_preview_token,
  887. )
  888. if attachment_asset_ids:
  889. req.attachment_asset_ids.extend(attachment_asset_ids)
  890. resp = await self._rpc(self._svc.messages.create_message(req, headers=self._headers()))
  891. message = Message.parse(pb_to_dict(resp.message))
  892. assert message is not None
  893. return message
  894. async def update_message(
  895. self,
  896. room_id: str,
  897. event_id: str,
  898. *,
  899. body: str | None = None,
  900. also_send_to_channel: bool | None = None,
  901. ) -> Message:
  902. req = messages_pb2.UpdateMessageRequest(room_id=room_id, event_id=event_id)
  903. if body is not None:
  904. req.body = body
  905. if also_send_to_channel is not None:
  906. req.also_send_to_channel = also_send_to_channel
  907. resp = await self._rpc(self._svc.messages.update_message(req, headers=self._headers()))
  908. message = Message.parse(pb_to_dict(resp.message))
  909. assert message is not None
  910. return message
  911. async def delete_message(self, room_id: str, event_id: str) -> None:
  912. await self._rpc(
  913. self._svc.messages.delete_message(
  914. messages_pb2.DeleteMessageRequest(room_id=room_id, event_id=event_id),
  915. headers=self._headers(),
  916. )
  917. )
  918. async def delete_attachment(self, room_id: str, event_id: str, attachment_id: str) -> None:
  919. await self._rpc(
  920. self._svc.messages.delete_attachment(
  921. messages_pb2.DeleteAttachmentRequest(
  922. room_id=room_id, event_id=event_id, attachment_id=attachment_id
  923. ),
  924. headers=self._headers(),
  925. )
  926. )
  927. async def delete_link_preview(self, room_id: str, event_id: str, url: str) -> None:
  928. await self._rpc(
  929. self._svc.messages.delete_link_preview(
  930. messages_pb2.DeleteLinkPreviewRequest(room_id=room_id, event_id=event_id, url=url),
  931. headers=self._headers(),
  932. )
  933. )
  934. async def get_message(self, room_id: str, event_id: str) -> Message | None:
  935. resp = await self._rpc(
  936. self._svc.messages.get_message(
  937. messages_pb2.GetMessageRequest(room_id=room_id, event_id=event_id),
  938. headers=self._headers(),
  939. )
  940. )
  941. return Message.parse(pb_to_dict(resp.message))
  942. async def batch_get_messages(self, room_id: str, event_ids: list[str]) -> list[Message]:
  943. resp = await self._rpc(
  944. self._svc.messages.batch_get_messages(
  945. messages_pb2.BatchGetMessagesRequest(room_id=room_id, event_ids=event_ids),
  946. headers=self._headers(),
  947. )
  948. )
  949. data = pb_to_dict(resp)
  950. return [
  951. m for m in (Message.parse(row) for row in data.get("messages") or []) if m is not None
  952. ]
  953. async def add_reaction(self, room_id: str, message_event_id: str, emoji: str) -> bool:
  954. resp = await self._rpc(
  955. self._svc.messages.add_reaction(
  956. reactions_pb2.AddReactionRequest(
  957. room_id=room_id, message_event_id=message_event_id, emoji=emoji
  958. ),
  959. headers=self._headers(),
  960. )
  961. )
  962. return resp.added
  963. async def remove_reaction(self, room_id: str, message_event_id: str, emoji: str) -> bool:
  964. resp = await self._rpc(
  965. self._svc.messages.remove_reaction(
  966. reactions_pb2.RemoveReactionRequest(
  967. room_id=room_id, message_event_id=message_event_id, emoji=emoji
  968. ),
  969. headers=self._headers(),
  970. )
  971. )
  972. return resp.removed
  973. # --- Threads --------------------------------------------------------
  974. async def follow_thread(self, room_id: str, thread_root_event_id: str) -> None:
  975. await self._rpc(
  976. self._svc.threads.follow_thread(
  977. threads_pb2.FollowThreadRequest(
  978. room_id=room_id, thread_root_event_id=thread_root_event_id
  979. ),
  980. headers=self._headers(),
  981. )
  982. )
  983. async def unfollow_thread(self, room_id: str, thread_root_event_id: str) -> None:
  984. await self._rpc(
  985. self._svc.threads.unfollow_thread(
  986. threads_pb2.UnfollowThreadRequest(
  987. room_id=room_id, thread_root_event_id=thread_root_event_id
  988. ),
  989. headers=self._headers(),
  990. )
  991. )
  992. async def list_followed_threads(
  993. self,
  994. *,
  995. limit: int | None = None,
  996. offset: int | None = None,
  997. include_direct_message_threads: bool | None = None,
  998. unread_only: bool | None = None,
  999. root_author_filter: ThreadRootAuthorFilter | None = None,
  1000. ) -> FollowedThreadsPage:
  1001. """Followed threads, newest activity first.
  1002. ``unread_only`` returns only threads with unread replies (older
  1003. servers ignore it); ``include_direct_message_threads`` also lists
  1004. followed direct-message threads; ``root_author_filter`` (since
  1005. 0.5.0b16) filters by the author of the thread *root* —
  1006. ``VIEWER`` for threads you started, ``OTHERS`` for threads started
  1007. by someone else (older servers ignore it).
  1008. """
  1009. req = threads_pb2.ListFollowedThreadsRequest()
  1010. page = _page_pb(limit, offset)
  1011. if page is not None:
  1012. req.page.CopyFrom(page)
  1013. if include_direct_message_threads is not None:
  1014. req.include_direct_message_threads = include_direct_message_threads
  1015. if unread_only is not None:
  1016. req.unread_only = unread_only
  1017. if root_author_filter is not None:
  1018. req.root_author_filter = threads_pb2.ThreadRootAuthorFilter.Value(
  1019. root_author_filter.value
  1020. )
  1021. resp = await self._rpc(
  1022. self._svc.threads.list_followed_threads(req, headers=self._headers())
  1023. )
  1024. data = pb_to_dict(resp)
  1025. threads = [FollowedThread.parse(t) for t in data.get("threads") or []]
  1026. users: dict[str, User] = {}
  1027. includes = data.get("includes") or {}
  1028. for uid, user_data in (includes.get("users") or {}).items():
  1029. parsed = User.parse(user_data)
  1030. if parsed is not None:
  1031. users[uid] = parsed
  1032. return FollowedThreadsPage(
  1033. threads=threads, page=Page.parse(data.get("page")), users_by_id=users
  1034. )
  1035. async def get_thread_events(
  1036. self,
  1037. room_id: str,
  1038. thread_root_event_id: str,
  1039. *,
  1040. limit: int | None = None,
  1041. before: str | None = None,
  1042. after: str | None = None,
  1043. ) -> TimelinePage:
  1044. req = room_timeline_pb2.GetThreadEventsRequest(
  1045. room_id=room_id, thread_root_event_id=thread_root_event_id
  1046. )
  1047. if limit is not None:
  1048. req.limit = limit
  1049. if before is not None:
  1050. req.before = before
  1051. elif after is not None:
  1052. req.after = after
  1053. resp = await self._rpc(self._svc.threads.get_thread_events(req, headers=self._headers()))
  1054. return TimelinePage.parse(pb_to_dict(resp.page))
  1055. async def get_thread_events_around(
  1056. self,
  1057. room_id: str,
  1058. thread_root_event_id: str,
  1059. event_id: str,
  1060. *,
  1061. limit: int | None = None,
  1062. ) -> tuple[TimelinePage, int]:
  1063. req = room_timeline_pb2.GetThreadEventsAroundRequest(
  1064. room_id=room_id,
  1065. thread_root_event_id=thread_root_event_id,
  1066. event_id=event_id,
  1067. )
  1068. if limit is not None:
  1069. req.limit = limit
  1070. resp = await self._rpc(
  1071. self._svc.threads.get_thread_events_around(req, headers=self._headers())
  1072. )
  1073. return TimelinePage.parse(pb_to_dict(resp.page)), resp.target_index
  1074. async def mark_thread_as_read(
  1075. self,
  1076. room_id: str,
  1077. thread_root_event_id: str,
  1078. up_to_event_id: str = "",
  1079. ) -> datetime | None:
  1080. resp = await self._rpc(
  1081. self._svc.threads.mark_thread_as_read(
  1082. read_state_pb2.MarkThreadAsReadRequest(
  1083. room_id=room_id,
  1084. thread_root_event_id=thread_root_event_id,
  1085. up_to_event_id=up_to_event_id,
  1086. ),
  1087. headers=self._headers(),
  1088. )
  1089. )
  1090. d = pb_to_dict(resp)
  1091. return parse_datetime(d.get("previousLastReadAt"))
  1092. # --- Notifications --------------------------------------------------
  1093. async def list_notification_occurrences(
  1094. self, *, limit: int | None = None, offset: int | None = None
  1095. ) -> NotificationOccurrencesPage:
  1096. req = notifications_pb2.ListNotificationOccurrencesRequest()
  1097. page = _page_pb(limit, offset)
  1098. if page is not None:
  1099. req.page.CopyFrom(page)
  1100. resp = await self._rpc(
  1101. self._svc.notifications.list_notification_occurrences(req, headers=self._headers())
  1102. )
  1103. return NotificationOccurrencesPage.parse(pb_to_dict(resp))
  1104. async def get_notification_occurrence(
  1105. self, notification_id: str
  1106. ) -> NotificationOccurrence | None:
  1107. resp = await self._rpc(
  1108. self._svc.notifications.get_notification_occurrence(
  1109. notifications_pb2.GetNotificationOccurrenceRequest(notification_id=notification_id),
  1110. headers=self._headers(),
  1111. )
  1112. )
  1113. raw = pb_to_dict(resp).get("occurrence")
  1114. return NotificationOccurrence.parse(raw) if raw else None
  1115. async def batch_get_notification_occurrences(
  1116. self, notification_ids: list[str]
  1117. ) -> list[NotificationOccurrence]:
  1118. resp = await self._rpc(
  1119. self._svc.notifications.batch_get_notification_occurrences(
  1120. notifications_pb2.BatchGetNotificationOccurrencesRequest(
  1121. notification_ids=notification_ids
  1122. ),
  1123. headers=self._headers(),
  1124. )
  1125. )
  1126. data = pb_to_dict(resp)
  1127. return [
  1128. o
  1129. for o in (NotificationOccurrence.parse(n) for n in data.get("occurrences") or [])
  1130. if o is not None
  1131. ]
  1132. async def mark_notification_read(self, notification_id: str) -> NotificationOccurrence | None:
  1133. resp = await self._rpc(
  1134. self._svc.notifications.mark_notification_read(
  1135. notifications_pb2.MarkNotificationReadRequest(notification_id=notification_id),
  1136. headers=self._headers(),
  1137. )
  1138. )
  1139. raw = pb_to_dict(resp).get("occurrence")
  1140. return NotificationOccurrence.parse(raw) if raw else None
  1141. async def delete_notification_occurrence(self, notification_id: str) -> bool:
  1142. resp = await self._rpc(
  1143. self._svc.notifications.delete_notification_occurrence(
  1144. notifications_pb2.DeleteNotificationOccurrenceRequest(
  1145. notification_id=notification_id
  1146. ),
  1147. headers=self._headers(),
  1148. )
  1149. )
  1150. return resp.deleted
  1151. async def batch_delete_notification_occurrences(self, notification_ids: list[str]) -> int:
  1152. resp = await self._rpc(
  1153. self._svc.notifications.batch_delete_notification_occurrences(
  1154. notifications_pb2.BatchDeleteNotificationOccurrencesRequest(
  1155. notification_ids=notification_ids
  1156. ),
  1157. headers=self._headers(),
  1158. )
  1159. )
  1160. return resp.deleted_count
  1161. async def delete_all_notification_occurrences(self) -> int:
  1162. resp = await self._rpc(
  1163. self._svc.notifications.delete_all_notification_occurrences(
  1164. notifications_pb2.DeleteAllNotificationOccurrencesRequest(),
  1165. headers=self._headers(),
  1166. )
  1167. )
  1168. return resp.deleted_count
  1169. async def get_notification_policy(
  1170. self, *, room_id: str = "", room_group_id: str = ""
  1171. ) -> NotificationPolicy:
  1172. """Get the notification policy for one scope (server / group / room)."""
  1173. req = notifications_pb2.NotificationPolicyServiceGetNotificationPolicyRequest(
  1174. scope=_policy_scope(room_id=room_id, room_group_id=room_group_id)
  1175. )
  1176. resp = await self._rpc(
  1177. self._svc.notification_policy.get_notification_policy(req, headers=self._headers())
  1178. )
  1179. return NotificationPolicy.parse(pb_to_dict(resp.policy.policy))
  1180. async def batch_get_notification_policies(
  1181. self, scopes: list[dict[str, str]]
  1182. ) -> list[NotificationPolicy]:
  1183. """Get a bounded set of notification policies (one per scope).
  1184. Each scope is a dict with ``room_id`` and/or ``room_group_id`` (empty
  1185. dict selects the server scope).
  1186. """
  1187. req = notifications_pb2.BatchGetNotificationPoliciesRequest()
  1188. for scope in scopes:
  1189. req.scopes.add().CopyFrom(
  1190. _policy_scope(
  1191. room_id=scope.get("room_id", ""),
  1192. room_group_id=scope.get("room_group_id", ""),
  1193. )
  1194. )
  1195. resp = await self._rpc(
  1196. self._svc.notification_policy.batch_get_notification_policies(
  1197. req, headers=self._headers()
  1198. )
  1199. )
  1200. return [NotificationPolicy.parse(pb_to_dict(p.policy)) for p in resp.policies]
  1201. async def update_notification_policy(
  1202. self,
  1203. *,
  1204. room_id: str = "",
  1205. room_group_id: str = "",
  1206. overrides: dict[str, Any] | None = None,
  1207. update_mask: str | None = None,
  1208. ) -> NotificationPolicy:
  1209. """Sparsely set notification-policy delivery-mode overrides at one scope.
  1210. ``overrides`` maps delivery-mode fields (e.g. ``direct_mentions``,
  1211. ``all_mentions``) to their mode names; ``update_mask`` lists the fields
  1212. to set or clear (``*`` selects all).
  1213. """
  1214. req = notifications_pb2.NotificationPolicyServiceUpdateNotificationPolicyRequest(
  1215. scope=_policy_scope(room_id=room_id, room_group_id=room_group_id)
  1216. )
  1217. if overrides:
  1218. from google.protobuf.json_format import ParseDict
  1219. ParseDict(overrides, req.overrides)
  1220. if update_mask:
  1221. req.update_mask.paths.extend(p for p in update_mask.split(",") if p)
  1222. resp = await self._rpc(
  1223. self._svc.notification_policy.update_notification_policy(req, headers=self._headers())
  1224. )
  1225. return NotificationPolicy.parse(pb_to_dict(resp.policy.policy))
  1226. # --- Notification preferences --------------------------------------
  1227. async def get_server_notification_preference(self) -> NotificationPreference:
  1228. resp = await self._rpc(
  1229. self._svc.notification_prefs.get_server_notification_preference(
  1230. notification_preferences_pb2.GetServerNotificationPreferenceRequest(),
  1231. headers=self._headers(),
  1232. )
  1233. )
  1234. return NotificationPreference.parse(pb_to_dict(resp.preference))
  1235. async def update_server_notification_preference(
  1236. self, level: NotificationLevel
  1237. ) -> NotificationPreference:
  1238. resp = await self._rpc(
  1239. self._svc.notification_prefs.update_server_notification_preference(
  1240. notification_preferences_pb2.UpdateServerNotificationPreferenceRequest(
  1241. level=level.value
  1242. ),
  1243. headers=self._headers(),
  1244. )
  1245. )
  1246. return NotificationPreference.parse(pb_to_dict(resp.preference))
  1247. async def get_room_notification_preference(self, room_id: str) -> NotificationPreference:
  1248. resp = await self._rpc(
  1249. self._svc.notification_prefs.get_room_notification_preference(
  1250. notification_preferences_pb2.GetRoomNotificationPreferenceRequest(room_id=room_id),
  1251. headers=self._headers(),
  1252. )
  1253. )
  1254. return NotificationPreference.parse(pb_to_dict(resp.preference))
  1255. async def update_room_notification_preference(
  1256. self, room_id: str, level: NotificationLevel
  1257. ) -> NotificationPreference:
  1258. resp = await self._rpc(
  1259. self._svc.notification_prefs.update_room_notification_preference(
  1260. notification_preferences_pb2.UpdateRoomNotificationPreferenceRequest(
  1261. room_id=room_id, level=level.value
  1262. ),
  1263. headers=self._headers(),
  1264. )
  1265. )
  1266. return NotificationPreference.parse(pb_to_dict(resp.preference))
  1267. # --- Push notifications --------------------------------------------
  1268. async def subscribe_push(
  1269. self,
  1270. endpoint: str,
  1271. p256dh: str,
  1272. auth: str,
  1273. *,
  1274. user_agent: str | None = None,
  1275. ) -> bool:
  1276. req = push_notifications_pb2.SubscribeRequest(endpoint=endpoint, p256dh=p256dh, auth=auth)
  1277. if user_agent is not None:
  1278. req.user_agent = user_agent
  1279. await self._rpc(self._svc.push.subscribe(req, headers=self._headers()))
  1280. return True
  1281. async def unsubscribe_push(self, endpoint: str) -> bool:
  1282. await self._rpc(
  1283. self._svc.push.unsubscribe(
  1284. push_notifications_pb2.UnsubscribeRequest(endpoint=endpoint),
  1285. headers=self._headers(),
  1286. )
  1287. )
  1288. return True
  1289. # --- Assets ---------------------------------------------------------
  1290. async def get_asset(
  1291. self,
  1292. room_id: str,
  1293. asset_id: str,
  1294. *,
  1295. thumbnail: ImageTransformOptions | None = None,
  1296. ) -> Asset | None:
  1297. req = attachments_pb2.GetAssetRequest(room_id=room_id, asset_id=asset_id)
  1298. thumb = _thumbnail_pb(thumbnail)
  1299. if thumb is not None:
  1300. req.thumbnail.CopyFrom(thumb)
  1301. resp = await self._rpc(self._svc.assets.get_asset(req, headers=self._headers()))
  1302. return Asset.parse(pb_to_dict(resp.asset))
  1303. async def batch_get_assets(
  1304. self,
  1305. room_id: str,
  1306. asset_ids: list[str],
  1307. *,
  1308. thumbnail: ImageTransformOptions | None = None,
  1309. ) -> list[Asset]:
  1310. req = attachments_pb2.BatchGetAssetsRequest(room_id=room_id, asset_ids=asset_ids)
  1311. thumb = _thumbnail_pb(thumbnail)
  1312. if thumb is not None:
  1313. req.thumbnail.CopyFrom(thumb)
  1314. resp = await self._rpc(self._svc.assets.batch_get_assets(req, headers=self._headers()))
  1315. data = pb_to_dict(resp)
  1316. return [a for a in (Asset.parse(row) for row in data.get("assets") or []) if a is not None]
  1317. # --- Asset uploads ------------------------------------------------
  1318. async def create_upload(
  1319. self,
  1320. room_id: str,
  1321. filename: str,
  1322. size: int,
  1323. sha256: str,
  1324. *,
  1325. content_type: str = "",
  1326. ) -> AssetUpload:
  1327. resp = await self._rpc(
  1328. self._svc.asset_uploads.create_upload(
  1329. asset_uploads_pb2.CreateUploadRequest(
  1330. room_id=room_id,
  1331. filename=filename,
  1332. content_type=content_type,
  1333. size=size,
  1334. sha256=sha256,
  1335. ),
  1336. headers=self._headers(),
  1337. )
  1338. )
  1339. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1340. assert upload is not None
  1341. return upload
  1342. async def upload_chunk(
  1343. self, upload_id: str, offset: int, content: bytes, chunk_sha256: str
  1344. ) -> AssetUpload:
  1345. resp = await self._rpc(
  1346. self._svc.asset_uploads.upload_chunk(
  1347. asset_uploads_pb2.UploadChunkRequest(
  1348. upload_id=upload_id,
  1349. offset=offset,
  1350. content=content,
  1351. chunk_sha256=chunk_sha256,
  1352. ),
  1353. headers=self._headers(),
  1354. )
  1355. )
  1356. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1357. assert upload is not None
  1358. return upload
  1359. async def get_upload(self, upload_id: str) -> AssetUpload:
  1360. resp = await self._rpc(
  1361. self._svc.asset_uploads.get_upload(
  1362. asset_uploads_pb2.GetUploadRequest(upload_id=upload_id),
  1363. headers=self._headers(),
  1364. )
  1365. )
  1366. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1367. assert upload is not None
  1368. return upload
  1369. async def complete_upload(self, upload_id: str) -> tuple[AssetUpload, Asset | None]:
  1370. resp = await self._rpc(
  1371. self._svc.asset_uploads.complete_upload(
  1372. asset_uploads_pb2.CompleteUploadRequest(upload_id=upload_id),
  1373. headers=self._headers(),
  1374. )
  1375. )
  1376. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1377. assert upload is not None
  1378. return upload, Asset.parse(pb_to_dict(resp.asset))
  1379. async def cancel_upload(self, upload_id: str) -> AssetUpload:
  1380. resp = await self._rpc(
  1381. self._svc.asset_uploads.cancel_upload(
  1382. asset_uploads_pb2.CancelUploadRequest(upload_id=upload_id),
  1383. headers=self._headers(),
  1384. )
  1385. )
  1386. upload = AssetUpload.parse(pb_to_dict(resp.upload))
  1387. assert upload is not None
  1388. return upload
  1389. async def upload_attachment(
  1390. self,
  1391. room_id: str,
  1392. file_path: str | Path,
  1393. *,
  1394. content_type: str = "",
  1395. filename: str | None = None,
  1396. ) -> Asset:
  1397. """Upload a file as a room attachment and return the resulting Asset."""
  1398. path = Path(file_path)
  1399. data = path.read_bytes()
  1400. size = len(data)
  1401. sha = hashlib.sha256(data).hexdigest()
  1402. upload = await self.create_upload(
  1403. room_id, filename or path.name, size, sha, content_type=content_type
  1404. )
  1405. chunk_size = upload.max_chunk_size or 512 * 1024
  1406. offset = upload.committed_offset
  1407. while offset < size:
  1408. end = min(offset + chunk_size, size)
  1409. chunk = data[offset:end]
  1410. chunk_sha = hashlib.sha256(chunk).hexdigest()
  1411. upload = await self.upload_chunk(upload.upload_id, offset, chunk, chunk_sha)
  1412. if upload.committed_offset <= offset:
  1413. raise ChattoError(
  1414. f"upload stalled at offset {offset} (server reported "
  1415. f"committed_offset={upload.committed_offset})"
  1416. )
  1417. offset = upload.committed_offset
  1418. upload, asset = await self.complete_upload(upload.upload_id)
  1419. if asset is None:
  1420. raise ChattoError("upload completed but server returned no asset")
  1421. return asset
  1422. # --- Voice calls ----------------------------------------------------
  1423. async def list_active_calls(self) -> list[ActiveCall]:
  1424. resp = await self._rpc(
  1425. self._svc.voice_calls.list_active_calls(
  1426. voice_calls_pb2.ListActiveCallsRequest(), headers=self._headers()
  1427. )
  1428. )
  1429. data = pb_to_dict(resp)
  1430. return [ActiveCall.parse(c) for c in data.get("calls") or []]
  1431. async def get_active_call(self, room_id: str) -> ActiveCall | None:
  1432. resp = await self._rpc(
  1433. self._svc.voice_calls.get_active_call(
  1434. voice_calls_pb2.GetActiveCallRequest(room_id=room_id),
  1435. headers=self._headers(),
  1436. )
  1437. )
  1438. raw = pb_to_dict(resp).get("call")
  1439. return ActiveCall.parse(raw) if raw else None
  1440. async def batch_get_active_calls(self, room_ids: list[str]) -> list[ActiveCall]:
  1441. resp = await self._rpc(
  1442. self._svc.voice_calls.batch_get_active_calls(
  1443. voice_calls_pb2.BatchGetActiveCallsRequest(room_ids=room_ids),
  1444. headers=self._headers(),
  1445. )
  1446. )
  1447. data = pb_to_dict(resp)
  1448. return [ActiveCall.parse(c) for c in data.get("calls") or []]
  1449. async def join_call(self, room_id: str) -> bool:
  1450. resp = await self._rpc(
  1451. self._svc.voice_calls.join_call(
  1452. voice_calls_pb2.JoinCallRequest(room_id=room_id),
  1453. headers=self._headers(),
  1454. )
  1455. )
  1456. return resp.joined
  1457. async def leave_call(self, room_id: str) -> bool:
  1458. resp = await self._rpc(
  1459. self._svc.voice_calls.leave_call(
  1460. voice_calls_pb2.LeaveCallRequest(room_id=room_id),
  1461. headers=self._headers(),
  1462. )
  1463. )
  1464. return resp.left
  1465. async def create_call_token(self, room_id: str) -> str:
  1466. resp = await self._rpc(
  1467. self._svc.voice_calls.create_call_token(
  1468. voice_calls_pb2.CreateCallTokenRequest(room_id=room_id),
  1469. headers=self._headers(),
  1470. )
  1471. )
  1472. return resp.token
  1473. # --- Permissions ----------------------------------------------------
  1474. async def list_effective_permissions(self, user_id: str) -> list[dict[str, Any]]:
  1475. """List every permission decision that applies to one user.
  1476. Returns raw protobuf JSON dicts (the decision shape is
  1477. server-version-dependent).
  1478. """
  1479. resp = await self._rpc(
  1480. self._svc.permissions.list_effective_permissions(
  1481. permissions_pb2.ListEffectivePermissionsRequest(user_id=user_id),
  1482. headers=self._headers(),
  1483. )
  1484. )
  1485. data = pb_to_dict(resp)
  1486. return list(data.get("permissions") or [])
  1487. # --- Admin: server --------------------------------------------------
  1488. async def admin_get_server_config(self) -> tuple[ServerConfig, ServerProfile]:
  1489. resp = await self._rpc(
  1490. self._svc.admin_server.get_server_config(
  1491. admin_server_pb2.GetServerConfigRequest(),
  1492. headers=self._headers(),
  1493. )
  1494. )
  1495. return (
  1496. ServerConfig.parse(pb_to_dict(resp.config)),
  1497. ServerProfile.parse(pb_to_dict(resp.public_profile)),
  1498. )
  1499. async def admin_update_server_config(
  1500. self,
  1501. *,
  1502. server_name: str | None = None,
  1503. description: str | None = None,
  1504. motd: str | None = None,
  1505. welcome_message: str | None = None,
  1506. ) -> tuple[ServerConfig, ServerProfile]:
  1507. req = admin_server_pb2.UpdateServerConfigRequest()
  1508. if server_name is not None:
  1509. req.server_name = server_name
  1510. if description is not None:
  1511. req.description = description
  1512. if motd is not None:
  1513. req.motd = motd
  1514. if welcome_message is not None:
  1515. req.welcome_message = welcome_message
  1516. resp = await self._rpc(
  1517. self._svc.admin_server.update_server_config(req, headers=self._headers())
  1518. )
  1519. return (
  1520. ServerConfig.parse(pb_to_dict(resp.config)),
  1521. ServerProfile.parse(pb_to_dict(resp.public_profile)),
  1522. )
  1523. async def admin_upload_server_logo(
  1524. self,
  1525. file_path: str | Path,
  1526. *,
  1527. content_type: str = "image/png",
  1528. ) -> ServerProfile:
  1529. p = Path(file_path)
  1530. req = admin_server_pb2.UploadServerLogoRequest(
  1531. image=common_pb2.ImageUpload(
  1532. image=p.read_bytes(), filename=p.name, content_type=content_type
  1533. )
  1534. )
  1535. resp = await self._rpc(
  1536. self._svc.admin_server.upload_server_logo(req, headers=self._headers())
  1537. )
  1538. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1539. async def admin_delete_server_logo(self) -> ServerProfile:
  1540. resp = await self._rpc(
  1541. self._svc.admin_server.delete_server_logo(
  1542. admin_server_pb2.DeleteServerLogoRequest(),
  1543. headers=self._headers(),
  1544. )
  1545. )
  1546. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1547. async def admin_upload_server_banner(
  1548. self,
  1549. file_path: str | Path,
  1550. *,
  1551. content_type: str = "image/png",
  1552. ) -> ServerProfile:
  1553. p = Path(file_path)
  1554. req = admin_server_pb2.UploadServerBannerRequest(
  1555. image=common_pb2.ImageUpload(
  1556. image=p.read_bytes(), filename=p.name, content_type=content_type
  1557. )
  1558. )
  1559. resp = await self._rpc(
  1560. self._svc.admin_server.upload_server_banner(req, headers=self._headers())
  1561. )
  1562. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1563. async def admin_delete_server_banner(self) -> ServerProfile:
  1564. resp = await self._rpc(
  1565. self._svc.admin_server.delete_server_banner(
  1566. admin_server_pb2.DeleteServerBannerRequest(),
  1567. headers=self._headers(),
  1568. )
  1569. )
  1570. return ServerProfile.parse(pb_to_dict(resp.public_profile))
  1571. async def admin_get_server_security_config(self) -> list[str]:
  1572. resp = await self._rpc(
  1573. self._svc.admin_server.get_server_security_config(
  1574. admin_server_pb2.GetServerSecurityConfigRequest(),
  1575. headers=self._headers(),
  1576. )
  1577. )
  1578. return list(resp.blocked_usernames)
  1579. async def admin_update_blocked_usernames(self, usernames: list[str]) -> list[str]:
  1580. resp = await self._rpc(
  1581. self._svc.admin_server.update_blocked_usernames(
  1582. admin_server_pb2.UpdateBlockedUsernamesRequest(blocked_usernames=usernames),
  1583. headers=self._headers(),
  1584. )
  1585. )
  1586. return list(resp.blocked_usernames)
  1587. # --- Admin: neighbors ---------------------------------------------
  1588. async def admin_list_neighbors(self) -> list[Neighbor]:
  1589. """List configured Neighbors. Requires ``server.manage-neighbors``."""
  1590. resp = await self._rpc(
  1591. self._svc.admin_server.list_neighbors(
  1592. admin_server_pb2.ListNeighborsRequest(),
  1593. headers=self._headers(),
  1594. )
  1595. )
  1596. data = pb_to_dict(resp)
  1597. return [n for n in (Neighbor.parse(x) for x in data.get("neighbors") or []) if n]
  1598. async def admin_get_neighbor(self, neighbor_id: str) -> Neighbor | None:
  1599. """Get one configured Neighbor. Requires ``server.manage-neighbors``."""
  1600. resp = await self._rpc(
  1601. self._svc.admin_server.get_neighbor(
  1602. admin_server_pb2.GetNeighborRequest(neighbor_id=neighbor_id),
  1603. headers=self._headers(),
  1604. )
  1605. )
  1606. return Neighbor.parse(pb_to_dict(resp).get("neighbor"))
  1607. async def admin_create_neighbor(self, origin: str) -> Neighbor:
  1608. """Advertise one server origin. Requires ``server.manage-neighbors``."""
  1609. resp = await self._rpc(
  1610. self._svc.admin_server.create_neighbor(
  1611. admin_server_pb2.CreateNeighborRequest(origin=origin),
  1612. headers=self._headers(),
  1613. )
  1614. )
  1615. neighbor = Neighbor.parse(pb_to_dict(resp).get("neighbor"))
  1616. assert neighbor is not None
  1617. return neighbor
  1618. async def admin_update_neighbor(self, neighbor_id: str, origin: str, revision: str) -> Neighbor:
  1619. """Change one advertised origin. Requires ``server.manage-neighbors``."""
  1620. resp = await self._rpc(
  1621. self._svc.admin_server.update_neighbor(
  1622. admin_server_pb2.UpdateNeighborRequest(
  1623. neighbor_id=neighbor_id, origin=origin, revision=revision
  1624. ),
  1625. headers=self._headers(),
  1626. )
  1627. )
  1628. neighbor = Neighbor.parse(pb_to_dict(resp).get("neighbor"))
  1629. assert neighbor is not None
  1630. return neighbor
  1631. async def admin_delete_neighbor(self, neighbor_id: str, revision: str) -> None:
  1632. """Stop advertising one origin. Requires ``server.manage-neighbors``."""
  1633. await self._rpc(
  1634. self._svc.admin_server.delete_neighbor(
  1635. admin_server_pb2.DeleteNeighborRequest(neighbor_id=neighbor_id, revision=revision),
  1636. headers=self._headers(),
  1637. )
  1638. )
  1639. # --- Admin: room layout & sidebar links ---------------------------
  1640. async def admin_list_room_groups(self) -> list[AdminRoomLayoutGroup]:
  1641. resp = await self._rpc(
  1642. self._svc.admin_room_layout.list_room_groups(
  1643. room_layout_pb2.ListRoomGroupsRequest(),
  1644. headers=self._headers(),
  1645. )
  1646. )
  1647. data = pb_to_dict(resp)
  1648. return [
  1649. g
  1650. for g in (AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or [])
  1651. if g is not None
  1652. ]
  1653. async def admin_create_room_group(
  1654. self, name: str, description: str = ""
  1655. ) -> AdminRoomLayoutGroup:
  1656. resp = await self._rpc(
  1657. self._svc.admin_room_layout.create_room_group(
  1658. room_layout_pb2.CreateRoomGroupRequest(name=name, description=description),
  1659. headers=self._headers(),
  1660. )
  1661. )
  1662. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1663. assert group is not None
  1664. return group
  1665. async def admin_update_room_group(
  1666. self,
  1667. group_id: str,
  1668. *,
  1669. name: str | None = None,
  1670. description: str | None = None,
  1671. ) -> AdminRoomLayoutGroup:
  1672. req = room_layout_pb2.UpdateRoomGroupRequest(group_id=group_id)
  1673. if name is not None:
  1674. req.name = name
  1675. if description is not None:
  1676. req.description = description
  1677. resp = await self._rpc(
  1678. self._svc.admin_room_layout.update_room_group(req, headers=self._headers())
  1679. )
  1680. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1681. assert group is not None
  1682. return group
  1683. async def admin_delete_room_group(self, group_id: str) -> bool:
  1684. resp = await self._rpc(
  1685. self._svc.admin_room_layout.delete_room_group(
  1686. room_layout_pb2.DeleteRoomGroupRequest(group_id=group_id),
  1687. headers=self._headers(),
  1688. )
  1689. )
  1690. return resp.deleted
  1691. async def admin_reorder_room_groups(
  1692. self, ordered_group_ids: list[str]
  1693. ) -> list[AdminRoomLayoutGroup]:
  1694. resp = await self._rpc(
  1695. self._svc.admin_room_layout.reorder_room_groups(
  1696. room_layout_pb2.ReorderRoomGroupsRequest(ordered_group_ids=ordered_group_ids),
  1697. headers=self._headers(),
  1698. )
  1699. )
  1700. data = pb_to_dict(resp)
  1701. return [
  1702. g
  1703. for g in (AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or [])
  1704. if g is not None
  1705. ]
  1706. async def admin_move_room_group(
  1707. self, group_id: str, before_group_id: str | None = None
  1708. ) -> list[AdminRoomLayoutGroup]:
  1709. """Move one room group before another (or to the end if omitted)."""
  1710. req = room_layout_pb2.MoveRoomGroupRequest(group_id=group_id)
  1711. if before_group_id is not None:
  1712. req.before_group_id = before_group_id
  1713. resp = await self._rpc(
  1714. self._svc.admin_room_layout.move_room_group(req, headers=self._headers())
  1715. )
  1716. data = pb_to_dict(resp)
  1717. return [
  1718. g
  1719. for g in (AdminRoomLayoutGroup.parse(row) for row in data.get("groups") or [])
  1720. if g is not None
  1721. ]
  1722. async def admin_move_room_to_group(self, room_id: str, group_id: str) -> Room:
  1723. resp = await self._rpc(
  1724. self._svc.admin_room_layout.move_room_to_group(
  1725. room_layout_pb2.MoveRoomToGroupRequest(room_id=room_id, group_id=group_id),
  1726. headers=self._headers(),
  1727. )
  1728. )
  1729. room = Room.parse(pb_to_dict(resp.room))
  1730. assert room is not None
  1731. return room
  1732. async def admin_reorder_sidebar_items_in_group(
  1733. self,
  1734. group_id: str,
  1735. items: list[tuple[str, str]],
  1736. ) -> AdminRoomLayoutGroup:
  1737. req = room_layout_pb2.ReorderSidebarItemsInGroupRequest(group_id=group_id)
  1738. for kind, item_id in items:
  1739. _layout_item(req.items.add(), kind, item_id)
  1740. resp = await self._rpc(
  1741. self._svc.admin_room_layout.reorder_sidebar_items_in_group(req, headers=self._headers())
  1742. )
  1743. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1744. assert group is not None
  1745. return group
  1746. async def admin_move_sidebar_item(
  1747. self,
  1748. item: tuple[str, str],
  1749. group_id: str,
  1750. before: tuple[str, str] | None = None,
  1751. ) -> AdminRoomLayoutGroup:
  1752. """Move one room or sidebar link to a position in a room group.
  1753. ``item`` is ``(kind, id)`` where kind is ``"room"`` or ``"sidebar_link"``;
  1754. ``before`` is the same tuple for the entry to place ahead of (omit to
  1755. place it last in ``group_id``).
  1756. """
  1757. req = room_layout_pb2.MoveSidebarItemRequest(group_id=group_id)
  1758. _layout_item(req.item, item[0], item[1])
  1759. if before is not None:
  1760. _layout_item(req.before, before[0], before[1])
  1761. resp = await self._rpc(
  1762. self._svc.admin_room_layout.move_sidebar_item(req, headers=self._headers())
  1763. )
  1764. group = AdminRoomLayoutGroup.parse(pb_to_dict(resp.group))
  1765. assert group is not None
  1766. return group
  1767. async def admin_create_sidebar_link(
  1768. self, group_id: str, label: str, url: str
  1769. ) -> dict[str, Any]:
  1770. resp = await self._rpc(
  1771. self._svc.admin_room_layout.create_sidebar_link(
  1772. room_layout_pb2.CreateSidebarLinkRequest(group_id=group_id, label=label, url=url),
  1773. headers=self._headers(),
  1774. )
  1775. )
  1776. sl = resp.sidebar_link
  1777. return {"id": sl.id, "label": sl.label, "url": sl.url}
  1778. async def admin_update_sidebar_link(
  1779. self,
  1780. link_id: str,
  1781. *,
  1782. label: str | None = None,
  1783. url: str | None = None,
  1784. ) -> dict[str, Any]:
  1785. req = room_layout_pb2.UpdateSidebarLinkRequest(link_id=link_id)
  1786. if label is not None:
  1787. req.label = label
  1788. if url is not None:
  1789. req.url = url
  1790. resp = await self._rpc(
  1791. self._svc.admin_room_layout.update_sidebar_link(req, headers=self._headers())
  1792. )
  1793. sl = resp.sidebar_link
  1794. return {"id": sl.id, "label": sl.label, "url": sl.url}
  1795. async def admin_delete_sidebar_link(self, link_id: str) -> bool:
  1796. resp = await self._rpc(
  1797. self._svc.admin_room_layout.delete_sidebar_link(
  1798. room_layout_pb2.DeleteSidebarLinkRequest(link_id=link_id),
  1799. headers=self._headers(),
  1800. )
  1801. )
  1802. return resp.deleted
  1803. async def admin_move_sidebar_link_to_group(self, link_id: str, group_id: str) -> dict[str, Any]:
  1804. resp = await self._rpc(
  1805. self._svc.admin_room_layout.move_sidebar_link_to_group(
  1806. room_layout_pb2.MoveSidebarLinkToGroupRequest(link_id=link_id, group_id=group_id),
  1807. headers=self._headers(),
  1808. )
  1809. )
  1810. sl = resp.sidebar_link
  1811. return {"id": sl.id, "label": sl.label, "url": sl.url}
  1812. # --- Admin: users --------------------------------------------------
  1813. async def admin_list_members(
  1814. self,
  1815. *,
  1816. search: str = "",
  1817. limit: int | None = None,
  1818. offset: int | None = None,
  1819. ) -> tuple[list[str], Page]:
  1820. """List server member IDs (plus page metadata).
  1821. The server returns member IDs only; hydrate full rows with
  1822. :meth:`admin_batch_get_members`.
  1823. """
  1824. req = admin_members_pb2.ListMembersRequest(search=search)
  1825. page = _page_pb(limit, offset)
  1826. if page is not None:
  1827. req.page.CopyFrom(page)
  1828. resp = await self._rpc(self._svc.admin_users.list_members(req, headers=self._headers()))
  1829. data = pb_to_dict(resp)
  1830. return list(data.get("userIds") or []), Page.parse(data.get("page"))
  1831. async def admin_get_member(
  1832. self,
  1833. *,
  1834. user_id: str | None = None,
  1835. login: str | None = None,
  1836. ) -> dict[str, Any]:
  1837. if bool(user_id) == bool(login):
  1838. raise ValueError("admin_get_member requires exactly one of user_id or login")
  1839. req = admin_members_pb2.GetMemberRequest()
  1840. if user_id:
  1841. req.user_id = user_id
  1842. else:
  1843. assert login is not None
  1844. req.login = login
  1845. resp = await self._rpc(self._svc.admin_users.get_member(req, headers=self._headers()))
  1846. return pb_to_dict(resp)
  1847. async def admin_batch_get_members(self, user_ids: list[str]) -> list[AdminMember]:
  1848. resp = await self._rpc(
  1849. self._svc.admin_users.batch_get_members(
  1850. admin_members_pb2.BatchGetMembersRequest(user_ids=user_ids),
  1851. headers=self._headers(),
  1852. )
  1853. )
  1854. data = pb_to_dict(resp)
  1855. return [
  1856. m
  1857. for m in (AdminMember.parse(row) for row in data.get("members") or [])
  1858. if m is not None
  1859. ]
  1860. async def admin_assign_role(self, user_id: str, role_name: str) -> AdminMember | None:
  1861. resp = await self._rpc(
  1862. self._svc.admin_users.assign_role(
  1863. admin_members_pb2.AssignRoleRequest(user_id=user_id, role_name=role_name),
  1864. headers=self._headers(),
  1865. )
  1866. )
  1867. return AdminMember.parse(pb_to_dict(resp.member))
  1868. async def admin_revoke_role(self, user_id: str, role_name: str) -> AdminMember | None:
  1869. resp = await self._rpc(
  1870. self._svc.admin_users.revoke_role(
  1871. admin_members_pb2.RevokeRoleRequest(user_id=user_id, role_name=role_name),
  1872. headers=self._headers(),
  1873. )
  1874. )
  1875. return AdminMember.parse(pb_to_dict(resp.member))
  1876. async def admin_update_user(
  1877. self,
  1878. user_id: str,
  1879. *,
  1880. display_name: str | None = None,
  1881. login: str | None = None,
  1882. bio: str | None = None,
  1883. ) -> tuple[User | None, AdminMember | None]:
  1884. """Update a user's login / display name / bio as a server-admin action.
  1885. The pre-0.5.0b9 ``AdminUserService.UpdateUser`` RPC was removed; admin
  1886. profile edits are now just a privileged
  1887. :meth:`update_user_profile` (``user.manage-accounts`` bypasses the
  1888. username-change cooldown and does not start one), so the second
  1889. returned value is always ``None`` — re-fetch the member row with
  1890. :meth:`admin_get_member` when it is needed.
  1891. """
  1892. user = await self.update_user_profile(
  1893. user_id, display_name=display_name, login=login, bio=bio
  1894. )
  1895. return user, None
  1896. async def admin_change_user_password(self, user_id: str, password: str) -> AdminMember | None:
  1897. resp = await self._rpc(
  1898. self._svc.admin_users.change_user_password(
  1899. admin_members_pb2.ChangeUserPasswordRequest(user_id=user_id, password=password),
  1900. headers=self._headers(),
  1901. )
  1902. )
  1903. return AdminMember.parse(pb_to_dict(resp.member))
  1904. async def admin_clear_username_cooldown(self, user_id: str) -> None:
  1905. await self._rpc(
  1906. self._svc.admin_users.clear_username_cooldown(
  1907. admin_members_pb2.ClearUsernameCooldownRequest(user_id=user_id),
  1908. headers=self._headers(),
  1909. )
  1910. )
  1911. async def admin_delete_user(self, user_id: str) -> None:
  1912. await self._rpc(
  1913. self._svc.admin_users.delete_user(
  1914. admin_members_pb2.DeleteUserRequest(user_id=user_id),
  1915. headers=self._headers(),
  1916. )
  1917. )
  1918. # --- Admin: roles --------------------------------------------------
  1919. async def admin_list_roles(self) -> list[AdminRole]:
  1920. resp = await self._rpc(
  1921. self._svc.admin_roles.list_roles(
  1922. admin_roles_pb2.ListRolesRequest(), headers=self._headers()
  1923. )
  1924. )
  1925. data = pb_to_dict(resp)
  1926. return [
  1927. r for r in (AdminRole.parse(row) for row in data.get("roles") or []) if r is not None
  1928. ]
  1929. async def admin_get_role(self, name: str) -> dict[str, Any]:
  1930. resp = await self._rpc(
  1931. self._svc.admin_roles.get_role(
  1932. admin_roles_pb2.GetRoleRequest(name=name), headers=self._headers()
  1933. )
  1934. )
  1935. return pb_to_dict(resp)
  1936. async def admin_create_role(
  1937. self,
  1938. name: str,
  1939. *,
  1940. display_name: str = "",
  1941. description: str = "",
  1942. pingable: bool = False,
  1943. ) -> AdminRole | None:
  1944. resp = await self._rpc(
  1945. self._svc.admin_roles.create_role(
  1946. admin_roles_pb2.CreateRoleRequest(
  1947. name=name,
  1948. display_name=display_name,
  1949. description=description,
  1950. pingable=pingable,
  1951. ),
  1952. headers=self._headers(),
  1953. )
  1954. )
  1955. return AdminRole.parse(pb_to_dict(resp.role))
  1956. async def admin_update_role(
  1957. self,
  1958. name: str,
  1959. *,
  1960. display_name: str | None = None,
  1961. description: str | None = None,
  1962. pingable: bool | None = None,
  1963. ) -> AdminRole | None:
  1964. req = admin_roles_pb2.UpdateRoleRequest(name=name)
  1965. if display_name is not None:
  1966. req.display_name = display_name
  1967. if description is not None:
  1968. req.description = description
  1969. if pingable is not None:
  1970. req.pingable = pingable
  1971. resp = await self._rpc(self._svc.admin_roles.update_role(req, headers=self._headers()))
  1972. return AdminRole.parse(pb_to_dict(resp.role))
  1973. async def admin_delete_role(self, name: str) -> None:
  1974. await self._rpc(
  1975. self._svc.admin_roles.delete_role(
  1976. admin_roles_pb2.DeleteRoleRequest(name=name),
  1977. headers=self._headers(),
  1978. )
  1979. )
  1980. async def admin_reorder_roles(self, role_names: list[str]) -> list[AdminRole]:
  1981. resp = await self._rpc(
  1982. self._svc.admin_roles.reorder_roles(
  1983. admin_roles_pb2.ReorderRolesRequest(role_names=role_names),
  1984. headers=self._headers(),
  1985. )
  1986. )
  1987. data = pb_to_dict(resp)
  1988. return [
  1989. r for r in (AdminRole.parse(row) for row in data.get("roles") or []) if r is not None
  1990. ]
  1991. async def admin_role_list_members(
  1992. self,
  1993. name: str,
  1994. *,
  1995. limit: int | None = None,
  1996. offset: int | None = None,
  1997. ) -> tuple[list[User], Page]:
  1998. """List a role's explicitly-assigned members (one page)."""
  1999. req = admin_roles_pb2.AdminRoleServiceListMembersRequest(name=name)
  2000. page = _page_pb(limit, offset)
  2001. if page is not None:
  2002. req.page.CopyFrom(page)
  2003. resp = await self._rpc(self._svc.admin_roles.list_members(req, headers=self._headers()))
  2004. data = pb_to_dict(resp)
  2005. members = [
  2006. u for u in (User.parse(row) for row in data.get("members") or []) if u is not None
  2007. ]
  2008. return members, Page.parse(data.get("page"))
  2009. # --- Admin: event log / diagnostics / permissions ----------------
  2010. async def admin_list_events(
  2011. self,
  2012. *,
  2013. event_types: list[str] | None = None,
  2014. limit: int | None = None,
  2015. offset: int | None = None,
  2016. ) -> dict[str, Any]:
  2017. req = event_log_pb2.ListEventsRequest()
  2018. if event_types:
  2019. req.event_types.extend(event_types)
  2020. page = _page_pb(limit, offset)
  2021. if page is not None:
  2022. req.page.CopyFrom(page)
  2023. resp = await self._rpc(self._svc.admin_event_log.list_events(req, headers=self._headers()))
  2024. return pb_to_dict(resp)
  2025. async def admin_list_event_types(self) -> list[str]:
  2026. resp = await self._rpc(
  2027. self._svc.admin_event_log.list_event_types(
  2028. event_log_pb2.ListEventTypesRequest(),
  2029. headers=self._headers(),
  2030. )
  2031. )
  2032. return list(resp.event_types)
  2033. async def admin_get_event(self, event_id: str) -> dict[str, Any]:
  2034. resp = await self._rpc(
  2035. self._svc.admin_event_log.get_event(
  2036. event_log_pb2.GetEventRequest(event_id=event_id),
  2037. headers=self._headers(),
  2038. )
  2039. )
  2040. return pb_to_dict(resp)
  2041. async def admin_get_system_info(self) -> dict[str, Any]:
  2042. from chattolib._pb.chatto.admin.v1 import diagnostics_pb2
  2043. resp = await self._rpc(
  2044. self._svc.admin_diagnostics.get_system_info(
  2045. diagnostics_pb2.GetSystemInfoRequest(),
  2046. headers=self._headers(),
  2047. )
  2048. )
  2049. return pb_to_dict(resp)
  2050. async def admin_get_role_permission_matrix(self) -> dict[str, Any]:
  2051. resp = await self._rpc(
  2052. self._svc.admin_permissions.get_role_permission_matrix(
  2053. admin_permissions_pb2.GetRolePermissionMatrixRequest(),
  2054. headers=self._headers(),
  2055. )
  2056. )
  2057. return pb_to_dict(resp)
  2058. async def admin_list_role_permission_decisions(self, role_name: str) -> dict[str, Any]:
  2059. resp = await self._rpc(
  2060. self._svc.admin_permissions.list_role_permission_decisions(
  2061. admin_permissions_pb2.ListRolePermissionDecisionsRequest(role_name=role_name),
  2062. headers=self._headers(),
  2063. )
  2064. )
  2065. return pb_to_dict(resp)
  2066. async def admin_get_user_permission_matrix(self, user_id: str) -> dict[str, Any]:
  2067. resp = await self._rpc(
  2068. self._svc.admin_permissions.get_user_permission_matrix(
  2069. admin_permissions_pb2.GetUserPermissionMatrixRequest(user_id=user_id),
  2070. headers=self._headers(),
  2071. )
  2072. )
  2073. return pb_to_dict(resp)
  2074. async def admin_list_user_permission_decisions(self, user_id: str) -> dict[str, Any]:
  2075. resp = await self._rpc(
  2076. self._svc.admin_permissions.list_user_permission_decisions(
  2077. admin_permissions_pb2.ListUserPermissionDecisionsRequest(user_id=user_id),
  2078. headers=self._headers(),
  2079. )
  2080. )
  2081. return pb_to_dict(resp)