Explorar el Código

fix: resolve cross-file RPC types in generated Connect stubs

The protoc plugin emitted every RPC's request/response against the current
proto's _pb2 module. That is wrong when a service's RPC references a message
defined in a *different* proto file — e.g. MyAccountService.UpdatePresence
(account.proto) uses UpdatePresenceRequest from presence.proto. Those 36
RPCs generated stubs that raised AttributeError at runtime (the message class
lived in the wrong module), silently breaking a large slice of the API in
0.5.0a5.

The plugin now builds a fully-qualified-message -> defining-proto map from the
CodeGeneratorRequest and, per generated file, imports each referenced proto's
_pb2 module under a stable alias (_pb2 for the current proto, _pb2_<stem> for
others), pointing each method's input/output at the module that actually
defines it.

Regenerated the 4 affected stubs (account, messages, rooms, threads); the
other 20 are byte-identical. All 652 input/output references across the stubs
now resolve. Added a regression test for the cross-file case.

Bumped version 0.5.0a5 -> 0.5.0a5.post1.
Federico Feliziani hace 2 meses
padre
commit
adf83ba4f6

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "chattolib"
-version = "0.5.0a5"
+version = "0.5.0a5.post1"
 description = "Async Python client library for the Chatto webchat Connect API"
 requires-python = ">=3.11"
 license = "MPL-2.0 AND Apache-2.0"

+ 39 - 36
src/chattolib/_pb/chatto/api/v1/account_connect.py

@@ -3,6 +3,9 @@
 # source: chatto/api/v1/account.proto
 
 import chatto.api.v1.account_pb2 as _pb2
+import chatto.api.v1.external_identities_pb2 as _pb2_external_identities
+import chatto.api.v1.presence_pb2 as _pb2_presence
+import chatto.api.v1.user_status_pb2 as _pb2_user_status
 from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
 
 class MyAccountServiceClient(ConnectClient):
@@ -66,74 +69,74 @@ class MyAccountServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def list_external_identities(self, request: _pb2.ListExternalIdentitiesRequest, *, headers: dict[str, str] | None = None) -> _pb2.ListExternalIdentitiesResponse:
+    async def list_external_identities(self, request: _pb2_external_identities.ListExternalIdentitiesRequest, *, headers: dict[str, str] | None = None) -> _pb2_external_identities.ListExternalIdentitiesResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="ListExternalIdentities",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.ListExternalIdentitiesRequest,
-                output=_pb2.ListExternalIdentitiesResponse,
+                input=_pb2_external_identities.ListExternalIdentitiesRequest,
+                output=_pb2_external_identities.ListExternalIdentitiesResponse,
             ),
             headers=headers,
         )
 
-    async def start_external_identity_link(self, request: _pb2.StartExternalIdentityLinkRequest, *, headers: dict[str, str] | None = None) -> _pb2.StartExternalIdentityLinkResponse:
+    async def start_external_identity_link(self, request: _pb2_external_identities.StartExternalIdentityLinkRequest, *, headers: dict[str, str] | None = None) -> _pb2_external_identities.StartExternalIdentityLinkResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="StartExternalIdentityLink",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.StartExternalIdentityLinkRequest,
-                output=_pb2.StartExternalIdentityLinkResponse,
+                input=_pb2_external_identities.StartExternalIdentityLinkRequest,
+                output=_pb2_external_identities.StartExternalIdentityLinkResponse,
             ),
             headers=headers,
         )
 
-    async def disconnect_external_identity(self, request: _pb2.DisconnectExternalIdentityRequest, *, headers: dict[str, str] | None = None) -> _pb2.DisconnectExternalIdentityResponse:
+    async def disconnect_external_identity(self, request: _pb2_external_identities.DisconnectExternalIdentityRequest, *, headers: dict[str, str] | None = None) -> _pb2_external_identities.DisconnectExternalIdentityResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="DisconnectExternalIdentity",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.DisconnectExternalIdentityRequest,
-                output=_pb2.DisconnectExternalIdentityResponse,
+                input=_pb2_external_identities.DisconnectExternalIdentityRequest,
+                output=_pb2_external_identities.DisconnectExternalIdentityResponse,
             ),
             headers=headers,
         )
 
-    async def update_presence(self, request: _pb2.UpdatePresenceRequest, *, headers: dict[str, str] | None = None) -> _pb2.UpdatePresenceResponse:
+    async def update_presence(self, request: _pb2_presence.UpdatePresenceRequest, *, headers: dict[str, str] | None = None) -> _pb2_presence.UpdatePresenceResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="UpdatePresence",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.UpdatePresenceRequest,
-                output=_pb2.UpdatePresenceResponse,
+                input=_pb2_presence.UpdatePresenceRequest,
+                output=_pb2_presence.UpdatePresenceResponse,
             ),
             headers=headers,
         )
 
