# mypy: disable-error-code="no-any-return" """Shared plumbing for the ConnectRPC-based transport. Chatto's public API is a ConnectRPC service surface. ``chattolib.client`` speaks to it through generated service stubs (see ``chattolib._pb``) driven by the official ``connectrpc`` Python package. This module exposes: * :func:`build_service_clients` — one call, one ``base_url`` argument, returns a ``ServiceClients`` object with a typed field per Chatto service. * :func:`translate_connect_error` — translates a ``connectrpc.errors.ConnectError`` into the library's public exception hierarchy (:class:`chattolib.exceptions.ChattoAuthError` / :class:`chattolib.exceptions.ChattoConnectError`). * :func:`pb_to_dict` — turns a protobuf response into the camelCase JSON shape that the existing ``types.py`` dataclass parsers already accept. Keeping the parsers dict-driven means the migration from Connect-JSON to Connect-binary transport doesn't ripple through the entire public API. """ from __future__ import annotations from dataclasses import dataclass from typing import Any from connectrpc.code import Code from connectrpc.compat import google_protobuf_binary_codec from connectrpc.errors import ConnectError from google.protobuf.json_format import MessageToDict from google.protobuf.message import Message # Sync client twins (generated alongside the async ones in every *_connect.py). from chattolib._pb.chatto.admin.v1.diagnostics_connect import ( AdminDiagnosticsServiceClient, AdminDiagnosticsServiceClientSync, ) from chattolib._pb.chatto.admin.v1.event_log_connect import ( AdminEventLogServiceClient, AdminEventLogServiceClientSync, ) from chattolib._pb.chatto.admin.v1.members_connect import ( AdminUserServiceClient, AdminUserServiceClientSync, ) from chattolib._pb.chatto.admin.v1.permissions_connect import ( AdminPermissionServiceClient, AdminPermissionServiceClientSync, ) from chattolib._pb.chatto.admin.v1.roles_connect import ( AdminRoleServiceClient, AdminRoleServiceClientSync, ) from chattolib._pb.chatto.admin.v1.room_layout_connect import ( AdminRoomLayoutServiceClient, AdminRoomLayoutServiceClientSync, ) from chattolib._pb.chatto.admin.v1.server_connect import ( AdminServerServiceClient, AdminServerServiceClientSync, ) from chattolib._pb.chatto.api.v1.account_connect import ( MyAccountServiceClient, MyAccountServiceClientSync, ) from chattolib._pb.chatto.api.v1.asset_uploads_connect import ( AssetUploadServiceClient, AssetUploadServiceClientSync, ) from chattolib._pb.chatto.api.v1.attachments_connect import ( AssetServiceClient, AssetServiceClientSync, ) from chattolib._pb.chatto.api.v1.member_directory_connect import ( UserServiceClient, UserServiceClientSync, ) from chattolib._pb.chatto.api.v1.messages_connect import ( MessageServiceClient, MessageServiceClientSync, ) from chattolib._pb.chatto.api.v1.notification_preferences_connect import ( NotificationPreferencesServiceClient, NotificationPreferencesServiceClientSync, ) from chattolib._pb.chatto.api.v1.notifications_connect import ( NotificationServiceClient, NotificationServiceClientSync, ) from chattolib._pb.chatto.api.v1.push_notifications_connect import ( PushNotificationServiceClient, PushNotificationServiceClientSync, ) from chattolib._pb.chatto.api.v1.roles_connect import RoleServiceClient, RoleServiceClientSync from chattolib._pb.chatto.api.v1.room_directory_connect import ( RoomDirectoryServiceClient, RoomDirectoryServiceClientSync, ) from chattolib._pb.chatto.api.v1.rooms_connect import RoomServiceClient, RoomServiceClientSync from chattolib._pb.chatto.api.v1.server_state_connect import ( ServerServiceClient, ServerServiceClientSync, ) from chattolib._pb.chatto.api.v1.threads_connect import ThreadServiceClient, ThreadServiceClientSync from chattolib._pb.chatto.api.v1.viewer_connect import ViewerServiceClient, ViewerServiceClientSync from chattolib._pb.chatto.api.v1.voice_calls_connect import ( VoiceCallServiceClient, VoiceCallServiceClientSync, ) from chattolib._pb.chatto.auth.v1.external_identity_auth_connect import ( ExternalIdentityAuthServiceClient, ExternalIdentityAuthServiceClientSync, ) from chattolib._pb.chatto.discovery.v1.server_connect import ( ServerDiscoveryServiceClient, ServerDiscoveryServiceClientSync, ) from chattolib.exceptions import ChattoAuthError, ChattoConnectError CONNECT_PREFIX = "/api/connect" @dataclass class ServiceClients: """Typed bundle of ConnectRPC service clients used by ``ChattoClient``.""" server_discovery: ServerDiscoveryServiceClient server: ServerServiceClient viewer: ViewerServiceClient account: MyAccountServiceClient users: UserServiceClient roles: RoleServiceClient room_directory: RoomDirectoryServiceClient rooms: RoomServiceClient messages: MessageServiceClient threads: ThreadServiceClient notifications: NotificationServiceClient notification_prefs: NotificationPreferencesServiceClient push: PushNotificationServiceClient assets: AssetServiceClient asset_uploads: AssetUploadServiceClient voice_calls: VoiceCallServiceClient external_auth: ExternalIdentityAuthServiceClient admin_server: AdminServerServiceClient admin_room_layout: AdminRoomLayoutServiceClient admin_users: AdminUserServiceClient admin_roles: AdminRoleServiceClient admin_event_log: AdminEventLogServiceClient admin_diagnostics: AdminDiagnosticsServiceClient admin_permissions: AdminPermissionServiceClient async def close(self) -> None: for name in self.__dataclass_fields__: client = getattr(self, name) await client.close() def build_service_clients(base_url: str) -> ServiceClients: """Instantiate one service client per Chatto Connect service. ``base_url`` is the server root (e.g. ``https://chat.chatto.run``); the ConnectRPC prefix is appended by this function. """ address = f"{base_url.rstrip('/')}{CONNECT_PREFIX}" codec = google_protobuf_binary_codec() def make(cls: Any) -> Any: return cls(address, codec=codec) return ServiceClients( server_discovery=make(ServerDiscoveryServiceClient), server=make(ServerServiceClient), viewer=make(ViewerServiceClient), account=make(MyAccountServiceClient), users=make(UserServiceClient), roles=make(RoleServiceClient), room_directory=make(RoomDirectoryServiceClient), rooms=make(RoomServiceClient), messages=make(MessageServiceClient), threads=make(ThreadServiceClient), notifications=make(NotificationServiceClient), notification_prefs=make(NotificationPreferencesServiceClient), push=make(PushNotificationServiceClient), assets=make(AssetServiceClient), asset_uploads=make(AssetUploadServiceClient), voice_calls=make(VoiceCallServiceClient), external_auth=make(ExternalIdentityAuthServiceClient), admin_server=make(AdminServerServiceClient), admin_room_layout=make(AdminRoomLayoutServiceClient), admin_users=make(AdminUserServiceClient), admin_roles=make(AdminRoleServiceClient), admin_event_log=make(AdminEventLogServiceClient), admin_diagnostics=make(AdminDiagnosticsServiceClient), admin_permissions=make(AdminPermissionServiceClient), ) def translate_connect_error(exc: ConnectError) -> Exception: """Convert a ``connectrpc`` error into chattolib's exception hierarchy.""" if exc.code == Code.UNAUTHENTICATED: return ChattoAuthError(str(exc)) return ChattoConnectError( code=exc.code.name.lower(), message=str(exc), ) # --- Synchronous transport ------------------------------------------------- # # The async client translates ConnectError via ``ChattoClient._rpc``, which # awaits the coroutine inside a try/except. The sync stubs raise at call time # (before the generated ``_rpc`` passthrough runs), so error translation is # done here at the transport via a ConnectRPC unary interceptor instead. class _ErrorTranslatingInterceptorSync: """Structural ``connectrpc.UnaryInterceptorSync`` that maps ConnectError.""" def intercept_unary_sync(self, call_next: Any, request: Any, ctx: Any) -> Any: try: return call_next(request, ctx) except ConnectError as exc: raise translate_connect_error(exc) from exc @dataclass class ServiceClientsSync: """Synchronous twin of :class:`ServiceClients`.""" server_discovery: ServerDiscoveryServiceClientSync server: ServerServiceClientSync viewer: ViewerServiceClientSync account: MyAccountServiceClientSync users: UserServiceClientSync roles: RoleServiceClientSync room_directory: RoomDirectoryServiceClientSync rooms: RoomServiceClientSync messages: MessageServiceClientSync threads: ThreadServiceClientSync notifications: NotificationServiceClientSync notification_prefs: NotificationPreferencesServiceClientSync push: PushNotificationServiceClientSync assets: AssetServiceClientSync asset_uploads: AssetUploadServiceClientSync voice_calls: VoiceCallServiceClientSync external_auth: ExternalIdentityAuthServiceClientSync admin_server: AdminServerServiceClientSync admin_room_layout: AdminRoomLayoutServiceClientSync admin_users: AdminUserServiceClientSync admin_roles: AdminRoleServiceClientSync admin_event_log: AdminEventLogServiceClientSync admin_diagnostics: AdminDiagnosticsServiceClientSync admin_permissions: AdminPermissionServiceClientSync def close(self) -> None: for name in self.__dataclass_fields__: getattr(self, name).close() def build_service_clients_sync(base_url: str) -> ServiceClientsSync: """Synchronous twin of :func:`build_service_clients`. Each client carries an error-translating interceptor so ConnectError surfaces as ``ChattoConnectError`` / ``ChattoAuthError`` exactly as the async path does. """ address = f"{base_url.rstrip('/')}{CONNECT_PREFIX}" codec = google_protobuf_binary_codec() interceptor = _ErrorTranslatingInterceptorSync() def make(cls: Any) -> Any: return cls(address, codec=codec, interceptors=(interceptor,)) return ServiceClientsSync( server_discovery=make(ServerDiscoveryServiceClientSync), server=make(ServerServiceClientSync), viewer=make(ViewerServiceClientSync), account=make(MyAccountServiceClientSync), users=make(UserServiceClientSync), roles=make(RoleServiceClientSync), room_directory=make(RoomDirectoryServiceClientSync), rooms=make(RoomServiceClientSync), messages=make(MessageServiceClientSync), threads=make(ThreadServiceClientSync), notifications=make(NotificationServiceClientSync), notification_prefs=make(NotificationPreferencesServiceClientSync), push=make(PushNotificationServiceClientSync), assets=make(AssetServiceClientSync), asset_uploads=make(AssetUploadServiceClientSync), voice_calls=make(VoiceCallServiceClientSync), external_auth=make(ExternalIdentityAuthServiceClientSync), admin_server=make(AdminServerServiceClientSync), admin_room_layout=make(AdminRoomLayoutServiceClientSync), admin_users=make(AdminUserServiceClientSync), admin_roles=make(AdminRoleServiceClientSync), admin_event_log=make(AdminEventLogServiceClientSync), admin_diagnostics=make(AdminDiagnosticsServiceClientSync), admin_permissions=make(AdminPermissionServiceClientSync), ) def pb_to_dict(message: Message | None) -> dict[str, Any]: """Convert a protobuf message to the camelCase dict shape the parsers accept. ``preserving_proto_field_name=False`` gives us JSON-mapping camelCase keys (e.g. ``created_at`` → ``createdAt``), matching what the ``types.py`` dataclass parsers already consume. """ if message is None: return {} return MessageToDict( message, preserving_proto_field_name=False, use_integers_for_enums=False, )