client.py 83 KB

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