-    async def update_custom_status(self, request: _pb2.UpdateCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2.UpdateCustomStatusResponse:
+    async def update_custom_status(self, request: _pb2_user_status.UpdateCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2_user_status.UpdateCustomStatusResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="UpdateCustomStatus",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.UpdateCustomStatusRequest,
-                output=_pb2.UpdateCustomStatusResponse,
+                input=_pb2_user_status.UpdateCustomStatusRequest,
+                output=_pb2_user_status.UpdateCustomStatusResponse,
             ),
             headers=headers,
         )
 
-    async def delete_custom_status(self, request: _pb2.DeleteCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2.DeleteCustomStatusResponse:
+    async def delete_custom_status(self, request: _pb2_user_status.DeleteCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2_user_status.DeleteCustomStatusResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="DeleteCustomStatus",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.DeleteCustomStatusRequest,
-                output=_pb2.DeleteCustomStatusResponse,
+                input=_pb2_user_status.DeleteCustomStatusRequest,
+                output=_pb2_user_status.DeleteCustomStatusResponse,
             ),
             headers=headers,
         )
@@ -224,74 +227,74 @@ class MyAccountServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def list_external_identities(self, request: _pb2.ListExternalIdentitiesRequest, *, headers: dict[str, str] | None = None) -> _pb2.ListExternalIdentitiesResponse:
+    def list_external_identities(self, request: _pb2_external_identities.ListExternalIdentitiesRequest, *, headers: dict[str, str] | None = None) -> _pb2_external_identities.ListExternalIdentitiesResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="ListExternalIdentities",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.ListExternalIdentitiesRequest,
-                output=_pb2.ListExternalIdentitiesResponse,
+                input=_pb2_external_identities.ListExternalIdentitiesRequest,
+                output=_pb2_external_identities.ListExternalIdentitiesResponse,
             ),
             headers=headers,
         )
 
-    def start_external_identity_link(self, request: _pb2.StartExternalIdentityLinkRequest, *, headers: dict[str, str] | None = None) -> _pb2.StartExternalIdentityLinkResponse:
+    def start_external_identity_link(self, request: _pb2_external_identities.StartExternalIdentityLinkRequest, *, headers: dict[str, str] | None = None) -> _pb2_external_identities.StartExternalIdentityLinkResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="StartExternalIdentityLink",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.StartExternalIdentityLinkRequest,
-                output=_pb2.StartExternalIdentityLinkResponse,
+                input=_pb2_external_identities.StartExternalIdentityLinkRequest,
+                output=_pb2_external_identities.StartExternalIdentityLinkResponse,
             ),
             headers=headers,
         )
 
-    def disconnect_external_identity(self, request: _pb2.DisconnectExternalIdentityRequest, *, headers: dict[str, str] | None = None) -> _pb2.DisconnectExternalIdentityResponse:
+    def disconnect_external_identity(self, request: _pb2_external_identities.DisconnectExternalIdentityRequest, *, headers: dict[str, str] | None = None) -> _pb2_external_identities.DisconnectExternalIdentityResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="DisconnectExternalIdentity",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.DisconnectExternalIdentityRequest,
-                output=_pb2.DisconnectExternalIdentityResponse,
+                input=_pb2_external_identities.DisconnectExternalIdentityRequest,
+                output=_pb2_external_identities.DisconnectExternalIdentityResponse,
             ),
             headers=headers,
         )
 
-    def update_presence(self, request: _pb2.UpdatePresenceRequest, *, headers: dict[str, str] | None = None) -> _pb2.UpdatePresenceResponse:
+    def update_presence(self, request: _pb2_presence.UpdatePresenceRequest, *, headers: dict[str, str] | None = None) -> _pb2_presence.UpdatePresenceResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="UpdatePresence",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.UpdatePresenceRequest,
-                output=_pb2.UpdatePresenceResponse,
+                input=_pb2_presence.UpdatePresenceRequest,
+                output=_pb2_presence.UpdatePresenceResponse,
             ),
             headers=headers,
         )
 
-    def update_custom_status(self, request: _pb2.UpdateCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2.UpdateCustomStatusResponse:
+    def update_custom_status(self, request: _pb2_user_status.UpdateCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2_user_status.UpdateCustomStatusResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="UpdateCustomStatus",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.UpdateCustomStatusRequest,
-                output=_pb2.UpdateCustomStatusResponse,
+                input=_pb2_user_status.UpdateCustomStatusRequest,
+                output=_pb2_user_status.UpdateCustomStatusResponse,
             ),
             headers=headers,
         )
 
