types.py 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121
  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 ImageFitMode(StrEnum):
  61. UNSPECIFIED = "IMAGE_FIT_MODE_UNSPECIFIED"
  62. CONTAIN = "IMAGE_FIT_MODE_CONTAIN"
  63. COVER = "IMAGE_FIT_MODE_COVER"
  64. class VideoProcessingStatus(StrEnum):
  65. UNSPECIFIED = "MESSAGE_VIDEO_PROCESSING_STATUS_UNSPECIFIED"
  66. PROCESSING = "MESSAGE_VIDEO_PROCESSING_STATUS_PROCESSING"
  67. COMPLETED = "MESSAGE_VIDEO_PROCESSING_STATUS_COMPLETED"
  68. FAILED = "MESSAGE_VIDEO_PROCESSING_STATUS_FAILED"
  69. class AssetUploadStatus(StrEnum):
  70. UNSPECIFIED = "ASSET_UPLOAD_STATUS_UNSPECIFIED"
  71. OPEN = "ASSET_UPLOAD_STATUS_OPEN"
  72. COMPLETED = "ASSET_UPLOAD_STATUS_COMPLETED"
  73. CANCELLED = "ASSET_UPLOAD_STATUS_CANCELLED"
  74. class AdminRoomLayoutItemKind(StrEnum):
  75. UNSPECIFIED = "ADMIN_ROOM_LAYOUT_ITEM_KIND_UNSPECIFIED"
  76. ROOM = "ADMIN_ROOM_LAYOUT_ITEM_KIND_ROOM"
  77. SIDEBAR_LINK = "ADMIN_ROOM_LAYOUT_ITEM_KIND_SIDEBAR_LINK"
  78. # --- Time helpers --------------------------------------------------------
  79. def parse_datetime(value: Any) -> datetime | None:
  80. """Parse an RFC 3339 timestamp string (proto JSON encoding for Timestamp)."""
  81. if not value:
  82. return None
  83. if isinstance(value, datetime):
  84. return value
  85. text = str(value)
  86. if text.endswith("Z"):
  87. text = text[:-1] + "+00:00"
  88. try:
  89. return datetime.fromisoformat(text)
  90. except ValueError:
  91. return None
  92. def format_datetime(value: datetime) -> str:
  93. """Format a ``datetime`` as an RFC 3339 UTC timestamp (``...Z``)."""
  94. if value.tzinfo is None:
  95. value = value.replace(tzinfo=UTC)
  96. return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
  97. # --- Core resource dataclasses ------------------------------------------
  98. @dataclass
  99. class CustomUserStatus:
  100. emoji: str
  101. text: str
  102. expires_at: datetime | None = None
  103. @classmethod
  104. def parse(cls, data: dict[str, Any] | None) -> CustomUserStatus | None:
  105. if not data:
  106. return None
  107. return cls(
  108. emoji=data.get("emoji", ""),
  109. text=data.get("text", ""),
  110. expires_at=parse_datetime(data.get("expiresAt")),
  111. )
  112. @dataclass
  113. class User:
  114. id: str
  115. login: str
  116. display_name: str
  117. presence_status: PresenceStatus = PresenceStatus.UNSPECIFIED
  118. avatar_url: str | None = None
  119. deleted: bool = False
  120. custom_status: CustomUserStatus | None = None
  121. @classmethod
  122. def parse(cls, data: dict[str, Any] | None) -> User | None:
  123. if not data:
  124. return None
  125. return cls(
  126. id=data.get("id", ""),
  127. login=data.get("login", ""),
  128. display_name=data.get("displayName", ""),
  129. presence_status=_parse_enum(
  130. PresenceStatus, data.get("presenceStatus"), PresenceStatus.UNSPECIFIED
  131. ), # type: ignore[arg-type]
  132. avatar_url=data.get("avatarUrl"),
  133. deleted=bool(data.get("deleted", False)),
  134. custom_status=CustomUserStatus.parse(data.get("customStatus")),
  135. )
  136. @dataclass
  137. class UserSettings:
  138. timezone: str | None = None
  139. time_format: TimeFormat = TimeFormat.UNSPECIFIED
  140. @classmethod
  141. def parse(cls, data: dict[str, Any] | None) -> UserSettings:
  142. data = data or {}
  143. return cls(
  144. timezone=data.get("timezone"),
  145. time_format=_parse_enum(
  146. TimeFormat, data.get("timeFormat"), TimeFormat.UNSPECIFIED
  147. ), # type: ignore[arg-type]
  148. )
  149. @dataclass
  150. class ViewerUser:
  151. profile: User | None
  152. settings: UserSettings
  153. has_verified_email: bool = False
  154. viewer_can_delete_account: bool = False
  155. last_login_change: datetime | None = None
  156. has_password: bool = False
  157. @classmethod
  158. def parse(cls, data: dict[str, Any] | None) -> ViewerUser | None:
  159. if not data:
  160. return None
  161. return cls(
  162. profile=User.parse(data.get("profile")),
  163. settings=UserSettings.parse(data.get("settings")),
  164. has_verified_email=bool(data.get("hasVerifiedEmail", False)),
  165. viewer_can_delete_account=bool(data.get("viewerCanDeleteAccount", False)),
  166. last_login_change=parse_datetime(data.get("lastLoginChange")),
  167. has_password=bool(data.get("hasPassword", False)),
  168. )
  169. @dataclass
  170. class DirectoryMember:
  171. user: User | None
  172. roles: list[str] = field(default_factory=list)
  173. created_at: datetime | None = None
  174. @classmethod
  175. def parse(cls, data: dict[str, Any] | None) -> DirectoryMember | None:
  176. if not data:
  177. return None
  178. return cls(
  179. user=User.parse(data.get("user")),
  180. roles=list(data.get("roles") or []),
  181. created_at=parse_datetime(data.get("createdAt")),
  182. )
  183. @dataclass
  184. class Page:
  185. total_count: int = 0
  186. has_more: bool = False
  187. @classmethod
  188. def parse(cls, data: dict[str, Any] | None) -> Page:
  189. data = data or {}
  190. return cls(
  191. total_count=int(data.get("totalCount", 0)),
  192. has_more=bool(data.get("hasMore", False)),
  193. )
  194. @dataclass
  195. class ServerProfile:
  196. name: str
  197. version: str = ""
  198. logo_url: str | None = None
  199. banner_url: str | None = None
  200. welcome_message: str | None = None
  201. description: str | None = None
  202. @classmethod
  203. def parse(cls, data: dict[str, Any] | None) -> ServerProfile:
  204. data = data or {}
  205. return cls(
  206. name=data.get("name", ""),
  207. version=data.get("version", ""),
  208. logo_url=data.get("logoUrl"),
  209. banner_url=data.get("bannerUrl"),
  210. welcome_message=data.get("welcomeMessage"),
  211. description=data.get("description"),
  212. )
  213. @dataclass
  214. class ProviderMetadata:
  215. id: str
  216. type: str
  217. label: str
  218. login_url: str = ""
  219. @classmethod
  220. def parse(cls, data: dict[str, Any]) -> ProviderMetadata:
  221. return cls(
  222. id=data.get("id", ""),
  223. type=data.get("type", ""),
  224. label=data.get("label", ""),
  225. login_url=data.get("loginUrl", ""),
  226. )
  227. @dataclass
  228. class ServerLogin:
  229. direct_registration_enabled: bool = False
  230. providers: list[ProviderMetadata] = field(default_factory=list)
  231. authorize_url: str = ""
  232. @classmethod
  233. def parse(cls, data: dict[str, Any] | None) -> ServerLogin:
  234. data = data or {}
  235. return cls(
  236. direct_registration_enabled=bool(data.get("directRegistrationEnabled", False)),
  237. providers=[ProviderMetadata.parse(p) for p in data.get("providers") or []],
  238. authorize_url=data.get("authorizeUrl", ""),
  239. )
  240. @dataclass
  241. class ServerRuntimeConfig:
  242. push_notifications_enabled: bool = False
  243. vapid_public_key: str | None = None
  244. livekit_url: str | None = None
  245. video_processing_enabled: bool = False
  246. max_upload_size: int = 0
  247. max_video_upload_size: int = 0
  248. message_edit_window_seconds: int = 0
  249. @classmethod
  250. def parse(cls, data: dict[str, Any] | None) -> ServerRuntimeConfig:
  251. data = data or {}
  252. return cls(
  253. push_notifications_enabled=bool(data.get("pushNotificationsEnabled", False)),
  254. vapid_public_key=data.get("vapidPublicKey"),
  255. livekit_url=data.get("livekitUrl"),
  256. video_processing_enabled=bool(data.get("videoProcessingEnabled", False)),
  257. max_upload_size=int(data.get("maxUploadSize", 0)),
  258. max_video_upload_size=int(data.get("maxVideoUploadSize", 0)),
  259. message_edit_window_seconds=int(data.get("messageEditWindowSeconds", 0)),
  260. )
  261. @dataclass
  262. class Room:
  263. id: str
  264. kind: RoomKind = RoomKind.UNSPECIFIED
  265. name: str = ""
  266. description: str = ""
  267. archived: bool = False
  268. group_id: str = ""
  269. universal: bool = False
  270. @classmethod
  271. def parse(cls, data: dict[str, Any] | None) -> Room | None:
  272. if not data:
  273. return None
  274. return cls(
  275. id=data.get("id", ""),
  276. kind=_parse_enum(RoomKind, data.get("kind"), RoomKind.UNSPECIFIED), # type: ignore[arg-type]
  277. name=data.get("name", ""),
  278. description=data.get("description", ""),
  279. archived=bool(data.get("archived", False)),
  280. group_id=data.get("groupId", ""),
  281. universal=bool(data.get("universal", False)),
  282. )
  283. @dataclass
  284. class RoomSummary:
  285. id: str
  286. kind: RoomKind = RoomKind.UNSPECIFIED
  287. name: str = ""
  288. @classmethod
  289. def parse(cls, data: dict[str, Any] | None) -> RoomSummary | None:
  290. if not data:
  291. return None
  292. return cls(
  293. id=data.get("id", ""),
  294. kind=_parse_enum(RoomKind, data.get("kind"), RoomKind.UNSPECIFIED), # type: ignore[arg-type]
  295. name=data.get("name", ""),
  296. )
  297. @dataclass
  298. class RoomViewerState:
  299. is_member: bool = False
  300. has_unread: bool = False
  301. @classmethod
  302. def parse(cls, data: dict[str, Any] | None) -> RoomViewerState:
  303. data = data or {}
  304. return cls(
  305. is_member=bool(data.get("isMember", False)),
  306. has_unread=bool(data.get("hasUnread", False)),
  307. )
  308. @dataclass
  309. class RoomWithViewerState:
  310. room: Room | None
  311. viewer_state: RoomViewerState
  312. @classmethod
  313. def parse(cls, data: dict[str, Any] | None) -> RoomWithViewerState | None:
  314. if not data:
  315. return None
  316. return cls(
  317. room=Room.parse(data.get("room")),
  318. viewer_state=RoomViewerState.parse(data.get("viewerState")),
  319. )
  320. @dataclass
  321. class SidebarLink:
  322. id: str
  323. label: str
  324. url: str
  325. @dataclass
  326. class RoomGroup:
  327. id: str
  328. name: str
  329. description: str = ""
  330. rooms: list[RoomWithViewerState] = field(default_factory=list)
  331. sidebar_links: list[SidebarLink] = field(default_factory=list)
  332. @classmethod
  333. def parse(cls, data: dict[str, Any] | None) -> RoomGroup | None:
  334. if not data:
  335. return None
  336. rooms: list[RoomWithViewerState] = []
  337. links: list[SidebarLink] = []
  338. for item in data.get("items") or []:
  339. room = RoomWithViewerState.parse(item.get("room"))
  340. if room:
  341. rooms.append(room)
  342. sl = item.get("sidebarLink")
  343. if sl:
  344. links.append(
  345. SidebarLink(
  346. id=sl.get("id", ""),
  347. label=sl.get("label", ""),
  348. url=sl.get("url", ""),
  349. )
  350. )
  351. return cls(
  352. id=data.get("id", ""),
  353. name=data.get("name", ""),
  354. description=data.get("description", ""),
  355. rooms=rooms,
  356. sidebar_links=links,
  357. )
  358. @dataclass
  359. class RoomBan:
  360. id: str
  361. room_id: str
  362. user_id: str
  363. moderator_id: str = ""
  364. reason: str = ""
  365. created_at: datetime | None = None
  366. expires_at: datetime | None = None
  367. room: Room | None = None
  368. user: DirectoryMember | None = None
  369. moderator: DirectoryMember | None = None
  370. @classmethod
  371. def parse(cls, data: dict[str, Any] | None) -> RoomBan | None:
  372. if not data:
  373. return None
  374. return cls(
  375. id=data.get("id", ""),
  376. room_id=data.get("roomId", ""),
  377. user_id=data.get("userId", ""),
  378. moderator_id=data.get("moderatorId", ""),
  379. reason=data.get("reason", ""),
  380. created_at=parse_datetime(data.get("createdAt")),
  381. expires_at=parse_datetime(data.get("expiresAt")),
  382. room=Room.parse(data.get("room")),
  383. user=DirectoryMember.parse(data.get("user")),
  384. moderator=DirectoryMember.parse(data.get("moderator")),
  385. )
  386. @dataclass
  387. class AssetUrl:
  388. url: str
  389. expires_at: datetime | None = None
  390. @classmethod
  391. def parse(cls, data: dict[str, Any] | None) -> AssetUrl | None:
  392. if not data:
  393. return None
  394. return cls(
  395. url=data.get("url", ""),
  396. expires_at=parse_datetime(data.get("expiresAt")),
  397. )
  398. @dataclass
  399. class VideoVariant:
  400. quality: str
  401. width: int
  402. height: int
  403. size: int
  404. asset_url: AssetUrl | None
  405. @classmethod
  406. def parse(cls, data: dict[str, Any]) -> VideoVariant:
  407. return cls(
  408. quality=data.get("quality", ""),
  409. width=int(data.get("width", 0)),
  410. height=int(data.get("height", 0)),
  411. size=int(data.get("size", 0)),
  412. asset_url=AssetUrl.parse(data.get("assetUrl")),
  413. )
  414. @dataclass
  415. class VideoProcessing:
  416. status: VideoProcessingStatus = VideoProcessingStatus.UNSPECIFIED
  417. duration_ms: int = 0
  418. width: int = 0
  419. height: int = 0
  420. source_available: bool = False
  421. reason_code: str = ""
  422. thumbnail_asset_url: AssetUrl | None = None
  423. variants: list[VideoVariant] = field(default_factory=list)
  424. @classmethod
  425. def parse(cls, data: dict[str, Any] | None) -> VideoProcessing | None:
  426. if not data:
  427. return None
  428. return cls(
  429. status=_parse_enum(
  430. VideoProcessingStatus,
  431. data.get("status"),
  432. VideoProcessingStatus.UNSPECIFIED,
  433. ), # type: ignore[arg-type]
  434. duration_ms=int(data.get("durationMs", 0)),
  435. width=int(data.get("width", 0)),
  436. height=int(data.get("height", 0)),
  437. source_available=bool(data.get("sourceAvailable", False)),
  438. reason_code=data.get("reasonCode", ""),
  439. thumbnail_asset_url=AssetUrl.parse(data.get("thumbnailAssetUrl")),
  440. variants=[VideoVariant.parse(v) for v in data.get("variants") or []],
  441. )
  442. @dataclass
  443. class MessageAttachment:
  444. id: str
  445. filename: str
  446. content_type: str
  447. width: int = 0
  448. height: int = 0
  449. asset_url: AssetUrl | None = None
  450. thumbnail_asset_url: AssetUrl | None = None
  451. video_processing: VideoProcessing | None = None
  452. @classmethod
  453. def parse(cls, data: dict[str, Any]) -> MessageAttachment:
  454. return cls(
  455. id=data.get("id", ""),
  456. filename=data.get("filename", ""),
  457. content_type=data.get("contentType", ""),
  458. width=int(data.get("width", 0)),
  459. height=int(data.get("height", 0)),
  460. asset_url=AssetUrl.parse(data.get("assetUrl")),
  461. thumbnail_asset_url=AssetUrl.parse(data.get("thumbnailAssetUrl")),
  462. video_processing=VideoProcessing.parse(data.get("videoProcessing")),
  463. )
  464. @dataclass
  465. class LinkPreview:
  466. url: str
  467. title: str | None = None
  468. description: str | None = None
  469. image_url: str | None = None
  470. image_asset_id: str | None = None
  471. site_name: str | None = None
  472. embed_type: str | None = None
  473. embed_id: str | None = None
  474. @classmethod
  475. def parse(cls, data: dict[str, Any] | None) -> LinkPreview | None:
  476. if not data:
  477. return None
  478. return cls(
  479. url=data.get("url", ""),
  480. title=data.get("title"),
  481. description=data.get("description"),
  482. image_url=data.get("imageUrl"),
  483. image_asset_id=data.get("imageAssetId"),
  484. site_name=data.get("siteName"),
  485. embed_type=data.get("embedType"),
  486. embed_id=data.get("embedId"),
  487. )
  488. @dataclass
  489. class MessageReaction:
  490. emoji: str
  491. count: int = 0
  492. has_reacted: bool = False
  493. preview_user_ids: list[str] = field(default_factory=list)
  494. @classmethod
  495. def parse(cls, data: dict[str, Any]) -> MessageReaction:
  496. return cls(
  497. emoji=data.get("emoji", ""),
  498. count=int(data.get("count", 0)),
  499. has_reacted=bool(data.get("hasReacted", False)),
  500. preview_user_ids=list(data.get("previewUserIds") or []),
  501. )
  502. @dataclass
  503. class ThreadSummary:
  504. thread_root_event_id: str
  505. reply_count: int = 0
  506. last_reply_at: datetime | None = None
  507. participant_preview_user_ids: list[str] = field(default_factory=list)
  508. participant_count: int = 0
  509. is_following: bool | None = None
  510. has_unread: bool | None = None
  511. @classmethod
  512. def parse(cls, data: dict[str, Any] | None) -> ThreadSummary | None:
  513. if not data:
  514. return None
  515. viewer = data.get("viewerState") or {}
  516. return cls(
  517. thread_root_event_id=data.get("threadRootEventId", ""),
  518. reply_count=int(data.get("replyCount", 0)),
  519. last_reply_at=parse_datetime(data.get("lastReplyAt")),
  520. participant_preview_user_ids=list(
  521. data.get("participantPreviewUserIds") or []
  522. ),
  523. participant_count=int(data.get("participantCount", 0)),
  524. is_following=viewer.get("isFollowing") if "isFollowing" in viewer else None,
  525. has_unread=viewer.get("hasUnread") if "hasUnread" in viewer else None,
  526. )
  527. @dataclass
  528. class Message:
  529. id: str
  530. room_id: str
  531. created_at: datetime | None
  532. actor_id: str
  533. body: str | None = None
  534. attachments: list[MessageAttachment] = field(default_factory=list)
  535. link_preview: LinkPreview | None = None
  536. updated_at: datetime | None = None
  537. in_reply_to: str = ""
  538. thread_root_event_id: str = ""
  539. echo_of_event_id: str = ""
  540. echo_from_thread_root_event_id: str = ""
  541. channel_echo_event_id: str = ""
  542. reactions: list[MessageReaction] = field(default_factory=list)
  543. thread: ThreadSummary | None = None
  544. deleted_at: datetime | None = None
  545. @classmethod
  546. def parse(cls, data: dict[str, Any] | None) -> Message | None:
  547. if not data:
  548. return None
  549. return cls(
  550. id=data.get("id", ""),
  551. room_id=data.get("roomId", ""),
  552. created_at=parse_datetime(data.get("createdAt")),
  553. actor_id=data.get("actorId", ""),
  554. body=data.get("body"),
  555. attachments=[MessageAttachment.parse(a) for a in data.get("attachments") or []],
  556. link_preview=LinkPreview.parse(data.get("linkPreview")),
  557. updated_at=parse_datetime(data.get("updatedAt")),
  558. in_reply_to=data.get("inReplyTo", ""),
  559. thread_root_event_id=data.get("threadRootEventId", ""),
  560. echo_of_event_id=data.get("echoOfEventId", ""),
  561. echo_from_thread_root_event_id=data.get("echoFromThreadRootEventId", ""),
  562. channel_echo_event_id=data.get("channelEchoEventId", ""),
  563. reactions=[MessageReaction.parse(r) for r in data.get("reactions") or []],
  564. thread=ThreadSummary.parse(data.get("thread")),
  565. deleted_at=parse_datetime(data.get("deletedAt")),
  566. )
  567. @dataclass
  568. class TimelineEvent:
  569. id: str
  570. created_at: datetime | None
  571. actor_id: str = ""
  572. kind: str = "" # "message_posted", "room_created", "user_joined_room", ...
  573. message: Message | None = None
  574. room_id: str = ""
  575. @classmethod
  576. def parse(cls, data: dict[str, Any]) -> TimelineEvent:
  577. kind = ""
  578. message: Message | None = None
  579. room_id = ""
  580. for candidate in (
  581. "messagePosted",
  582. "roomCreated",
  583. "roomUpdated",
  584. "roomDeleted",
  585. "roomArchived",
  586. "roomUnarchived",
  587. "userJoinedRoom",
  588. "userLeftRoom",
  589. ):
  590. payload = data.get(candidate)
  591. if payload is None:
  592. continue
  593. kind = _snake_case(candidate)
  594. if candidate == "messagePosted":
  595. message = Message.parse(payload.get("message"))
  596. else:
  597. room_id = payload.get("roomId", "")
  598. break
  599. return cls(
  600. id=data.get("id", ""),
  601. created_at=parse_datetime(data.get("createdAt")),
  602. actor_id=data.get("actorId", ""),
  603. kind=kind,
  604. message=message,
  605. room_id=room_id,
  606. )
  607. def _snake_case(name: str) -> str:
  608. out: list[str] = []
  609. for ch in name:
  610. if ch.isupper() and out:
  611. out.append("_")
  612. out.append(ch.lower())
  613. return "".join(out)
  614. @dataclass
  615. class TimelinePage:
  616. events: list[TimelineEvent] = field(default_factory=list)
  617. start_cursor: str = ""
  618. end_cursor: str = ""
  619. has_older: bool = False
  620. has_newer: bool = False
  621. users_by_id: dict[str, User] = field(default_factory=dict)
  622. @classmethod
  623. def parse(cls, data: dict[str, Any] | None) -> TimelinePage:
  624. data = data or {}
  625. includes = data.get("includes") or {}
  626. users_raw = includes.get("users") or {}
  627. users: dict[str, User] = {}
  628. for uid, user_data in users_raw.items():
  629. parsed_user = User.parse(user_data)
  630. if parsed_user is not None:
  631. users[uid] = parsed_user
  632. return cls(
  633. events=[TimelineEvent.parse(e) for e in data.get("events") or []],
  634. start_cursor=data.get("startCursor", ""),
  635. end_cursor=data.get("endCursor", ""),
  636. has_older=bool(data.get("hasOlder", False)),
  637. has_newer=bool(data.get("hasNewer", False)),
  638. users_by_id=users,
  639. )
  640. @dataclass
  641. class FollowedThread:
  642. room: RoomSummary | None
  643. root_message: Message | None
  644. thread: ThreadSummary | None
  645. @classmethod
  646. def parse(cls, data: dict[str, Any]) -> FollowedThread:
  647. return cls(
  648. room=RoomSummary.parse(data.get("room")),
  649. root_message=Message.parse(data.get("rootMessage")),
  650. thread=ThreadSummary.parse(data.get("thread")),
  651. )
  652. @dataclass
  653. class FollowedThreadsPage:
  654. threads: list[FollowedThread] = field(default_factory=list)
  655. page: Page = field(default_factory=Page)
  656. users_by_id: dict[str, User] = field(default_factory=dict)
  657. @dataclass
  658. class Notification:
  659. id: str
  660. created_at: datetime | None
  661. actor: User | None
  662. kind: str # "direct_message" | "mention" | "reply" | "room_message"
  663. room: RoomSummary | None = None
  664. event_id: str = ""
  665. in_reply_to_id: str = ""
  666. thread_root_event_id: str | None = None
  667. @classmethod
  668. def parse(cls, data: dict[str, Any]) -> Notification:
  669. kind = ""
  670. room: RoomSummary | None = None
  671. event_id = ""
  672. in_reply_to_id = ""
  673. thread_root: str | None = None
  674. for candidate in ("directMessage", "mention", "reply", "roomMessage"):
  675. payload = data.get(candidate)
  676. if payload is None:
  677. continue
  678. kind = _snake_case(candidate)
  679. room = RoomSummary.parse(payload.get("room"))
  680. event_id = payload.get("eventId", "")
  681. in_reply_to_id = payload.get("inReplyToId", "") if candidate == "reply" else ""
  682. thread_root = payload.get("threadRootEventId")
  683. break
  684. return cls(
  685. id=data.get("id", ""),
  686. created_at=parse_datetime(data.get("createdAt")),
  687. actor=User.parse(data.get("actor")),
  688. kind=kind,
  689. room=room,
  690. event_id=event_id,
  691. in_reply_to_id=in_reply_to_id,
  692. thread_root_event_id=thread_root,
  693. )
  694. @dataclass
  695. class NotificationsPage:
  696. notifications: list[Notification] = field(default_factory=list)
  697. page: Page = field(default_factory=Page)
  698. @dataclass
  699. class NotificationPreference:
  700. level: NotificationLevel = NotificationLevel.UNSPECIFIED
  701. effective_level: NotificationLevel = NotificationLevel.UNSPECIFIED
  702. @classmethod
  703. def parse(cls, data: dict[str, Any] | None) -> NotificationPreference:
  704. data = data or {}
  705. return cls(
  706. level=_parse_enum(
  707. NotificationLevel, data.get("level"), NotificationLevel.UNSPECIFIED
  708. ), # type: ignore[arg-type]
  709. effective_level=_parse_enum(
  710. NotificationLevel,
  711. data.get("effectiveLevel"),
  712. NotificationLevel.UNSPECIFIED,
  713. ), # type: ignore[arg-type]
  714. )
  715. @dataclass
  716. class Asset:
  717. id: str
  718. filename: str
  719. content_type: str
  720. size: int
  721. width: int = 0
  722. height: int = 0
  723. asset_url: AssetUrl | None = None
  724. thumbnail_asset_url: AssetUrl | None = None
  725. video_processing: VideoProcessing | None = None
  726. @classmethod
  727. def parse(cls, data: dict[str, Any] | None) -> Asset | None:
  728. if not data:
  729. return None
  730. return cls(
  731. id=data.get("id", ""),
  732. filename=data.get("filename", ""),
  733. content_type=data.get("contentType", ""),
  734. size=int(data.get("size", 0)),
  735. width=int(data.get("width", 0)),
  736. height=int(data.get("height", 0)),
  737. asset_url=AssetUrl.parse(data.get("assetUrl")),
  738. thumbnail_asset_url=AssetUrl.parse(data.get("thumbnailAssetUrl")),
  739. video_processing=VideoProcessing.parse(data.get("videoProcessing")),
  740. )
  741. @dataclass
  742. class ImageTransformOptions:
  743. width: int
  744. height: int
  745. fit: ImageFitMode = ImageFitMode.UNSPECIFIED
  746. def to_wire(self) -> dict[str, Any]:
  747. return {
  748. "width": self.width,
  749. "height": self.height,
  750. "fit": self.fit.value,
  751. }
  752. @dataclass
  753. class ActiveCallParticipant:
  754. user: User | None = None
  755. joined_at: datetime | None = None
  756. call_id: str = ""
  757. @classmethod
  758. def parse(cls, data: dict[str, Any]) -> ActiveCallParticipant:
  759. return cls(
  760. user=User.parse(data.get("user")),
  761. joined_at=parse_datetime(data.get("joinedAt")),
  762. call_id=data.get("callId", ""),
  763. )
  764. @dataclass
  765. class ActiveCall:
  766. call_id: str
  767. room: RoomSummary | None
  768. participants: list[ActiveCallParticipant] = field(default_factory=list)
  769. @classmethod
  770. def parse(cls, data: dict[str, Any]) -> ActiveCall:
  771. return cls(
  772. call_id=data.get("callId", ""),
  773. room=RoomSummary.parse(data.get("room")),
  774. participants=[
  775. ActiveCallParticipant.parse(p) for p in data.get("participants") or []
  776. ],
  777. )
  778. # --- Roles ----------------------------------------------------------------
  779. @dataclass
  780. class Role:
  781. name: str
  782. display_name: str = ""
  783. description: str = ""
  784. is_system: bool = False
  785. position: int = 0
  786. pingable: bool = False
  787. @classmethod
  788. def parse(cls, data: dict[str, Any] | None) -> Role | None:
  789. if not data:
  790. return None
  791. return cls(
  792. name=data.get("name", ""),
  793. display_name=data.get("displayName", ""),
  794. description=data.get("description", ""),
  795. is_system=bool(data.get("isSystem", False)),
  796. position=int(data.get("position", 0)),
  797. pingable=bool(data.get("pingable", False)),
  798. )
  799. @dataclass
  800. class AdminRole:
  801. role: Role | None
  802. permissions: list[str] = field(default_factory=list)
  803. permission_denials: list[str] = field(default_factory=list)
  804. @classmethod
  805. def parse(cls, data: dict[str, Any] | None) -> AdminRole | None:
  806. if not data:
  807. return None
  808. return cls(
  809. role=Role.parse(data.get("role")),
  810. permissions=list(data.get("permissions") or []),
  811. permission_denials=list(data.get("permissionDenials") or []),
  812. )
  813. # --- Asset uploads --------------------------------------------------------
  814. @dataclass
  815. class AssetUpload:
  816. upload_id: str
  817. room_id: str
  818. status: AssetUploadStatus = AssetUploadStatus.UNSPECIFIED
  819. committed_offset: int = 0
  820. size: int = 0
  821. max_chunk_size: int = 0
  822. sha256: str = ""
  823. expires_at: datetime | None = None
  824. asset_id: str = ""
  825. @classmethod
  826. def parse(cls, data: dict[str, Any] | None) -> AssetUpload | None:
  827. if not data:
  828. return None
  829. return cls(
  830. upload_id=data.get("uploadId", ""),
  831. room_id=data.get("roomId", ""),
  832. status=_parse_enum(
  833. AssetUploadStatus,
  834. data.get("status"),
  835. AssetUploadStatus.UNSPECIFIED,
  836. ), # type: ignore[arg-type]
  837. committed_offset=int(data.get("committedOffset", 0)),
  838. size=int(data.get("size", 0)),
  839. max_chunk_size=int(data.get("maxChunkSize", 0)),
  840. sha256=data.get("sha256", ""),
  841. expires_at=parse_datetime(data.get("expiresAt")),
  842. asset_id=data.get("assetId", ""),
  843. )
  844. # --- External identity ----------------------------------------------------
  845. @dataclass
  846. class ExternalIdentityProvider:
  847. provider: ProviderMetadata | None
  848. link_url: str = ""
  849. linked: bool = False
  850. linked_identity_subject_hash: str = ""
  851. @classmethod
  852. def parse(cls, data: dict[str, Any]) -> ExternalIdentityProvider:
  853. provider_data = data.get("provider")
  854. provider = ProviderMetadata.parse(provider_data) if provider_data else None
  855. return cls(
  856. provider=provider,
  857. link_url=data.get("linkUrl", ""),
  858. linked=bool(data.get("linked", False)),
  859. linked_identity_subject_hash=data.get("linkedIdentitySubjectHash", ""),
  860. )
  861. @dataclass
  862. class LinkedExternalIdentity:
  863. provider_id: str
  864. provider_type: str = ""
  865. provider_label: str = ""
  866. subject_hash: str = ""
  867. @classmethod
  868. def parse(cls, data: dict[str, Any]) -> LinkedExternalIdentity:
  869. return cls(
  870. provider_id=data.get("providerId", ""),
  871. provider_type=data.get("providerType", ""),
  872. provider_label=data.get("providerLabel", ""),
  873. subject_hash=data.get("subjectHash", ""),
  874. )
  875. # --- Admin: server / members ---------------------------------------------
  876. @dataclass
  877. class ServerConfig:
  878. server_name: str = ""
  879. description: str = ""
  880. motd: str = ""
  881. welcome_message: str = ""
  882. @classmethod
  883. def parse(cls, data: dict[str, Any] | None) -> ServerConfig:
  884. data = data or {}
  885. return cls(
  886. server_name=data.get("serverName", ""),
  887. description=data.get("description", ""),
  888. motd=data.get("motd", ""),
  889. welcome_message=data.get("welcomeMessage", ""),
  890. )
  891. @dataclass
  892. class AdminMember:
  893. user: User | None
  894. roles: list[str] = field(default_factory=list)
  895. created_at: datetime | None = None
  896. has_verified_email: bool = False
  897. verified_emails: list[str] = field(default_factory=list)
  898. viewer_can_delete_account: bool = False
  899. last_login_change: datetime | None = None
  900. @classmethod
  901. def parse(cls, data: dict[str, Any] | None) -> AdminMember | None:
  902. if not data:
  903. return None
  904. return cls(
  905. user=User.parse(data.get("user")),
  906. roles=list(data.get("roles") or []),
  907. created_at=parse_datetime(data.get("createdAt")),
  908. has_verified_email=bool(data.get("hasVerifiedEmail", False)),
  909. verified_emails=list(data.get("verifiedEmails") or []),
  910. viewer_can_delete_account=bool(data.get("viewerCanDeleteAccount", False)),
  911. last_login_change=parse_datetime(data.get("lastLoginChange")),
  912. )
  913. # --- Admin: room layout --------------------------------------------------
  914. @dataclass
  915. class AdminRoomLayoutGroup:
  916. id: str
  917. name: str
  918. description: str = ""
  919. rooms: list[Room] = field(default_factory=list)
  920. sidebar_links: list[SidebarLink] = field(default_factory=list)
  921. can_create_room: bool = False
  922. @classmethod
  923. def parse(cls, data: dict[str, Any] | None) -> AdminRoomLayoutGroup | None:
  924. if not data:
  925. return None
  926. rooms: list[Room] = []
  927. links: list[SidebarLink] = []
  928. for item in data.get("items") or []:
  929. room = Room.parse(item.get("room"))
  930. if room is not None:
  931. rooms.append(room)
  932. sl = item.get("sidebarLink")
  933. if sl:
  934. links.append(
  935. SidebarLink(
  936. id=sl.get("id", ""),
  937. label=sl.get("label", ""),
  938. url=sl.get("url", ""),
  939. )
  940. )
  941. return cls(
  942. id=data.get("id", ""),
  943. name=data.get("name", ""),
  944. description=data.get("description", ""),
  945. rooms=rooms,
  946. sidebar_links=links,
  947. can_create_room=bool(data.get("canCreateRoom", False)),
  948. )