types.py 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972
  1. """Dataclasses and enums mirroring Chatto's Connect API messages.
  2. Enum ``value`` strings match the proto JSON encoding used on the wire
  3. (e.g. ``PRESENCE_STATUS_ONLINE``). Parsers below accept both the fully-qualified
  4. form and the short/legacy tail (``ONLINE``) for robustness against older
  5. snapshots or clients that construct short enum strings by hand.
  6. """
  7. from __future__ import annotations
  8. from dataclasses import dataclass, field
  9. from datetime import UTC, datetime
  10. from enum import Enum, StrEnum
  11. from typing import Any
  12. # --- Enums ---------------------------------------------------------------
  13. def _parse_enum(cls: type[Enum], raw: Any, default: Enum) -> Enum:
  14. """Accept either the full ``FOO_BAR_BAZ`` form or its tail (``BAZ``)."""
  15. if raw is None or raw == "":
  16. return default
  17. if isinstance(raw, cls):
  18. return raw
  19. try:
  20. return cls(raw)
  21. except ValueError:
  22. pass
  23. # Tolerate integer values from proto JSON alternate encoding.
  24. if isinstance(raw, int):
  25. for member in cls:
  26. if getattr(member, "_proto_index", None) == raw:
  27. return member
  28. text = str(raw)
  29. for member in cls:
  30. value = member.value if isinstance(member.value, str) else ""
  31. if value.endswith("_" + text) or value == text:
  32. return member
  33. return default
  34. class PresenceStatus(StrEnum):
  35. UNSPECIFIED = "PRESENCE_STATUS_UNSPECIFIED"
  36. ONLINE = "PRESENCE_STATUS_ONLINE"
  37. AWAY = "PRESENCE_STATUS_AWAY"
  38. DO_NOT_DISTURB = "PRESENCE_STATUS_DO_NOT_DISTURB"
  39. OFFLINE = "PRESENCE_STATUS_OFFLINE"
  40. class TimeFormat(StrEnum):
  41. UNSPECIFIED = "TIME_FORMAT_UNSPECIFIED"
  42. AUTO = "TIME_FORMAT_AUTO"
  43. HOUR_12 = "TIME_FORMAT_12_HOUR"
  44. HOUR_24 = "TIME_FORMAT_24_HOUR"
  45. class RoomKind(StrEnum):
  46. UNSPECIFIED = "ROOM_KIND_UNSPECIFIED"
  47. CHANNEL = "ROOM_KIND_CHANNEL"
  48. DM = "ROOM_KIND_DM"
  49. class RoomDirectoryScope(StrEnum):
  50. UNSPECIFIED = "ROOM_DIRECTORY_SCOPE_UNSPECIFIED"
  51. ALL = "ROOM_DIRECTORY_SCOPE_ALL"
  52. CHANNELS = "ROOM_DIRECTORY_SCOPE_CHANNELS"
  53. DMS = "ROOM_DIRECTORY_SCOPE_DMS"
  54. class NotificationLevel(StrEnum):
  55. UNSPECIFIED = "NOTIFICATION_LEVEL_UNSPECIFIED"
  56. DEFAULT = "NOTIFICATION_LEVEL_DEFAULT"
  57. MUTED = "NOTIFICATION_LEVEL_MUTED"
  58. NORMAL = "NOTIFICATION_LEVEL_NORMAL"
  59. ALL_MESSAGES = "NOTIFICATION_LEVEL_ALL_MESSAGES"
  60. class CallEventSource(StrEnum):
  61. UNSPECIFIED = "REALTIME_CALL_EVENT_SOURCE_UNSPECIFIED"
  62. USER = "REALTIME_CALL_EVENT_SOURCE_USER"
  63. LIVEKIT = "REALTIME_CALL_EVENT_SOURCE_LIVEKIT"
  64. RECONCILIATION = "REALTIME_CALL_EVENT_SOURCE_RECONCILIATION"
  65. class ImageFitMode(StrEnum):
  66. UNSPECIFIED = "IMAGE_FIT_MODE_UNSPECIFIED"
  67. CONTAIN = "IMAGE_FIT_MODE_CONTAIN"
  68. COVER = "IMAGE_FIT_MODE_COVER"
  69. class VideoProcessingStatus(StrEnum):
  70. UNSPECIFIED = "MESSAGE_VIDEO_PROCESSING_STATUS_UNSPECIFIED"
  71. PROCESSING = "MESSAGE_VIDEO_PROCESSING_STATUS_PROCESSING"
  72. COMPLETED = "MESSAGE_VIDEO_PROCESSING_STATUS_COMPLETED"
  73. FAILED = "MESSAGE_VIDEO_PROCESSING_STATUS_FAILED"
  74. class AssetUploadStatus(StrEnum):
  75. UNSPECIFIED = "ASSET_UPLOAD_STATUS_UNSPECIFIED"
  76. OPEN = "ASSET_UPLOAD_STATUS_OPEN"
  77. COMPLETED = "ASSET_UPLOAD_STATUS_COMPLETED"
  78. CANCELLED = "ASSET_UPLOAD_STATUS_CANCELLED"
  79. class AdminRoomLayoutItemKind(StrEnum):
  80. UNSPECIFIED = "ADMIN_ROOM_LAYOUT_ITEM_KIND_UNSPECIFIED"
  81. ROOM = "ADMIN_ROOM_LAYOUT_ITEM_KIND_ROOM"
  82. SIDEBAR_LINK = "ADMIN_ROOM_LAYOUT_ITEM_KIND_SIDEBAR_LINK"
  83. class ExternalIdentityFlowKind(StrEnum):
  84. UNSPECIFIED = "EXTERNAL_IDENTITY_FLOW_KIND_UNSPECIFIED"
  85. CREATE_ACCOUNT = "EXTERNAL_IDENTITY_FLOW_KIND_CREATE_ACCOUNT"
  86. LINK_ACCOUNT = "EXTERNAL_IDENTITY_FLOW_KIND_LINK_ACCOUNT"
  87. class PermissionDecision(StrEnum):
  88. UNSPECIFIED = "PERMISSION_DECISION_UNSPECIFIED"
  89. ALLOW = "PERMISSION_DECISION_ALLOW"
  90. DENY = "PERMISSION_DECISION_DENY"
  91. NONE = "PERMISSION_DECISION_NONE"
  92. class PermissionScopeKind(StrEnum):
  93. UNSPECIFIED = "PERMISSION_SCOPE_KIND_UNSPECIFIED"
  94. SERVER = "PERMISSION_SCOPE_KIND_SERVER"
  95. GROUP = "PERMISSION_SCOPE_KIND_GROUP"
  96. ROOM = "PERMISSION_SCOPE_KIND_ROOM"
  97. # --- Time helpers --------------------------------------------------------
  98. def parse_datetime(value: Any) -> datetime | None:
  99. """Parse an RFC 3339 timestamp string (proto JSON encoding for Timestamp)."""
  100. if not value:
  101. return None
  102. if isinstance(value, datetime):
  103. return value
  104. text = str(value)
  105. if text.endswith("Z"):
  106. text = text[:-1] + "+00:00"
  107. try:
  108. return datetime.fromisoformat(text)
  109. except ValueError:
  110. return None
  111. def format_datetime(value: datetime) -> str:
  112. """Format a ``datetime`` as an RFC 3339 UTC timestamp (``...Z``)."""
  113. if value.tzinfo is None:
  114. value = value.replace(tzinfo=UTC)
  115. return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
  116. # --- Core resource dataclasses ------------------------------------------
  117. @dataclass
  118. class CustomUserStatus:
  119. emoji: str
  120. text: str
  121. expires_at: datetime | None = None
  122. @classmethod
  123. def parse(cls, data: dict[str, Any] | None) -> CustomUserStatus | None:
  124. if not data:
  125. return None
  126. return cls(
  127. emoji=data.get("emoji", ""),
  128. text=data.get("text", ""),
  129. expires_at=parse_datetime(data.get("expiresAt")),
  130. )
  131. @dataclass
  132. class User:
  133. id: str
  134. login: str
  135. display_name: str
  136. presence_status: PresenceStatus = PresenceStatus.UNSPECIFIED
  137. avatar_url: str | None = None
  138. deleted: bool = False
  139. custom_status: CustomUserStatus | None = None
  140. @classmethod
  141. def parse(cls, data: dict[str, Any] | None) -> User | None:
  142. if not data:
  143. return None
  144. return cls(
  145. id=data.get("id", ""),
  146. login=data.get("login", ""),
  147. display_name=data.get("displayName", ""),
  148. presence_status=_parse_enum(
  149. PresenceStatus, data.get("presenceStatus"), PresenceStatus.UNSPECIFIED
  150. ), # type: ignore[arg-type]
  151. avatar_url=data.get("avatarUrl"),
  152. deleted=bool(data.get("deleted", False)),
  153. custom_status=CustomUserStatus.parse(data.get("customStatus")),
  154. )
  155. @dataclass
  156. class UserSettings:
  157. timezone: str | None = None
  158. time_format: TimeFormat = TimeFormat.UNSPECIFIED
  159. @classmethod
  160. def parse(cls, data: dict[str, Any] | None) -> UserSettings:
  161. data = data or {}
  162. return cls(
  163. timezone=data.get("timezone"),
  164. time_format=_parse_enum(
  165. TimeFormat, data.get("timeFormat"), TimeFormat.UNSPECIFIED
  166. ), # type: ignore[arg-type]
  167. )
  168. @dataclass
  169. class ViewerUser:
  170. profile: User | None
  171. settings: UserSettings
  172. has_verified_email: bool = False
  173. viewer_can_delete_account: bool = False
  174. last_login_change: datetime | None = None
  175. has_password: bool = False
  176. @classmethod
  177. def parse(cls, data: dict[str, Any] | None) -> ViewerUser | None:
  178. if not data:
  179. return None
  180. return cls(
  181. profile=User.parse(data.get("profile")),
  182. settings=UserSettings.parse(data.get("settings")),
  183. has_verified_email=bool(data.get("hasVerifiedEmail", False)),
  184. viewer_can_delete_account=bool(data.get("viewerCanDeleteAccount", False)),
  185. last_login_change=parse_datetime(data.get("lastLoginChange")),
  186. has_password=bool(data.get("hasPassword", False)),
  187. )
  188. @dataclass
  189. class DirectoryMember:
  190. user: User | None
  191. roles: list[str] = field(default_factory=list)
  192. created_at: datetime | None = None
  193. @classmethod
  194. def parse(cls, data: dict[str, Any] | None) -> DirectoryMember | None:
  195. if not data:
  196. return None
  197. return cls(
  198. user=User.parse(data.get("user")),
  199. roles=list(data.get("roles") or []),
  200. created_at=parse_datetime(data.get("createdAt")),
  201. )
  202. @dataclass
  203. class Page:
  204. total_count: int = 0
  205. has_more: bool = False
  206. @classmethod
  207. def parse(cls, data: dict[str, Any] | None) -> Page:
  208. data = data or {}
  209. return cls(
  210. total_count=int(data.get("totalCount", 0)),
  211. has_more=bool(data.get("hasMore", False)),
  212. )
  213. @dataclass
  214. class ServerProfile:
  215. name: str
  216. version: str = ""
  217. logo_url: str | None = None
  218. banner_url: str | None = None
  219. welcome_message: str | None = None
  220. description: str | None = None
  221. @classmethod
  222. def parse(cls, data: dict[str, Any] | None) -> ServerProfile:
  223. data = data or {}
  224. return cls(
  225. name=data.get("name", ""),
  226. version=data.get("version", ""),
  227. logo_url=data.get("logoUrl"),
  228. banner_url=data.get("bannerUrl"),
  229. welcome_message=data.get("welcomeMessage"),
  230. description=data.get("description"),
  231. )
  232. @dataclass
  233. class ProviderMetadata:
  234. id: str
  235. type: str
  236. label: str
  237. login_url: str = ""
  238. @classmethod
  239. def parse(cls, data: dict[str, Any]) -> ProviderMetadata:
  240. return cls(
  241. id=data.get("id", ""),
  242. type=data.get("type", ""),
  243. label=data.get("label", ""),
  244. login_url=data.get("loginUrl", ""),
  245. )
  246. @dataclass
  247. class ServerLogin:
  248. direct_registration_enabled: bool = False
  249. providers: list[ProviderMetadata] = field(default_factory=list)
  250. authorize_url: str = ""
  251. @classmethod
  252. def parse(cls, data: dict[str, Any] | None) -> ServerLogin:
  253. data = data or {}
  254. return cls(
  255. direct_registration_enabled=bool(data.get("directRegistrationEnabled", False)),
  256. providers=[ProviderMetadata.parse(p) for p in data.get("providers") or []],
  257. authorize_url=data.get("authorizeUrl", ""),
  258. )
  259. @dataclass
  260. class ServerRuntimeConfig:
  261. push_notifications_enabled: bool = False
  262. vapid_public_key: str | None = None
  263. livekit_url: str | None = None
  264. video_processing_enabled: bool = False
  265. max_upload_size: int = 0
  266. max_video_upload_size: int = 0
  267. message_edit_window_seconds: int = 0
  268. @classmethod
  269. def parse(cls, data: dict[str, Any] | None) -> ServerRuntimeConfig:
  270. data = data or {}
  271. return cls(
  272. push_notifications_enabled=bool(data.get("pushNotificationsEnabled", False)),
  273. vapid_public_key=data.get("vapidPublicKey"),
  274. livekit_url=data.get("livekitUrl"),
  275. video_processing_enabled=bool(data.get("videoProcessingEnabled", False)),
  276. max_upload_size=int(data.get("maxUploadSize", 0)),
  277. max_video_upload_size=int(data.get("maxVideoUploadSize", 0)),
  278. message_edit_window_seconds=int(data.get("messageEditWindowSeconds", 0)),
  279. )
  280. @dataclass
  281. class Room:
  282. id: str
  283. kind: RoomKind = RoomKind.UNSPECIFIED
  284. name: str = ""
  285. description: str = ""
  286. archived: bool = False
  287. group_id: str = ""
  288. universal: bool = False
  289. @classmethod
  290. def parse(cls, data: dict[str, Any] | None) -> Room | None:
  291. if not data:
  292. return None
  293. return cls(
  294. id=data.get("id", ""),
  295. kind=_parse_enum(RoomKind, data.get("kind"), RoomKind.UNSPECIFIED), # type: ignore[arg-type]
  296. name=data.get("name", ""),
  297. description=data.get("description", ""),
  298. archived=bool(data.get("archived", False)),
  299. group_id=data.get("groupId", ""),
  300. universal=bool(data.get("universal", False)),
  301. )
  302. @dataclass
  303. class RoomSummary:
  304. id: str
  305. kind: RoomKind = RoomKind.UNSPECIFIED
  306. name: str = ""
  307. @classmethod
  308. def parse(cls, data: dict[str, Any] | None) -> RoomSummary | None:
  309. if not data:
  310. return None
  311. return cls(
  312. id=data.get("id", ""),
  313. kind=_parse_enum(RoomKind, data.get("kind"), RoomKind.UNSPECIFIED), # type: ignore[arg-type]
  314. name=data.get("name", ""),
  315. )
  316. @dataclass
  317. class RoomViewerState:
  318. is_member: bool = False
  319. has_unread: bool = False
  320. @classmethod
  321. def parse(cls, data: dict[str, Any] | None) -> RoomViewerState:
  322. data = data or {}
  323. return cls(
  324. is_member=bool(data.get("isMember", False)),
  325. has_unread=bool(data.get("hasUnread", False)),
  326. )
  327. @dataclass
  328. class RoomWithViewerState:
  329. room: Room | None
  330. viewer_state: RoomViewerState
  331. @classmethod
  332. def parse(cls, data: dict[str, Any] | None) -> RoomWithViewerState | None:
  333. if not data:
  334. return None
  335. return cls(
  336. room=Room.parse(data.get("room")),
  337. viewer_state=RoomViewerState.parse(data.get("viewerState")),
  338. )
  339. @dataclass
  340. class SidebarLink:
  341. id: str
  342. label: str
  343. url: str
  344. @classmethod
  345. def parse(cls, data: dict[str, Any] | None) -> SidebarLink | None:
  346. if not data:
  347. return None
  348. return cls(
  349. id=data.get("id", ""),
  350. label=data.get("label", ""),
  351. url=data.get("url", ""),
  352. )
  353. @dataclass
  354. class RoomGroup:
  355. id: str
  356. name: str
  357. description: str = ""
  358. rooms: list[RoomWithViewerState] = field(default_factory=list)
  359. sidebar_links: list[SidebarLink] = field(default_factory=list)
  360. @classmethod
  361. def parse(cls, data: dict[str, Any] | None) -> RoomGroup | None:
  362. if not data:
  363. return None
  364. rooms: list[RoomWithViewerState] = []
  365. links: list[SidebarLink] = []
  366. for item in data.get("items") or []:
  367. room = RoomWithViewerState.parse(item.get("room"))
  368. if room:
  369. rooms.append(room)
  370. sl = item.get("sidebarLink")
  371. if sl:
  372. links.append(
  373. SidebarLink(
  374. id=sl.get("id", ""),
  375. label=sl.get("label", ""),
  376. url=sl.get("url", ""),
  377. )
  378. )
  379. return cls(
  380. id=data.get("id", ""),
  381. name=data.get("name", ""),
  382. description=data.get("description", ""),
  383. rooms=rooms,
  384. sidebar_links=links,
  385. )
  386. @dataclass
  387. class RoomBan:
  388. id: str
  389. room_id: str
  390. user_id: str
  391. moderator_id: str = ""
  392. reason: str = ""
  393. created_at: datetime | None = None
  394. expires_at: datetime | None = None
  395. room: Room | None = None
  396. user: DirectoryMember | None = None
  397. moderator: DirectoryMember | None = None
  398. @classmethod
  399. def parse(cls, data: dict[str, Any] | None) -> RoomBan | None:
  400. if not data:
  401. return None
  402. return cls(
  403. id=data.get("id", ""),
  404. room_id=data.get("roomId", ""),
  405. user_id=data.get("userId", ""),
  406. moderator_id=data.get("moderatorId", ""),
  407. reason=data.get("reason", ""),
  408. created_at=parse_datetime(data.get("createdAt")),
  409. expires_at=parse_datetime(data.get("expiresAt")),
  410. room=Room.parse(data.get("room")),
  411. user=DirectoryMember.parse(data.get("user")),
  412. moderator=DirectoryMember.parse(data.get("moderator")),
  413. )
  414. @dataclass
  415. class AssetUrl:
  416. url: str
  417. expires_at: datetime | None = None
  418. @classmethod
  419. def parse(cls, data: dict[str, Any] | None) -> AssetUrl | None:
  420. if not data:
  421. return None
  422. return cls(
  423. url=data.get("url", ""),
  424. expires_at=parse_datetime(data.get("expiresAt")),
  425. )
  426. @dataclass
  427. class VideoVariant:
  428. quality: str
  429. width: int
  430. height: int
  431. size: int
  432. asset_url: AssetUrl | None
  433. @classmethod
  434. def parse(cls, data: dict[str, Any]) -> VideoVariant:
  435. return cls(
  436. quality=data.get("quality", ""),
  437. width=int(data.get("width", 0)),
  438. height=int(data.get("height", 0)),
  439. size=int(data.get("size", 0)),
  440. asset_url=AssetUrl.parse(data.get("assetUrl")),
  441. )
  442. @dataclass
  443. class VideoProcessing:
  444. status: VideoProcessingStatus = VideoProcessingStatus.UNSPECIFIED
  445. duration_ms: int = 0
  446. width: int = 0
  447. height: int = 0
  448. source_available: bool = False
  449. reason_code: str = ""
  450. thumbnail_asset_url: AssetUrl | None = None
  451. variants: list[VideoVariant] = field(default_factory=list)
  452. @classmethod
  453. def parse(cls, data: dict[str, Any] | None) -> VideoProcessing | None:
  454. if not data:
  455. return None
  456. return cls(
  457. status=_parse_enum(
  458. VideoProcessingStatus,
  459. data.get("status"),
  460. VideoProcessingStatus.UNSPECIFIED,
  461. ), # type: ignore[arg-type]
  462. duration_ms=int(data.get("durationMs", 0)),
  463. width=int(data.get("width", 0)),
  464. height=int(data.get("height", 0)),
  465. source_available=bool(data.get("sourceAvailable", False)),
  466. reason_code=data.get("reasonCode", ""),
  467. thumbnail_asset_url=AssetUrl.parse(data.get("thumbnailAssetUrl")),
  468. variants=[VideoVariant.parse(v) for v in data.get("variants") or []],
  469. )
  470. @dataclass
  471. class MessageAttachment:
  472. id: str
  473. filename: str
  474. content_type: str
  475. width: int = 0
  476. height: int = 0
  477. asset_url: AssetUrl | None = None
  478. thumbnail_asset_url: AssetUrl | None = None
  479. video_processing: VideoProcessing | None = None
  480. @classmethod
  481. def parse(cls, data: dict[str, Any]) -> MessageAttachment:
  482. return cls(
  483. id=data.get("id", ""),
  484. filename=data.get("filename", ""),
  485. content_type=data.get("contentType", ""),
  486. width=int(data.get("width", 0)),
  487. height=int(data.get("height", 0)),
  488. asset_url=AssetUrl.parse(data.get("assetUrl")),
  489. thumbnail_asset_url=AssetUrl.parse(data.get("thumbnailAssetUrl")),
  490. video_processing=VideoProcessing.parse(data.get("videoProcessing")),
  491. )
  492. @dataclass
  493. class LinkPreview:
  494. url: str
  495. title: str | None = None
  496. description: str | None = None
  497. image_url: str | None = None
  498. image_asset_id: str | None = None
  499. site_name: str | None = None
  500. embed_type: str | None = None
  501. embed_id: str | None = None
  502. @classmethod
  503. def parse(cls, data: dict[str, Any] | None) -> LinkPreview | None:
  504. if not data:
  505. return None
  506. return cls(
  507. url=data.get("url", ""),
  508. title=data.get("title"),
  509. description=data.get("description"),
  510. image_url=data.get("imageUrl"),
  511. image_asset_id=data.get("imageAssetId"),
  512. site_name=data.get("siteName"),
  513. embed_type=data.get("embedType"),
  514. embed_id=data.get("embedId"),
  515. )
  516. @dataclass
  517. class MessageReaction:
  518. emoji: str
  519. count: int = 0
  520. has_reacted: bool = False
  521. preview_user_ids: list[str] = field(default_factory=list)
  522. @classmethod
  523. def parse(cls, data: dict[str, Any]) -> MessageReaction:
  524. return cls(
  525. emoji=data.get("emoji", ""),
  526. count=int(data.get("count", 0)),
  527. has_reacted=bool(data.get("hasReacted", False)),
  528. preview_user_ids=list(data.get("previewUserIds") or []),
  529. )
  530. @dataclass
  531. class ThreadSummary:
  532. thread_root_event_id: str
  533. reply_count: int = 0
  534. last_reply_at: datetime | None = None
  535. participant_preview_user_ids: list[str] = field(default_factory=list)
  536. participant_count: int = 0
  537. is_following: bool | None = None
  538. has_unread: bool | None = None
  539. @classmethod
  540. def parse(cls, data: dict[str, Any] | None) -> ThreadSummary | None:
  541. if not data:
  542. return None
  543. viewer = data.get("viewerState") or {}
  544. return cls(
  545. thread_root_event_id=data.get("threadRootEventId", ""),
  546. reply_count=int(data.get("replyCount", 0)),
  547. last_reply_at=parse_datetime(data.get("lastReplyAt")),
  548. participant_preview_user_ids=list(
  549. data.get("participantPreviewUserIds") or []
  550. ),
  551. participant_count=int(data.get("participantCount", 0)),
  552. is_following=viewer.get("isFollowing") if "isFollowing" in viewer else None,
  553. has_unread=viewer.get("hasUnread") if "hasUnread" in viewer else None,
  554. )
  555. @dataclass
  556. class Message:
  557. id: str
  558. room_id: str
  559. created_at: datetime | None
  560. actor_id: str
  561. body: str | None = None
  562. attachments: list[MessageAttachment] = field(default_factory=list)
  563. link_preview: LinkPreview | None = None
  564. updated_at: datetime | None = None
  565. in_reply_to: str = ""
  566. thread_root_event_id: str = ""
  567. echo_of_event_id: str = ""
  568. echo_from_thread_root_event_id: str = ""
  569. channel_echo_event_id: str = ""
  570. reactions: list[MessageReaction] = field(default_factory=list)
  571. thread: ThreadSummary | None = None
  572. deleted_at: datetime | None = None
  573. @classmethod
  574. def parse(cls, data: dict[str, Any] | None) -> Message | None:
  575. if not data:
  576. return None
  577. return cls(
  578. id=data.get("id", ""),
  579. room_id=data.get("roomId", ""),
  580. created_at=parse_datetime(data.get("createdAt")),
  581. actor_id=data.get("actorId", ""),
  582. body=data.get("body"),
  583. attachments=[MessageAttachment.parse(a) for a in data.get("attachments") or []],
  584. link_preview=LinkPreview.parse(data.get("linkPreview")),
  585. updated_at=parse_datetime(data.get("updatedAt")),
  586. in_reply_to=data.get("inReplyTo", ""),
  587. thread_root_event_id=data.get("threadRootEventId", ""),
  588. echo_of_event_id=data.get("echoOfEventId", ""),
  589. echo_from_thread_root_event_id=data.get("echoFromThreadRootEventId", ""),
  590. channel_echo_event_id=data.get("channelEchoEventId", ""),
  591. reactions=[MessageReaction.parse(r) for r in data.get("reactions") or []],
  592. thread=ThreadSummary.parse(data.get("thread")),
  593. deleted_at=parse_datetime(data.get("deletedAt")),
  594. )
  595. @dataclass
  596. class TimelineEvent:
  597. id: str
  598. created_at: datetime | None
  599. actor_id: str = ""
  600. kind: str = "" # "message_posted", "room_created", "user_joined_room", ...
  601. message: Message | None = None
  602. room_id: str = ""
  603. @classmethod
  604. def parse(cls, data: dict[str, Any]) -> TimelineEvent:
  605. kind = ""
  606. message: Message | None = None
  607. room_id = ""
  608. for candidate in (
  609. "messagePosted",
  610. "roomCreated",
  611. "roomUpdated",
  612. "roomDeleted",
  613. "roomArchived",
  614. "roomUnarchived",
  615. "userJoinedRoom",
  616. "userLeftRoom",
  617. ):
  618. payload = data.get(candidate)
  619. if payload is None:
  620. continue
  621. kind = _snake_case(candidate)
  622. if candidate == "messagePosted":
  623. message = Message.parse(payload.get("message"))
  624. else:
  625. room_id = payload.get("roomId", "")
  626. break
  627. return cls(
  628. id=data.get("id", ""),
  629. created_at=parse_datetime(data.get("createdAt")),
  630. actor_id=data.get("actorId", ""),
  631. kind=kind,
  632. message=message,
  633. room_id=room_id,
  634. )
  635. def _snake_case(name: str) -> str:
  636. out: list[str] = []
  637. for ch in name:
  638. if ch.isupper() and out:
  639. out.append("_")
  640. out.append(ch.lower())
  641. return "".join(out)
  642. @dataclass
  643. class TimelinePage:
  644. events: list[TimelineEvent] = field(default_factory=list)
  645. start_cursor: str = ""
  646. end_cursor: str = ""
  647. has_older: bool = False
  648. has_newer: bool = False
  649. users_by_id: dict[str, User] = field(default_factory=dict)
  650. @classmethod
  651. def parse(cls, data: dict[str, Any] | None) -> TimelinePage:
  652. data = data or {}
  653. includes = data.get("includes") or {}
  654. users_raw = includes.get("users") or {}
  655. users: dict[str, User] = {}
  656. for uid, user_data in users_raw.items():
  657. parsed_user = User.parse(user_data)
  658. if parsed_user is not None:
  659. users[uid] = parsed_user
  660. return cls(
  661. events=[TimelineEvent.parse(e) for e in data.get("events") or []],
  662. start_cursor=data.get("startCursor", ""),
  663. end_cursor=data.get("endCursor", ""),
  664. has_older=bool(data.get("hasOlder", False)),
  665. has_newer=bool(data.get("hasNewer", False)),
  666. users_by_id=users,
  667. )
  668. @dataclass
  669. class FollowedThread:
  670. room: RoomSummary | None
  671. root_message: Message | None
  672. thread: ThreadSummary | None
  673. @classmethod
  674. def parse(cls, data: dict[str, Any]) -> FollowedThread:
  675. return cls(
  676. room=RoomSummary.parse(data.get("room")),
  677. root_message=Message.parse(data.get("rootMessage")),
  678. thread=ThreadSummary.parse(data.get("thread")),
  679. )
  680. @dataclass
  681. class FollowedThreadsPage:
  682. threads: list[FollowedThread] = field(default_factory=list)
  683. page: Page = field(default_factory=Page)
  684. users_by_id: dict[str, User] = field(default_factory=dict)
  685. @dataclass
  686. class Notification:
  687. id: str
  688. created_at: datetime | None
  689. actor: User | None
  690. kind: str # "direct_message" | "mention" | "reply" | "room_message"
  691. room: RoomSummary | None = None
  692. event_id: str = ""
  693. in_reply_to_id: str = ""
  694. thread_root_event_id: str | None = None
  695. @classmethod
  696. def parse(cls, data: dict[str, Any]) -> Notification:
  697. kind = ""
  698. room: RoomSummary | None = None
  699. event_id = ""
  700. in_reply_to_id = ""
  701. thread_root: str | None = None
  702. for candidate in ("directMessage", "mention", "reply", "roomMessage"):
  703. payload = data.get(candidate)
  704. if payload is None:
  705. continue
  706. kind = _snake_case(candidate)
  707. room = RoomSummary.parse(payload.get("room"))
  708. event_id = payload.get("eventId", "")
  709. in_reply_to_id = payload.get("inReplyToId", "") if candidate == "reply" else ""
  710. thread_root = payload.get("threadRootEventId")
  711. break
  712. return cls(
  713. id=data.get("id", ""),
  714. created_at=parse_datetime(data.get("createdAt")),
  715. actor=User.parse(data.get("actor")),
  716. kind=kind,
  717. room=room,
  718. event_id=event_id,
  719. in_reply_to_id=in_reply_to_id,
  720. thread_root_event_id=thread_root,
  721. )
  722. @dataclass
  723. class NotificationsPage:
  724. notifications: list[Notification] = field(default_factory=list)
  725. page: Page = field(default_factory=Page)
  726. @dataclass
  727. class NotificationPreference:
  728. level: NotificationLevel = NotificationLevel.UNSPECIFIED
  729. effective_level: NotificationLevel = NotificationLevel.UNSPECIFIED
  730. @classmethod
  731. def parse(cls, data: dict[str, Any] | None) -> NotificationPreference:
  732. data = data or {}
  733. return cls(
  734. level=_parse_enum(
  735. NotificationLevel, data.get("level"), NotificationLevel.UNSPECIFIED
  736. ), # type: ignore[arg-type]
  737. effective_level=_parse_enum(
  738. NotificationLevel,
  739. data.get("effectiveLevel"),
  740. NotificationLevel.UNSPECIFIED,
  741. ), # type: ignore[arg-type]
  742. )
  743. @dataclass
  744. class Asset:
  745. id: str
  746. filename: str
  747. content_type: str
  748. size: int
  749. width: int = 0
  750. height: int = 0
  751. asset_url: AssetUrl | None = None
  752. thumbnail_asset_url: AssetUrl | None = None
  753. video_processing: VideoProcessing | None = None
  754. @classmethod
  755. def parse(cls, data: dict[str, Any] | None) -> Asset | None:
  756. if not data:
  757. return None
  758. return cls(
  759. id=data.get("id", ""),
  760. filename=data.get("filename", ""),
  761. content_type=data.get("contentType", ""),
  762. size=int(data.get("size", 0)),
  763. width=int(data.get("width", 0)),
  764. height=int(data.get("height", 0)),
  765. asset_url=AssetUrl.parse(data.get("assetUrl")),
  766. thumbnail_asset_url=AssetUrl.parse(data.get("thumbnailAssetUrl")),
  767. video_processing=VideoProcessing.parse(data.get("videoProcessing")),
  768. )
  769. @dataclass
  770. class ImageTransformOptions:
  771. width: int
  772. height: int
  773. fit: ImageFitMode = ImageFitMode.UNSPECIFIED
  774. def to_wire(self) -> dict[str, Any]:
  775. return {
  776. "width": self.width,
  777. "height": self.height,
  778. "fit": self.fit.value,
  779. }
  780. @dataclass
  781. class ActiveCallParticipant:
  782. user: User | None = None
  783. joined_at: datetime | None = None
  784. call_id: str = ""
  785. @classmethod
  786. def parse(cls, data: dict[str, Any]) -> ActiveCallParticipant:
  787. return cls(
  788. user=User.parse(data.get("user")),
  789. joined_at=parse_datetime(data.get("joinedAt")),
  790. call_id=data.get("callId", ""),
  791. )
  792. @dataclass
  793. class ActiveCall:
  794. call_id: str
  795. room: RoomSummary | None
  796. participants: list[ActiveCallParticipant] = field(default_factory=list)
  797. @classmethod
  798. def parse(cls, data: dict[str, Any]) -> ActiveCall:
  799. return cls(
  800. call_id=data.get("callId", ""),
  801. room=RoomSummary.parse(data.get("room")),
  802. participants=[
  803. ActiveCallParticipant.parse(p) for p in data.get("participants") or []
  804. ],
  805. )
  806. # --- Roles ----------------------------------------------------------------
  807. @dataclass
  808. class Role:
  809. name: str
  810. display_name: str = ""
  811. description: str = ""
  812. is_system: bool = False
  813. position: int = 0
  814. pingable: bool = False
  815. @classmethod
  816. def parse(cls, data: dict[str, Any] | None) -> Role | None:
  817. if not data:
  818. return None
  819. return cls(
  820. name=data.get("name", ""),
  821. display_name=data.get("displayName", ""),
  822. description=data.get("description", ""),
  823. is_system=bool(data.get("isSystem", False)),
  824. position=int(data.get("position", 0)),
  825. pingable=bool(data.get("pingable", False)),
  826. )
  827. @dataclass
  828. class AdminRole:
  829. role: Role | None
  830. permissions: list[str] = field(default_factory=list)
  831. permission_denials: list[str] = field(default_factory=list)
  832. @classmethod
  833. def parse(cls, data: dict[str, Any] | None) -> AdminRole | None:
  834. if not data:
  835. return None
  836. return cls(
  837. role=Role.parse(data.get("role")),
  838. permissions=list(data.get("permissions") or []),
  839. permission_denials=list(data.get("permissionDenials") or []),
  840. )
  841. # --- Asset uploads --------------------------------------------------------
  842. @dataclass
  843. class AssetUpload:
  844. upload_id: str
  845. room_id: str
  846. status: AssetUploadStatus = AssetUploadStatus.UNSPECIFIED
  847. committed_offset: int = 0
  848. size: int = 0
  849. max_chunk_size: int = 0
  850. sha256: str = ""
  851. expires_at: datetime | None = None
  852. asset_id: str = ""
  853. @classmethod
  854. def parse(cls, data: dict[str, Any] | None) -> AssetUpload | None:
  855. if not data:
  856. return None
  857. return cls(
  858. upload_id=data.get("uploadId", ""),
  859. room_id=data.get("roomId", ""),
  860. status=_parse_enum(
  861. AssetUploadStatus,
  862. data.get("status"),
  863. AssetUploadStatus.UNSPECIFIED,
  864. ), # type: ignore[arg-type]
  865. committed_offset=int(data.get("committedOffset", 0)),
  866. size=int(data.get("size", 0)),
  867. max_chunk_size=int(data.get("maxChunkSize", 0)),
  868. sha256=data.get("sha256", ""),
  869. expires_at=parse_datetime(data.get("expiresAt")),
  870. asset_id=data.get("assetId", ""),
  871. )
  872. # --- External identity ----------------------------------------------------
  873. @dataclass
  874. class ExternalIdentityProvider:
  875. provider: ProviderMetadata | None
  876. link_url: str = ""
  877. linked: bool = False
  878. linked_identity_subject_hash: str = ""
  879. @classmethod
  880. def parse(cls, data: dict[str, Any]) -> ExternalIdentityProvider:
  881. provider_data = data.get("provider")
  882. provider = ProviderMetadata.parse(provider_data) if provider_data else None
  883. return cls(
  884. provider=provider,
  885. link_url=data.get("linkUrl", ""),
  886. linked=bool(data.get("linked", False)),
  887. linked_identity_subject_hash=data.get("linkedIdentitySubjectHash", ""),
  888. )
  889. @dataclass
  890. class LinkedExternalIdentity:
  891. provider_id: str
  892. provider_type: str = ""
  893. provider_label: str = ""
  894. subject_hash: str = ""
  895. @classmethod
  896. def parse(cls, data: dict[str, Any]) -> LinkedExternalIdentity:
  897. return cls(
  898. provider_id=data.get("providerId", ""),
  899. provider_type=data.get("providerType", ""),
  900. provider_label=data.get("providerLabel", ""),
  901. subject_hash=data.get("subjectHash", ""),
  902. )
  903. # --- External identity flow types ----------------------------------------
  904. @dataclass
  905. class PendingExternalIdentity:
  906. kind: ExternalIdentityFlowKind = ExternalIdentityFlowKind.UNSPECIFIED
  907. provider_id: str = ""
  908. provider_type: str = ""
  909. provider_label: str = ""
  910. verified_email: str = ""
  911. login_hint: str = ""
  912. display_name_hint: str = ""
  913. bound_user_id: str = ""
  914. redirect_path: str = ""
  915. @classmethod
  916. def parse(cls, data: dict[str, Any] | None) -> PendingExternalIdentity | None:
  917. if not data:
  918. return None
  919. return cls(
  920. kind=_parse_enum( # type: ignore[arg-type]
  921. ExternalIdentityFlowKind,
  922. data.get("kind"),
  923. ExternalIdentityFlowKind.UNSPECIFIED,
  924. ),
  925. provider_id=data.get("providerId", ""),
  926. provider_type=data.get("providerType", ""),
  927. provider_label=data.get("providerLabel", ""),
  928. verified_email=data.get("verifiedEmail", ""),
  929. login_hint=data.get("loginHint", ""),
  930. display_name_hint=data.get("displayNameHint", ""),
  931. bound_user_id=data.get("boundUserId", ""),
  932. redirect_path=data.get("redirectPath", ""),
  933. )
  934. @dataclass
  935. class ExternalIdentityAccount:
  936. user_id: str = ""
  937. login: str = ""
  938. token: str = ""
  939. @classmethod
  940. def parse(cls, data: dict[str, Any] | None) -> ExternalIdentityAccount | None:
  941. if not data:
  942. return None
  943. return cls(
  944. user_id=data.get("userId", ""),
  945. login=data.get("login", ""),
  946. token=data.get("token", ""),
  947. )
  948. # --- Admin: server / members ---------------------------------------------
  949. @dataclass
  950. class ServerConfig:
  951. server_name: str = ""
  952. description: str = ""
  953. motd: str = ""
  954. welcome_message: str = ""
  955. @classmethod
  956. def parse(cls, data: dict[str, Any] | None) -> ServerConfig:
  957. data = data or {}
  958. return cls(
  959. server_name=data.get("serverName", ""),
  960. description=data.get("description", ""),
  961. motd=data.get("motd", ""),
  962. welcome_message=data.get("welcomeMessage", ""),
  963. )
  964. @dataclass
  965. class AdminMember:
  966. user: User | None
  967. roles: list[str] = field(default_factory=list)
  968. created_at: datetime | None = None
  969. has_verified_email: bool = False
  970. verified_emails: list[str] = field(default_factory=list)
  971. viewer_can_delete_account: bool = False
  972. last_login_change: datetime | None = None
  973. @classmethod
  974. def parse(cls, data: dict[str, Any] | None) -> AdminMember | None:
  975. if not data:
  976. return None
  977. return cls(
  978. user=User.parse(data.get("user")),
  979. roles=list(data.get("roles") or []),
  980. created_at=parse_datetime(data.get("createdAt")),
  981. has_verified_email=bool(data.get("hasVerifiedEmail", False)),
  982. verified_emails=list(data.get("verifiedEmails") or []),
  983. viewer_can_delete_account=bool(data.get("viewerCanDeleteAccount", False)),
  984. last_login_change=parse_datetime(data.get("lastLoginChange")),
  985. )
  986. # --- Admin: member/role detail envelopes ---------------------------------
  987. @dataclass
  988. class AdminMemberDetail:
  989. member: AdminMember | None
  990. roles: list[AdminRole] = field(default_factory=list)
  991. available_permissions: list[str] = field(default_factory=list)
  992. viewer_can_assign_roles: bool = False
  993. viewer_can_manage_roles: bool = False
  994. viewer_can_manage_user_permissions: bool = False
  995. @classmethod
  996. def parse(cls, data: dict[str, Any] | None) -> AdminMemberDetail:
  997. data = data or {}
  998. roles = [
  999. r
  1000. for raw in data.get("roles") or []
  1001. if (r := AdminRole.parse(raw)) is not None
  1002. ]
  1003. return cls(
  1004. member=AdminMember.parse(data.get("member")),
  1005. roles=roles,
  1006. available_permissions=list(data.get("availablePermissions") or []),
  1007. viewer_can_assign_roles=bool(data.get("viewerCanAssignRoles", False)),
  1008. viewer_can_manage_roles=bool(data.get("viewerCanManageRoles", False)),
  1009. viewer_can_manage_user_permissions=bool(
  1010. data.get("viewerCanManageUserPermissions", False)
  1011. ),
  1012. )
  1013. @dataclass
  1014. class AdminRoleDetail:
  1015. role: AdminRole | None
  1016. users: list[User] = field(default_factory=list)
  1017. viewer_can_manage_roles: bool = False
  1018. viewer_can_assign_roles: bool = False
  1019. @classmethod
  1020. def parse(cls, data: dict[str, Any] | None) -> AdminRoleDetail:
  1021. data = data or {}
  1022. users = [
  1023. u
  1024. for raw in data.get("users") or []
  1025. if (u := User.parse(raw)) is not None
  1026. ]
  1027. return cls(
  1028. role=AdminRole.parse(data.get("role")),
  1029. users=users,
  1030. viewer_can_manage_roles=bool(data.get("viewerCanManageRoles", False)),
  1031. viewer_can_assign_roles=bool(data.get("viewerCanAssignRoles", False)),
  1032. )
  1033. # --- Admin: room layout --------------------------------------------------
  1034. @dataclass
  1035. class AdminRoomLayoutGroup:
  1036. id: str
  1037. name: str
  1038. description: str = ""
  1039. rooms: list[Room] = field(default_factory=list)
  1040. sidebar_links: list[SidebarLink] = field(default_factory=list)
  1041. can_create_room: bool = False
  1042. @classmethod
  1043. def parse(cls, data: dict[str, Any] | None) -> AdminRoomLayoutGroup | None:
  1044. if not data:
  1045. return None
  1046. rooms: list[Room] = []
  1047. links: list[SidebarLink] = []
  1048. for item in data.get("items") or []:
  1049. room = Room.parse(item.get("room"))
  1050. if room is not None:
  1051. rooms.append(room)
  1052. sl = item.get("sidebarLink")
  1053. if sl:
  1054. links.append(
  1055. SidebarLink(
  1056. id=sl.get("id", ""),
  1057. label=sl.get("label", ""),
  1058. url=sl.get("url", ""),
  1059. )
  1060. )
  1061. return cls(
  1062. id=data.get("id", ""),
  1063. name=data.get("name", ""),
  1064. description=data.get("description", ""),
  1065. rooms=rooms,
  1066. sidebar_links=links,
  1067. can_create_room=bool(data.get("canCreateRoom", False)),
  1068. )
  1069. # --- Viewer snapshot (GetViewerResponse) ----------------------------------
  1070. @dataclass
  1071. class CapabilityGrant:
  1072. """Effective decision for one capability key (e.g. ``admin.view``)."""
  1073. capability: str
  1074. granted: bool = False
  1075. @classmethod
  1076. def parse(cls, data: dict[str, Any] | None) -> CapabilityGrant | None:
  1077. if data is None:
  1078. return None
  1079. return cls(
  1080. capability=data.get("capability", ""),
  1081. granted=bool(data.get("granted", False)),
  1082. )
  1083. @dataclass
  1084. class ViewerCapabilities:
  1085. """Permission-derived capabilities for the authenticated user."""
  1086. grants: list[CapabilityGrant] = field(default_factory=list)
  1087. has_unread_followed_threads: bool = False
  1088. has_pending_followed_thread_notifications: bool = False
  1089. @classmethod
  1090. def parse(cls, data: dict[str, Any] | None) -> ViewerCapabilities | None:
  1091. if data is None:
  1092. return None
  1093. grants = [
  1094. g
  1095. for raw in data.get("grants") or []
  1096. if (g := CapabilityGrant.parse(raw)) is not None
  1097. ]
  1098. return cls(
  1099. grants=grants,
  1100. has_unread_followed_threads=bool(
  1101. data.get("hasUnreadFollowedThreads", False)
  1102. ),
  1103. has_pending_followed_thread_notifications=bool(
  1104. data.get("hasPendingFollowedThreadNotifications", False)
  1105. ),
  1106. )
  1107. @dataclass
  1108. class PermissionGrant:
  1109. """Effective decision for one permission key (e.g. ``server.manage``)."""
  1110. permission: str
  1111. granted: bool = False
  1112. @classmethod
  1113. def parse(cls, data: dict[str, Any] | None) -> PermissionGrant | None:
  1114. if data is None:
  1115. return None
  1116. return cls(
  1117. permission=data.get("permission", ""),
  1118. granted=bool(data.get("granted", False)),
  1119. )
  1120. @dataclass
  1121. class ViewerState:
  1122. """Non-permission server state for the authenticated user."""
  1123. has_unread_rooms: bool = False
  1124. @classmethod
  1125. def parse(cls, data: dict[str, Any] | None) -> ViewerState | None:
  1126. if data is None:
  1127. return None
  1128. return cls(
  1129. has_unread_rooms=bool(data.get("hasUnreadRooms", False)),
  1130. )
  1131. @dataclass
  1132. class RoomNotificationPreference:
  1133. """Notification preference for one joined room."""
  1134. room_id: str
  1135. preference: NotificationPreference | None = None
  1136. @classmethod
  1137. def parse(cls, data: dict[str, Any] | None) -> RoomNotificationPreference | None:
  1138. if data is None:
  1139. return None
  1140. raw_pref = data.get("preference")
  1141. return cls(
  1142. room_id=data.get("roomId", ""),
  1143. preference=NotificationPreference.parse(raw_pref) if raw_pref is not None else None,
  1144. )
  1145. @dataclass
  1146. class AdminEventLogEntry:
  1147. """One durable EVT entry decoded for operator inspection (``AdminEventLogEntry``)."""
  1148. sequence: str = ""
  1149. subject: str = ""
  1150. aggregate_type: str = ""
  1151. aggregate_id: str = ""
  1152. event_type: str = ""
  1153. event_id: str = ""
  1154. actor_id: str = ""
  1155. created_at: datetime | None = None
  1156. payload_json: str = ""
  1157. @classmethod
  1158. def parse(cls, data: dict[str, Any] | None) -> AdminEventLogEntry | None:
  1159. if data is None:
  1160. return None
  1161. return cls(
  1162. sequence=data.get("sequence", ""),
  1163. subject=data.get("subject", ""),
  1164. aggregate_type=data.get("aggregateType", ""),
  1165. aggregate_id=data.get("aggregateId", ""),
  1166. event_type=data.get("eventType", ""),
  1167. event_id=data.get("eventId", ""),
  1168. actor_id=data.get("actorId", ""),
  1169. created_at=parse_datetime(data.get("createdAt")),
  1170. payload_json=data.get("payloadJson", ""),
  1171. )
  1172. @dataclass
  1173. class AdminEventLogPage:
  1174. """Newest-first page of durable EVT entries (``ListEventsResponse``)."""
  1175. entries: list[AdminEventLogEntry] = field(default_factory=list)
  1176. has_older: bool = False
  1177. end_cursor: str | None = None
  1178. total_count: int = 0
  1179. scanned_count: int = 0
  1180. scan_limit: int = 0
  1181. scan_limited: bool = False
  1182. @classmethod
  1183. def parse(cls, data: dict[str, Any] | None) -> AdminEventLogPage:
  1184. data = data or {}
  1185. entries = [
  1186. e
  1187. for raw in data.get("entries") or []
  1188. if (e := AdminEventLogEntry.parse(raw)) is not None
  1189. ]
  1190. return cls(
  1191. entries=entries,
  1192. has_older=bool(data.get("hasOlder", False)),
  1193. end_cursor=data.get("endCursor"),
  1194. total_count=int(data.get("totalCount", 0)),
  1195. scanned_count=int(data.get("scannedCount", 0)),
  1196. scan_limit=int(data.get("scanLimit", 0)),
  1197. scan_limited=bool(data.get("scanLimited", False)),
  1198. )
  1199. @dataclass
  1200. class ViewerSnapshot:
  1201. """Full authenticated viewer snapshot (``GetViewerResponse``)."""
  1202. user: ViewerUser | None = None
  1203. capabilities: ViewerCapabilities | None = None
  1204. server_notification_preference: NotificationPreference | None = None
  1205. room_notification_preferences: list[RoomNotificationPreference] = field(
  1206. default_factory=list
  1207. )
  1208. viewer_permissions: list[PermissionGrant] = field(default_factory=list)
  1209. viewer_state: ViewerState | None = None
  1210. @classmethod
  1211. def parse(cls, data: dict[str, Any] | None) -> ViewerSnapshot:
  1212. data = data or {}
  1213. # ServerViewerPermissions wraps a "permissions" list; flatten it.
  1214. vp_raw = data.get("viewerPermissions") or {}
  1215. viewer_permissions = [
  1216. p
  1217. for raw in vp_raw.get("permissions") or []
  1218. if (p := PermissionGrant.parse(raw)) is not None
  1219. ]
  1220. room_prefs = [
  1221. rp
  1222. for raw in data.get("roomNotificationPreferences") or []
  1223. if (rp := RoomNotificationPreference.parse(raw)) is not None
  1224. ]
  1225. raw_snp = data.get("serverNotificationPreference")
  1226. return cls(
  1227. user=ViewerUser.parse(data.get("user")),
  1228. capabilities=ViewerCapabilities.parse(data.get("capabilities")),
  1229. server_notification_preference=(
  1230. NotificationPreference.parse(raw_snp) if raw_snp is not None else None
  1231. ),
  1232. room_notification_preferences=room_prefs,
  1233. viewer_permissions=viewer_permissions,
  1234. viewer_state=ViewerState.parse(data.get("viewerState")),
  1235. )
  1236. # --- Admin permissions ----------------------------------------------------
  1237. @dataclass
  1238. class PermissionScope:
  1239. kind: PermissionScopeKind = PermissionScopeKind.UNSPECIFIED
  1240. id: str = ""
  1241. @classmethod
  1242. def parse(cls, data: dict[str, Any] | None) -> PermissionScope | None:
  1243. if not data:
  1244. return None
  1245. return cls(
  1246. kind=_parse_enum( # type: ignore[arg-type]
  1247. PermissionScopeKind, data.get("kind"), PermissionScopeKind.UNSPECIFIED
  1248. ),
  1249. id=data.get("id", ""),
  1250. )
  1251. @dataclass
  1252. class ScopedPermissionDecision:
  1253. permission: str = ""
  1254. scope: PermissionScope | None = None
  1255. override: PermissionDecision = PermissionDecision.UNSPECIFIED
  1256. effective: PermissionDecision = PermissionDecision.UNSPECIFIED
  1257. @classmethod
  1258. def parse(cls, data: dict[str, Any] | None) -> ScopedPermissionDecision | None:
  1259. if not data:
  1260. return None
  1261. return cls(
  1262. permission=data.get("permission", ""),
  1263. scope=PermissionScope.parse(data.get("scope")),
  1264. override=_parse_enum( # type: ignore[arg-type]
  1265. PermissionDecision, data.get("override"), PermissionDecision.UNSPECIFIED
  1266. ),
  1267. effective=_parse_enum( # type: ignore[arg-type]
  1268. PermissionDecision, data.get("effective"), PermissionDecision.UNSPECIFIED
  1269. ),
  1270. )
  1271. @dataclass
  1272. class PermissionMatrixScope:
  1273. id: str = ""
  1274. label: str = ""
  1275. kind: PermissionScopeKind = PermissionScopeKind.UNSPECIFIED
  1276. parent_group_id: str = ""
  1277. @classmethod
  1278. def parse(cls, data: dict[str, Any] | None) -> PermissionMatrixScope | None:
  1279. if not data:
  1280. return None
  1281. return cls(
  1282. id=data.get("id", ""),
  1283. label=data.get("label", ""),
  1284. kind=_parse_enum( # type: ignore[arg-type]
  1285. PermissionScopeKind, data.get("kind"), PermissionScopeKind.UNSPECIFIED
  1286. ),
  1287. parent_group_id=data.get("parentGroupId", ""),
  1288. )
  1289. @dataclass
  1290. class PermissionMatrixCell:
  1291. permission: str = ""
  1292. scope_id: str = ""
  1293. override: PermissionDecision = PermissionDecision.UNSPECIFIED
  1294. effective: PermissionDecision = PermissionDecision.UNSPECIFIED
  1295. @classmethod
  1296. def parse(cls, data: dict[str, Any] | None) -> PermissionMatrixCell | None:
  1297. if not data:
  1298. return None
  1299. return cls(
  1300. permission=data.get("permission", ""),
  1301. scope_id=data.get("scopeId", ""),
  1302. override=_parse_enum( # type: ignore[arg-type]
  1303. PermissionDecision, data.get("override"), PermissionDecision.UNSPECIFIED
  1304. ),
  1305. effective=_parse_enum( # type: ignore[arg-type]
  1306. PermissionDecision, data.get("effective"), PermissionDecision.UNSPECIFIED
  1307. ),
  1308. )
  1309. @dataclass
  1310. class RolePermissionDecisions:
  1311. """Resource-oriented permission decisions for one role.
  1312. Maps to ``ListRolePermissionDecisionsResponse``.
  1313. """
  1314. role_name: str = ""
  1315. decisions: list[ScopedPermissionDecision] = field(default_factory=list)
  1316. @classmethod
  1317. def parse(cls, data: dict[str, Any] | None) -> RolePermissionDecisions:
  1318. data = data or {}
  1319. decisions = [
  1320. d
  1321. for raw in data.get("decisions") or []
  1322. if (d := ScopedPermissionDecision.parse(raw)) is not None
  1323. ]
  1324. return cls(
  1325. role_name=data.get("roleName", ""),
  1326. decisions=decisions,
  1327. )
  1328. @dataclass
  1329. class UserPermissionDecisions:
  1330. """Resource-oriented permission decisions for one user.
  1331. Maps to ``ListUserPermissionDecisionsResponse``.
  1332. """
  1333. user_id: str = ""
  1334. decisions: list[ScopedPermissionDecision] = field(default_factory=list)
  1335. @classmethod
  1336. def parse(cls, data: dict[str, Any] | None) -> UserPermissionDecisions:
  1337. data = data or {}
  1338. decisions = [
  1339. d
  1340. for raw in data.get("decisions") or []
  1341. if (d := ScopedPermissionDecision.parse(raw)) is not None
  1342. ]
  1343. return cls(
  1344. user_id=data.get("userId", ""),
  1345. decisions=decisions,
  1346. )
  1347. @dataclass
  1348. class RolePermissionMatrix:
  1349. """Permission matrix for one role across all scopes (``RolePermissionMatrix``)."""
  1350. role_name: str = ""
  1351. applicable_permissions: list[str] = field(default_factory=list)
  1352. scopes: list[PermissionMatrixScope] = field(default_factory=list)
  1353. cells: list[PermissionMatrixCell] = field(default_factory=list)
  1354. @classmethod
  1355. def parse(cls, data: dict[str, Any] | None) -> RolePermissionMatrix | None:
  1356. if not data:
  1357. return None
  1358. scopes = [
  1359. s
  1360. for raw in data.get("scopes") or []
  1361. if (s := PermissionMatrixScope.parse(raw)) is not None
  1362. ]
  1363. cells = [
  1364. c
  1365. for raw in data.get("cells") or []
  1366. if (c := PermissionMatrixCell.parse(raw)) is not None
  1367. ]
  1368. return cls(
  1369. role_name=data.get("roleName", ""),
  1370. applicable_permissions=list(data.get("applicablePermissions") or []),
  1371. scopes=scopes,
  1372. cells=cells,
  1373. )
  1374. @dataclass
  1375. class UserPermissionMatrix:
  1376. """Permission matrix for one user across all scopes (``UserPermissionMatrix``)."""
  1377. user_id: str = ""
  1378. applicable_permissions: list[str] = field(default_factory=list)
  1379. scopes: list[PermissionMatrixScope] = field(default_factory=list)
  1380. cells: list[PermissionMatrixCell] = field(default_factory=list)
  1381. @classmethod
  1382. def parse(cls, data: dict[str, Any] | None) -> UserPermissionMatrix | None:
  1383. if not data:
  1384. return None
  1385. scopes = [
  1386. s
  1387. for raw in data.get("scopes") or []
  1388. if (s := PermissionMatrixScope.parse(raw)) is not None
  1389. ]
  1390. cells = [
  1391. c
  1392. for raw in data.get("cells") or []
  1393. if (c := PermissionMatrixCell.parse(raw)) is not None
  1394. ]
  1395. return cls(
  1396. user_id=data.get("userId", ""),
  1397. applicable_permissions=list(data.get("applicablePermissions") or []),
  1398. scopes=scopes,
  1399. cells=cells,
  1400. )
  1401. # --- Group G: admin diagnostics tree (diagnostics.proto) -----------------
  1402. @dataclass
  1403. class AdminProjectionMetric:
  1404. """Projection-specific diagnostic metric (``AdminProjectionMetric``)."""
  1405. name: str = ""
  1406. value: int = 0
  1407. bytes: int = 0
  1408. @classmethod
  1409. def parse(cls, data: dict[str, Any] | None) -> AdminProjectionMetric | None:
  1410. if data is None:
  1411. return None
  1412. return cls(
  1413. name=data.get("name", ""),
  1414. value=int(data.get("value", 0)),
  1415. bytes=int(data.get("bytes", 0)),
  1416. )
  1417. @dataclass
  1418. class AdminProjectionState:
  1419. """Event-sourced projection health and memory estimates (``AdminProjectionState``)."""
  1420. key: str = ""
  1421. name: str = ""
  1422. subjects: list[str] = field(default_factory=list)
  1423. started: bool = False
  1424. startup_duration_seconds: float | None = None
  1425. last_applied_sequence: str = ""
  1426. matching_stream_sequence: str = ""
  1427. stream_last_sequence: str = ""
  1428. lag: int = 0
  1429. failed: bool = False
  1430. failed_sequence: str = ""
  1431. failure: str = ""
  1432. entry_count: int = 0
  1433. estimated_bytes: int = 0
  1434. average_entry_bytes: int = 0
  1435. metrics: list[AdminProjectionMetric] = field(default_factory=list)
  1436. @classmethod
  1437. def parse(cls, data: dict[str, Any] | None) -> AdminProjectionState | None:
  1438. if data is None:
  1439. return None
  1440. raw_sds = data.get("startupDurationSeconds")
  1441. metrics = [
  1442. m
  1443. for raw in data.get("metrics") or []
  1444. if (m := AdminProjectionMetric.parse(raw)) is not None
  1445. ]
  1446. return cls(
  1447. key=data.get("key", ""),
  1448. name=data.get("name", ""),
  1449. subjects=list(data.get("subjects") or []),
  1450. started=bool(data.get("started", False)),
  1451. startup_duration_seconds=(
  1452. float(raw_sds) if raw_sds is not None else None
  1453. ),
  1454. last_applied_sequence=data.get("lastAppliedSequence", ""),
  1455. matching_stream_sequence=data.get("matchingStreamSequence", ""),
  1456. stream_last_sequence=data.get("streamLastSequence", ""),
  1457. lag=int(data.get("lag", 0)),
  1458. failed=bool(data.get("failed", False)),
  1459. failed_sequence=data.get("failedSequence", ""),
  1460. failure=data.get("failure", ""),
  1461. entry_count=int(data.get("entryCount", 0)),
  1462. estimated_bytes=int(data.get("estimatedBytes", 0)),
  1463. average_entry_bytes=int(data.get("averageEntryBytes", 0)),
  1464. metrics=metrics,
  1465. )
  1466. @dataclass
  1467. class AdminConnectionInfo:
  1468. """Current NATS server connection metadata (``AdminConnectionInfo``)."""
  1469. connected: bool = False
  1470. server_id: str = ""
  1471. server_name: str = ""
  1472. version: str = ""
  1473. max_payload: int = 0
  1474. rtt: str = ""
  1475. @classmethod
  1476. def parse(cls, data: dict[str, Any] | None) -> AdminConnectionInfo | None:
  1477. if data is None:
  1478. return None
  1479. return cls(
  1480. connected=bool(data.get("connected", False)),
  1481. server_id=data.get("serverId", ""),
  1482. server_name=data.get("serverName", ""),
  1483. version=data.get("version", ""),
  1484. max_payload=int(data.get("maxPayload", 0)),
  1485. rtt=data.get("rtt", ""),
  1486. )
  1487. @dataclass
  1488. class AdminAccountInfo:
  1489. """JetStream account limits and usage (``AdminAccountInfo``)."""
  1490. memory: int = 0
  1491. memory_used: int = 0
  1492. storage: int = 0
  1493. storage_used: int = 0
  1494. streams: int = 0
  1495. streams_used: int = 0
  1496. consumers: int = 0
  1497. consumers_used: int = 0
  1498. @classmethod
  1499. def parse(cls, data: dict[str, Any] | None) -> AdminAccountInfo | None:
  1500. if data is None:
  1501. return None
  1502. return cls(
  1503. memory=int(data.get("memory", 0)),
  1504. memory_used=int(data.get("memoryUsed", 0)),
  1505. storage=int(data.get("storage", 0)),
  1506. storage_used=int(data.get("storageUsed", 0)),
  1507. streams=int(data.get("streams", 0)),
  1508. streams_used=int(data.get("streamsUsed", 0)),
  1509. consumers=int(data.get("consumers", 0)),
  1510. consumers_used=int(data.get("consumersUsed", 0)),
  1511. )
  1512. @dataclass
  1513. class AdminServerStats:
  1514. """Aggregate Chatto object counts (``AdminServerStats``)."""
  1515. user_count: int = 0
  1516. channel_room_count: int = 0
  1517. dm_room_count: int = 0
  1518. @classmethod
  1519. def parse(cls, data: dict[str, Any] | None) -> AdminServerStats | None:
  1520. if data is None:
  1521. return None
  1522. return cls(
  1523. user_count=int(data.get("userCount", 0)),
  1524. channel_room_count=int(data.get("channelRoomCount", 0)),
  1525. dm_room_count=int(data.get("dmRoomCount", 0)),
  1526. )
  1527. @dataclass
  1528. class AdminNatsStreamInfo:
  1529. """JetStream stream state (``AdminNatsStreamInfo``)."""
  1530. name: str = ""
  1531. description: str = ""
  1532. subjects: list[str] = field(default_factory=list)
  1533. storage: str = ""
  1534. messages: int = 0
  1535. bytes: int = 0
  1536. first_sequence: str = ""
  1537. last_sequence: str = ""
  1538. consumer_count: int = 0
  1539. replicas: int = 0
  1540. cluster_leader: str = ""
  1541. @classmethod
  1542. def parse(cls, data: dict[str, Any] | None) -> AdminNatsStreamInfo | None:
  1543. if data is None:
  1544. return None
  1545. return cls(
  1546. name=data.get("name", ""),
  1547. description=data.get("description", ""),
  1548. subjects=list(data.get("subjects") or []),
  1549. storage=data.get("storage", ""),
  1550. messages=int(data.get("messages", 0)),
  1551. bytes=int(data.get("bytes", 0)),
  1552. first_sequence=data.get("firstSequence", ""),
  1553. last_sequence=data.get("lastSequence", ""),
  1554. consumer_count=int(data.get("consumerCount", 0)),
  1555. replicas=int(data.get("replicas", 0)),
  1556. cluster_leader=data.get("clusterLeader", ""),
  1557. )
  1558. @dataclass
  1559. class AdminNatsConsumerInfo:
  1560. """JetStream consumer state (``AdminNatsConsumerInfo``)."""
  1561. stream: str = ""
  1562. name: str = ""
  1563. durable: str = ""
  1564. filter_subject: str = ""
  1565. filter_subjects: list[str] = field(default_factory=list)
  1566. ack_policy: str = ""
  1567. pull_based: bool = False
  1568. push_bound: bool = False
  1569. pending: int = 0
  1570. ack_pending: int = 0
  1571. redelivered: int = 0
  1572. waiting: int = 0
  1573. delivered_consumer_sequence: str = ""
  1574. delivered_stream_sequence: str = ""
  1575. ack_floor_consumer_sequence: str = ""
  1576. ack_floor_stream_sequence: str = ""
  1577. @classmethod
  1578. def parse(cls, data: dict[str, Any] | None) -> AdminNatsConsumerInfo | None:
  1579. if data is None:
  1580. return None
  1581. return cls(
  1582. stream=data.get("stream", ""),
  1583. name=data.get("name", ""),
  1584. durable=data.get("durable", ""),
  1585. filter_subject=data.get("filterSubject", ""),
  1586. filter_subjects=list(data.get("filterSubjects") or []),
  1587. ack_policy=data.get("ackPolicy", ""),
  1588. pull_based=bool(data.get("pullBased", False)),
  1589. push_bound=bool(data.get("pushBound", False)),
  1590. pending=int(data.get("pending", 0)),
  1591. ack_pending=int(data.get("ackPending", 0)),
  1592. redelivered=int(data.get("redelivered", 0)),
  1593. waiting=int(data.get("waiting", 0)),
  1594. delivered_consumer_sequence=data.get("deliveredConsumerSequence", ""),
  1595. delivered_stream_sequence=data.get("deliveredStreamSequence", ""),
  1596. ack_floor_consumer_sequence=data.get("ackFloorConsumerSequence", ""),
  1597. ack_floor_stream_sequence=data.get("ackFloorStreamSequence", ""),
  1598. )
  1599. @dataclass
  1600. class AdminNatsStats:
  1601. """JetStream stream and consumer diagnostics (``AdminNatsStats``)."""
  1602. total_messages: int = 0
  1603. total_bytes: int = 0
  1604. total_consumer_pending: int = 0
  1605. total_ack_pending: int = 0
  1606. streams: list[AdminNatsStreamInfo] = field(default_factory=list)
  1607. consumers: list[AdminNatsConsumerInfo] = field(default_factory=list)
  1608. @classmethod
  1609. def parse(cls, data: dict[str, Any] | None) -> AdminNatsStats | None:
  1610. if data is None:
  1611. return None
  1612. streams = [
  1613. s
  1614. for raw in data.get("streams") or []
  1615. if (s := AdminNatsStreamInfo.parse(raw)) is not None
  1616. ]
  1617. consumers = [
  1618. c
  1619. for raw in data.get("consumers") or []
  1620. if (c := AdminNatsConsumerInfo.parse(raw)) is not None
  1621. ]
  1622. return cls(
  1623. total_messages=int(data.get("totalMessages", 0)),
  1624. total_bytes=int(data.get("totalBytes", 0)),
  1625. total_consumer_pending=int(data.get("totalConsumerPending", 0)),
  1626. total_ack_pending=int(data.get("totalAckPending", 0)),
  1627. streams=streams,
  1628. consumers=consumers,
  1629. )
  1630. @dataclass
  1631. class AdminSystemInfo:
  1632. """Broker and application diagnostics (``AdminSystemInfo``)."""
  1633. connection: AdminConnectionInfo | None = None
  1634. account: AdminAccountInfo | None = None
  1635. nats: AdminNatsStats | None = None
  1636. stats: AdminServerStats | None = None
  1637. @classmethod
  1638. def parse(cls, data: dict[str, Any] | None) -> AdminSystemInfo | None:
  1639. if data is None:
  1640. return None
  1641. return cls(
  1642. connection=AdminConnectionInfo.parse(data.get("connection")),
  1643. account=AdminAccountInfo.parse(data.get("account")),
  1644. nats=AdminNatsStats.parse(data.get("nats")),
  1645. stats=AdminServerStats.parse(data.get("stats")),
  1646. )
  1647. @dataclass
  1648. class AdminSystemInfoSnapshot:
  1649. """Envelope for ``GetSystemInfoResponse``.
  1650. Contains broker/JetStream diagnostics and projection health snapshots.
  1651. """
  1652. system_info: AdminSystemInfo | None = None
  1653. projections: list[AdminProjectionState] = field(default_factory=list)
  1654. @classmethod
  1655. def parse(cls, data: dict[str, Any] | None) -> AdminSystemInfoSnapshot:
  1656. data = data or {}
  1657. projections = [
  1658. p
  1659. for raw in data.get("projections") or []
  1660. if (p := AdminProjectionState.parse(raw)) is not None
  1661. ]
  1662. return cls(
  1663. system_info=AdminSystemInfo.parse(data.get("systemInfo")),
  1664. projections=projections,
  1665. )