-    def delete_custom_status(self, request: _pb2.DeleteCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2.DeleteCustomStatusResponse:
+    def delete_custom_status(self, request: _pb2_user_status.DeleteCustomStatusRequest, *, headers: dict[str, str] | None = None) -> _pb2_user_status.DeleteCustomStatusResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="DeleteCustomStatus",
                 service_name="chatto.api.v1.MyAccountService",
-                input=_pb2.DeleteCustomStatusRequest,
-                output=_pb2.DeleteCustomStatusResponse,
+                input=_pb2_user_status.DeleteCustomStatusRequest,
+                output=_pb2_user_status.DeleteCustomStatusResponse,
             ),
             headers=headers,
         )

+ 20 - 18
src/chattolib/_pb/chatto/api/v1/messages_connect.py

@@ -3,17 +3,19 @@
 # source: chatto/api/v1/messages.proto
 
 import chatto.api.v1.messages_pb2 as _pb2
+import chatto.api.v1.link_previews_pb2 as _pb2_link_previews
+import chatto.api.v1.reactions_pb2 as _pb2_reactions
 from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
 
 class MessageServiceClient(ConnectClient):
-    async def fetch_link_preview(self, request: _pb2.FetchLinkPreviewRequest, *, headers: dict[str, str] | None = None) -> _pb2.FetchLinkPreviewResponse:
+    async def fetch_link_preview(self, request: _pb2_link_previews.FetchLinkPreviewRequest, *, headers: dict[str, str] | None = None) -> _pb2_link_previews.FetchLinkPreviewResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="FetchLinkPreview",
                 service_name="chatto.api.v1.MessageService",
-                input=_pb2.FetchLinkPreviewRequest,
-                output=_pb2.FetchLinkPreviewResponse,
+                input=_pb2_link_previews.FetchLinkPreviewRequest,
+                output=_pb2_link_previews.FetchLinkPreviewResponse,
             ),
             headers=headers,
         )
@@ -102,40 +104,40 @@ class MessageServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def add_reaction(self, request: _pb2.AddReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2.AddReactionResponse:
+    async def add_reaction(self, request: _pb2_reactions.AddReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2_reactions.AddReactionResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="AddReaction",
                 service_name="chatto.api.v1.MessageService",
-                input=_pb2.AddReactionRequest,
-                output=_pb2.AddReactionResponse,
+                input=_pb2_reactions.AddReactionRequest,
+                output=_pb2_reactions.AddReactionResponse,
             ),
             headers=headers,
         )
 
-    async def remove_reaction(self, request: _pb2.RemoveReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2.RemoveReactionResponse:
+    async def remove_reaction(self, request: _pb2_reactions.RemoveReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2_reactions.RemoveReactionResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="RemoveReaction",
                 service_name="chatto.api.v1.MessageService",
-                input=_pb2.RemoveReactionRequest,
-                output=_pb2.RemoveReactionResponse,
+                input=_pb2_reactions.RemoveReactionRequest,
+                output=_pb2_reactions.RemoveReactionResponse,
             ),
             headers=headers,
         )
 
 class MessageServiceClientSync(ConnectClientSync):
 
-    def fetch_link_preview(self, request: _pb2.FetchLinkPreviewRequest, *, headers: dict[str, str] | None = None) -> _pb2.FetchLinkPreviewResponse:
+    def fetch_link_preview(self, request: _pb2_link_previews.FetchLinkPreviewRequest, *, headers: dict[str, str] | None = None) -> _pb2_link_previews.FetchLinkPreviewResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="FetchLinkPreview",
                 service_name="chatto.api.v1.MessageService",
-                input=_pb2.FetchLinkPreviewRequest,
-                output=_pb2.FetchLinkPreviewResponse,
+                input=_pb2_link_previews.FetchLinkPreviewRequest,
+                output=_pb2_link_previews.FetchLinkPreviewResponse,
             ),
             headers=headers,
         )
@@ -224,26 +226,26 @@ class MessageServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def add_reaction(self, request: _pb2.AddReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2.AddReactionResponse:
+    def add_reaction(self, request: _pb2_reactions.AddReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2_reactions.AddReactionResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="AddReaction",
                 service_name="chatto.api.v1.MessageService",
-                input=_pb2.AddReactionRequest,
-                output=_pb2.AddReactionResponse,
+                input=_pb2_reactions.AddReactionRequest,
+                output=_pb2_reactions.AddReactionResponse,
             ),
             headers=headers,
         )
 
-    def remove_reaction(self, request: _pb2.RemoveReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2.RemoveReactionResponse:
+    def remove_reaction(self, request: _pb2_reactions.RemoveReactionRequest, *, headers: dict[str, str] | None = None) -> _pb2_reactions.RemoveReactionResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="RemoveReaction",
                 service_name="chatto.api.v1.MessageService",
-                input=_pb2.RemoveReactionRequest,
-                output=_pb2.RemoveReactionResponse,
+                input=_pb2_reactions.RemoveReactionRequest,
+                output=_pb2_reactions.RemoveReactionResponse,
             ),
             headers=headers,
         )

+ 39 - 36
src/chattolib/_pb/chatto/api/v1/rooms_connect.py

@@ -3,6 +3,9 @@
 # source: chatto/api/v1/rooms.proto
 
 import chatto.api.v1.rooms_pb2 as _pb2
+import chatto.api.v1.member_directory_pb2 as _pb2_member_directory
+import chatto.api.v1.room_timeline_pb2 as _pb2_room_timeline
+import chatto.api.v1.read_state_pb2 as _pb2_read_state
 from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
 
 class RoomServiceClient(ConnectClient):
@@ -102,38 +105,38 @@ class RoomServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def list_members(self, request: _pb2.ListRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2.ListRoomMembersResponse:
+    async def list_members(self, request: _pb2_member_directory.ListRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2_member_directory.ListRoomMembersResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="ListMembers",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.ListRoomMembersRequest,
-                output=_pb2.ListRoomMembersResponse,
+                input=_pb2_member_directory.ListRoomMembersRequest,
+                output=_pb2_member_directory.ListRoomMembersResponse,
             ),
             headers=headers,
         )
 
-    async def get_member(self, request: _pb2.GetRoomMemberRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetRoomMemberResponse:
+    async def get_member(self, request: _pb2_member_directory.GetRoomMemberRequest, *, headers: dict[str, str] | None = None) -> _pb2_member_directory.GetRoomMemberResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetMember",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.GetRoomMemberRequest,
-                output=_pb2.GetRoomMemberResponse,
+                input=_pb2_member_directory.GetRoomMemberRequest,
+                output=_pb2_member_directory.GetRoomMemberResponse,
             ),
             headers=headers,
         )
 
-    async def batch_get_members(self, request: _pb2.BatchGetRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2.BatchGetRoomMembersResponse:
+    async def batch_get_members(self, request: _pb2_member_directory.BatchGetRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2_member_directory.BatchGetRoomMembersResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="BatchGetMembers",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.BatchGetRoomMembersRequest,
-                output=_pb2.BatchGetRoomMembersResponse,
+                input=_pb2_member_directory.BatchGetRoomMembersRequest,
+                output=_pb2_member_directory.BatchGetRoomMembersResponse,
             ),
             headers=headers,
         )
@@ -234,38 +237,38 @@ class RoomServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def get_room_events(self, request: _pb2.GetRoomEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetRoomEventsResponse:
+    async def get_room_events(self, request: _pb2_room_timeline.GetRoomEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetRoomEventsResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetRoomEvents",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.GetRoomEventsRequest,
-                output=_pb2.GetRoomEventsResponse,
+                input=_pb2_room_timeline.GetRoomEventsRequest,
+                output=_pb2_room_timeline.GetRoomEventsResponse,
             ),
             headers=headers,
         )
 
-    async def get_room_events_around(self, request: _pb2.GetRoomEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetRoomEventsAroundResponse:
+    async def get_room_events_around(self, request: _pb2_room_timeline.GetRoomEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetRoomEventsAroundResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetRoomEventsAround",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.GetRoomEventsAroundRequest,
-                output=_pb2.GetRoomEventsAroundResponse,
+                input=_pb2_room_timeline.GetRoomEventsAroundRequest,
+                output=_pb2_room_timeline.GetRoomEventsAroundResponse,
             ),
             headers=headers,
         )
 
-    async def mark_room_as_read(self, request: _pb2.MarkRoomAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2.MarkRoomAsReadResponse:
+    async def mark_room_as_read(self, request: _pb2_read_state.MarkRoomAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2_read_state.MarkRoomAsReadResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="MarkRoomAsRead",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.MarkRoomAsReadRequest,
-                output=_pb2.MarkRoomAsReadResponse,
+                input=_pb2_read_state.MarkRoomAsReadRequest,
+                output=_pb2_read_state.MarkRoomAsReadResponse,
             ),
             headers=headers,
         )
@@ -392,38 +395,38 @@ class RoomServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def list_members(self, request: _pb2.ListRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2.ListRoomMembersResponse:
+    def list_members(self, request: _pb2_member_directory.ListRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2_member_directory.ListRoomMembersResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="ListMembers",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.ListRoomMembersRequest,
-                output=_pb2.ListRoomMembersResponse,
+                input=_pb2_member_directory.ListRoomMembersRequest,
+                output=_pb2_member_directory.ListRoomMembersResponse,
             ),
             headers=headers,
         )
 
-    def get_member(self, request: _pb2.GetRoomMemberRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetRoomMemberResponse:
+    def get_member(self, request: _pb2_member_directory.GetRoomMemberRequest, *, headers: dict[str, str] | None = None) -> _pb2_member_directory.GetRoomMemberResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetMember",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.GetRoomMemberRequest,
-                output=_pb2.GetRoomMemberResponse,
+                input=_pb2_member_directory.GetRoomMemberRequest,
+                output=_pb2_member_directory.GetRoomMemberResponse,
             ),
             headers=headers,
         )
 
-    def batch_get_members(self, request: _pb2.BatchGetRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2.BatchGetRoomMembersResponse:
+    def batch_get_members(self, request: _pb2_member_directory.BatchGetRoomMembersRequest, *, headers: dict[str, str] | None = None) -> _pb2_member_directory.BatchGetRoomMembersResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="BatchGetMembers",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.BatchGetRoomMembersRequest,
-                output=_pb2.BatchGetRoomMembersResponse,
+                input=_pb2_member_directory.BatchGetRoomMembersRequest,
+                output=_pb2_member_directory.BatchGetRoomMembersResponse,
             ),
             headers=headers,
         )
@@ -524,38 +527,38 @@ class RoomServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def get_room_events(self, request: _pb2.GetRoomEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetRoomEventsResponse:
+    def get_room_events(self, request: _pb2_room_timeline.GetRoomEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetRoomEventsResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetRoomEvents",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.GetRoomEventsRequest,
-                output=_pb2.GetRoomEventsResponse,
+                input=_pb2_room_timeline.GetRoomEventsRequest,
+                output=_pb2_room_timeline.GetRoomEventsResponse,
             ),
             headers=headers,
         )
 
-    def get_room_events_around(self, request: _pb2.GetRoomEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetRoomEventsAroundResponse:
+    def get_room_events_around(self, request: _pb2_room_timeline.GetRoomEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetRoomEventsAroundResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetRoomEventsAround",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.GetRoomEventsAroundRequest,
-                output=_pb2.GetRoomEventsAroundResponse,
+                input=_pb2_room_timeline.GetRoomEventsAroundRequest,
+                output=_pb2_room_timeline.GetRoomEventsAroundResponse,
             ),
             headers=headers,
         )
 
-    def mark_room_as_read(self, request: _pb2.MarkRoomAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2.MarkRoomAsReadResponse:
+    def mark_room_as_read(self, request: _pb2_read_state.MarkRoomAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2_read_state.MarkRoomAsReadResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="MarkRoomAsRead",
                 service_name="chatto.api.v1.RoomService",
-                input=_pb2.MarkRoomAsReadRequest,
-                output=_pb2.MarkRoomAsReadResponse,
+                input=_pb2_read_state.MarkRoomAsReadRequest,
+                output=_pb2_read_state.MarkRoomAsReadResponse,
             ),
             headers=headers,
         )

+ 20 - 18
src/chattolib/_pb/chatto/api/v1/threads_connect.py

@@ -3,6 +3,8 @@
 # source: chatto/api/v1/threads.proto
 
 import chatto.api.v1.threads_pb2 as _pb2
+import chatto.api.v1.room_timeline_pb2 as _pb2_room_timeline
+import chatto.api.v1.read_state_pb2 as _pb2_read_state
 from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
 
 class ThreadServiceClient(ConnectClient):
@@ -42,38 +44,38 @@ class ThreadServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def get_thread_events(self, request: _pb2.GetThreadEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetThreadEventsResponse:
+    async def get_thread_events(self, request: _pb2_room_timeline.GetThreadEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetThreadEventsResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetThreadEvents",
                 service_name="chatto.api.v1.ThreadService",
-                input=_pb2.GetThreadEventsRequest,
-                output=_pb2.GetThreadEventsResponse,
+                input=_pb2_room_timeline.GetThreadEventsRequest,
+                output=_pb2_room_timeline.GetThreadEventsResponse,
             ),
             headers=headers,
         )
 
-    async def get_thread_events_around(self, request: _pb2.GetThreadEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetThreadEventsAroundResponse:
+    async def get_thread_events_around(self, request: _pb2_room_timeline.GetThreadEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetThreadEventsAroundResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetThreadEventsAround",
                 service_name="chatto.api.v1.ThreadService",
-                input=_pb2.GetThreadEventsAroundRequest,
-                output=_pb2.GetThreadEventsAroundResponse,
+                input=_pb2_room_timeline.GetThreadEventsAroundRequest,
+                output=_pb2_room_timeline.GetThreadEventsAroundResponse,
             ),
             headers=headers,
         )
 
-    async def mark_thread_as_read(self, request: _pb2.MarkThreadAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2.MarkThreadAsReadResponse:
+    async def mark_thread_as_read(self, request: _pb2_read_state.MarkThreadAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2_read_state.MarkThreadAsReadResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="MarkThreadAsRead",
                 service_name="chatto.api.v1.ThreadService",
-                input=_pb2.MarkThreadAsReadRequest,
-                output=_pb2.MarkThreadAsReadResponse,
+                input=_pb2_read_state.MarkThreadAsReadRequest,
+                output=_pb2_read_state.MarkThreadAsReadResponse,
             ),
             headers=headers,
         )
@@ -116,38 +118,38 @@ class ThreadServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def get_thread_events(self, request: _pb2.GetThreadEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetThreadEventsResponse:
+    def get_thread_events(self, request: _pb2_room_timeline.GetThreadEventsRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetThreadEventsResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetThreadEvents",
                 service_name="chatto.api.v1.ThreadService",
-                input=_pb2.GetThreadEventsRequest,
-                output=_pb2.GetThreadEventsResponse,
+                input=_pb2_room_timeline.GetThreadEventsRequest,
+                output=_pb2_room_timeline.GetThreadEventsResponse,
             ),
             headers=headers,
         )
 
-    def get_thread_events_around(self, request: _pb2.GetThreadEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2.GetThreadEventsAroundResponse:
+    def get_thread_events_around(self, request: _pb2_room_timeline.GetThreadEventsAroundRequest, *, headers: dict[str, str] | None = None) -> _pb2_room_timeline.GetThreadEventsAroundResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="GetThreadEventsAround",
                 service_name="chatto.api.v1.ThreadService",
-                input=_pb2.GetThreadEventsAroundRequest,
-                output=_pb2.GetThreadEventsAroundResponse,
+                input=_pb2_room_timeline.GetThreadEventsAroundRequest,
+                output=_pb2_room_timeline.GetThreadEventsAroundResponse,
             ),
             headers=headers,
         )
 
-    def mark_thread_as_read(self, request: _pb2.MarkThreadAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2.MarkThreadAsReadResponse:
+    def mark_thread_as_read(self, request: _pb2_read_state.MarkThreadAsReadRequest, *, headers: dict[str, str] | None = None) -> _pb2_read_state.MarkThreadAsReadResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(
                 name="MarkThreadAsRead",
                 service_name="chatto.api.v1.ThreadService",
-                input=_pb2.MarkThreadAsReadRequest,
-                output=_pb2.MarkThreadAsReadResponse,
+                input=_pb2_read_state.MarkThreadAsReadRequest,
+                output=_pb2_read_state.MarkThreadAsReadResponse,
             ),
             headers=headers,
         )

+ 101 - 29
src/chattolib/_protoc_plugin.py

@@ -63,41 +63,99 @@ def _method_name(m: Any) -> str:
     return "".join(out)
 
 
-def _service_preamble(fd: Any) -> str:
-    """The shared header + imports for a generated module (emitted once)."""
-    pb2_module = fd.name[: -len(".proto")].replace("/", ".") + "_pb2"
-    return "\n".join(
-        [
-            _HEADER,
-            f"# source: {fd.name}",
-            "",
-            f"import {pb2_module} as _pb2",
-            "from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo",
-            "",
-            "",
-        ]
-    )
+def _message_owner(request: Any) -> dict[str, str]:
+    """Map each fully-qualified message name to the proto file that defines it.
+
+    A service's RPC can reference a request/response message that is defined in
+    a *different* ``.proto`` file than the service itself (e.g. ``MyAccountService
+    .UpdatePresence`` in ``account.proto`` uses ``UpdatePresenceRequest`` from
+    ``presence.proto``). To emit a correct import for each, we need to know which
+    proto owns each message. We scan every ``FileDescriptorProto`` in the request
+    (including nested messages) to build that map.
+    """
+    owner: dict[str, str] = {}
 
+    def _walk(file_name: str, pkg: str, message_type: Any) -> None:
+        for mt in message_type:
+            fqn = f"{pkg}.{mt.name}" if pkg else mt.name
+            owner.setdefault(fqn, file_name)
+            _walk(file_name, f"{pkg}.{mt.name}" if pkg else mt.name, mt.nested_type)
 
-def _service_body(fd: Any, svc: Any) -> str:
+    for fd in request.proto_file:
+        _walk(fd.name, fd.package, fd.message_type)
+
+    return owner
+
+
+def _module_alias(proto_name: str, current_proto: str) -> str:
+    """A stable import alias for a proto's ``_pb2`` module.
+
+    The proto being generated is always ``_pb2`` (so same-file references are
+    unchanged); any other referenced proto gets ``_pb2_<last-path-segment>``.
+    """
+    if proto_name == current_proto:
+        return "_pb2"
+    stem = proto_name[: -len(".proto")].rsplit("/", 1)[-1]
+    return f"_pb2_{stem}"
+
+
+def _pb2_module(proto_name: str) -> str:
+    """The dotted import path for a proto's generated ``_pb2`` module."""
+    return proto_name[: -len(".proto")].replace("/", ".") + "_pb2"
+
+
+def _service_preamble(fd: Any, extra_modules: list[str]) -> str:
+    """The shared header + imports for a generated module (emitted once).
+
+    ``extra_modules`` are proto file names (other than ``fd.name``) whose
+    messages this file's services reference; each gets its own import so a
+    cross-file RPC points at the module that actually defines its messages.
+    """
+    lines = [
+        _HEADER,
+        f"# source: {fd.name}",
+        "",
+        f"import {_pb2_module(fd.name)} as _pb2",
+    ]
+    for mod in extra_modules:
+        lines.append(f"import {_pb2_module(mod)} as {_module_alias(mod, fd.name)}")
+    lines += [
+        "from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo",
+        "",
+        "",
+    ]
+    return "\n".join(lines)
+
+
+def _service_body(fd: Any, svc: Any, owner: dict[str, str]) -> str:
     """Render one service's async + sync client classes (no preamble)."""
     pkg = fd.package
     base = f"{pkg}.{svc.name}" if pkg else svc.name
+
+    def _alias(fqn: str) -> str:
+        """The import alias to use for a fully-qualified message name."""
+        proto = owner.get(fqn, fd.name)
+        return _module_alias(proto, fd.name)
+
     lines: list[str] = [f"class {svc.name}Client(ConnectClient):"]
     for m in svc.method:
         mn = _method_name(m)
-        in_name = m.input_type.lstrip(".").split(".")[-1]
-        out_name = m.output_type.lstrip(".").split(".")[-1]
+        in_fqn = m.input_type.lstrip(".")
+        out_fqn = m.output_type.lstrip(".")
+        in_name = in_fqn.split(".")[-1]
+        out_name = out_fqn.split(".")[-1]
+        in_alias = _alias(in_fqn)
+        out_alias = _alias(out_fqn)
         lines += [
-            f"    async def {mn}(self, request: _pb2.{in_name}, *,"
-            f" headers: dict[str, str] | None = None) -> _pb2.{out_name}:",
+            f"    async def {mn}(self, request: {in_alias}.{in_name}, *,"
+            f" headers: dict[str, str] | None = None) -> {out_alias}.{out_name}:",
             "        return await self.execute_unary(",
             "            request=request,",
             "            method=MethodInfo(",
             f'                name="{m.name}",',
             f'                service_name="{base}",',
-            f"                input=_pb2.{in_name},",
-            f"                output=_pb2.{out_name},",
+            f"                input={in_alias}.{in_name},",
+            f"                output={out_alias}.{out_name},",
             "            ),",
             "            headers=headers,",
             "        )",
@@ -106,18 +164,22 @@ def _service_body(fd: Any, svc: Any) -> str:
     lines += [f"class {svc.name}ClientSync(ConnectClientSync):", ""]
     for m in svc.method:
         mn = _method_name(m)
-        in_name = m.input_type.lstrip(".").split(".")[-1]
-        out_name = m.output_type.lstrip(".").split(".")[-1]
+        in_fqn = m.input_type.lstrip(".")
+        out_fqn = m.output_type.lstrip(".")
+        in_name = in_fqn.split(".")[-1]
+        out_name = out_fqn.split(".")[-1]
+        in_alias = _alias(in_fqn)
+        out_alias = _alias(out_fqn)
         lines += [
-            f"    def {mn}(self, request: _pb2.{in_name}, *,"
-            f" headers: dict[str, str] | None = None) -> _pb2.{out_name}:",
+            f"    def {mn}(self, request: {in_alias}.{in_name}, *,"
+            f" headers: dict[str, str] | None = None) -> {out_alias}.{out_name}:",
             "        return self.execute_unary(",
             "            request=request,",
             "            method=MethodInfo(",
             f'                name="{m.name}",',
             f'                service_name="{base}",',
-            f"                input=_pb2.{in_name},",
-            f"                output=_pb2.{out_name},",
+            f"                input={in_alias}.{in_name},",
+            f"                output={out_alias}.{out_name},",
             "            ),",
             "            headers=headers,",
             "        )",
@@ -135,20 +197,30 @@ def generate(request: g.CodeGeneratorRequest) -> r.CodeGeneratorResponse:
     # file_to_generate is a repeated *string* (proto file names), not a list of
     # message objects — match by string membership.
     to_generate = set(request.file_to_generate)
+    owner = _message_owner(request)
     for fd in request.proto_file:
         if fd.name not in to_generate:
             continue
         services = [svc for svc in fd.service if svc.method]
         if not services:
             continue
+        # Collect the other proto files this file's services reference (for
+        # cross-file RPC messages), in first-seen order, deduplicated.
+        extra: list[str] = []
+        for svc in services:
+            for m in svc.method:
+                for fqn in (m.input_type.lstrip("."), m.output_type.lstrip(".")):
+                    proto = owner.get(fqn, fd.name)
+                    if proto != fd.name and proto not in extra:
+                        extra.append(proto)
         # One file per proto, containing every service it declares (e.g.
         # notifications.proto -> NotificationServiceClient and
         # NotificationPolicyServiceClient in the same module), matching the
         # historical connectrpc layout.
         out = resp.file.add()
         out.name = fd.name.replace(".proto", "_connect.py")
-        out.content = _service_preamble(fd) + "\n".join(
-            _service_body(fd, svc) for svc in services
+        out.content = _service_preamble(fd, extra) + "\n".join(
+            _service_body(fd, svc, owner) for svc in services
         )
     return resp
 

+ 51 - 0
tests/test_protoc_plugin.py

@@ -70,3 +70,54 @@ def test_skips_files_without_services():
     fd.message_type.add().name = "RealtimeClientFrame"  # messages, no service
     resp = generate(req)
     assert len(resp.file) == 0
+
+
+def _request_with_cross_file_rpc() -> g.CodeGeneratorRequest:
+    """A service in account.proto whose UpdatePresence RPC uses a message that
+    is defined in presence.proto — the cross-file case the plugin must handle."""
+    req = g.CodeGeneratorRequest()
+    req.file_to_generate.append("chatto/api/v1/account.proto")
+
+    # account.proto: declares the service + its own UpdateProfile messages.
+    acc = req.proto_file.add()
+    acc.name = "chatto/api/v1/account.proto"
+    acc.package = "chatto.api.v1"
+    acc.message_type.add().name = "UpdateProfileRequest"
+    acc.message_type.add().name = "UpdateProfileResponse"
+    svc = acc.service.add()
+    svc.name = "MyAccountService"
+    m = svc.method.add()
+    m.name = "UpdatePresence"
+    m.input_type = ".chatto.api.v1.UpdatePresenceRequest"
+    m.output_type = ".chatto.api.v1.UpdatePresenceResponse"
+    m2 = svc.method.add()
+    m2.name = "UpdateProfile"
+    m2.input_type = ".chatto.api.v1.UpdateProfileRequest"
+    m2.output_type = ".chatto.api.v1.UpdateProfileResponse"
+
+    # presence.proto: defines the UpdatePresence request/response.
+    pres = req.proto_file.add()
+    pres.name = "chatto/api/v1/presence.proto"
+    pres.package = "chatto.api.v1"
+    pres.message_type.add().name = "UpdatePresenceRequest"
+    pres.message_type.add().name = "UpdatePresenceResponse"
+    return req
+
+
+def test_cross_file_rpc_uses_the_defining_module():
+    resp = generate(_request_with_cross_file_rpc())
+    assert len(resp.file) == 1
+    content = resp.file[0].content
+    # The account_pb2 import is still there for the same-file messages.
+    assert "import chatto.api.v1.account_pb2 as _pb2" in content
+    # The presence messages must be imported from their own module and the
+    # UpdatePresence method must reference *that* module, not _pb2.
+    assert "import chatto.api.v1.presence_pb2 as _pb2_presence" in content
+    assert "_pb2_presence.UpdatePresenceRequest" in content
+    assert "_pb2_presence.UpdatePresenceResponse" in content
+    # The same-file method keeps using the plain _pb2 alias.
+    assert "_pb2.UpdateProfileRequest" in content
+    # Crucially: UpdatePresenceRequest must NOT be referenced via _pb2 (the
+    # original bug was `_pb2.UpdatePresenceRequest` -> AttributeError).
+    assert "_pb2.UpdatePresenceRequest" not in content
+    assert "_pb2.UpdatePresenceResponse" not in content