From ce60bb937043adc32e4241b77ad014ff3a5c4e55 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 3 Feb 2026 15:09:35 +0100 Subject: [PATCH 1/5] DEVEXP-1241: Conversation Messages - List (E2E) --- .../conversation/api/v1/internal/__init__.py | 2 + .../api/v1/internal/messages_endpoints.py | 41 +++++++++ .../conversation/api/v1/messages_apis.py | 88 +++++++++++++++++-- .../models/v1/messages/internal/__init__.py | 8 +- .../internal/list_messages_response.py | 24 +++++ .../v1/messages/internal/request/__init__.py | 4 + .../internal/request/list_messages_request.py | 64 ++++++++++++++ .../models/v1/messages/types/__init__.py | 4 + .../types/conversation_messages_view_type.py | 8 ++ .../features/steps/conversation.steps.py | 43 +++++++-- .../v1/test_conversation_messages.py | 36 ++++++++ 11 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py diff --git a/sinch/domains/conversation/api/v1/internal/__init__.py b/sinch/domains/conversation/api/v1/internal/__init__.py index bc4a7083..3fbd813d 100644 --- a/sinch/domains/conversation/api/v1/internal/__init__.py +++ b/sinch/domains/conversation/api/v1/internal/__init__.py @@ -1,6 +1,7 @@ from sinch.domains.conversation.api.v1.internal.messages_endpoints import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListMessagesEndpoint, UpdateMessageMetadataEndpoint, SendMessageEndpoint, ) @@ -8,6 +9,7 @@ __all__ = [ "DeleteMessageEndpoint", "GetMessageEndpoint", + "ListMessagesEndpoint", "UpdateMessageMetadataEndpoint", "SendMessageEndpoint", ] diff --git a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py index d28bcd4c..11de5527 100644 --- a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py +++ b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py @@ -2,10 +2,14 @@ from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, ) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) from sinch.domains.conversation.models.v1.messages.response.types import ( ConversationMessageResponse, SendMessageResponse, @@ -36,6 +40,43 @@ def build_query_params(self) -> dict: return query_params +class ListMessagesEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + QUERY_PARAM_FIELDS = { + "conversation_id", + "contact_id", + "app_id", + "channel_identity", + "start_time", + "end_time", + "page_size", + "page_token", + "view", + "messages_source", + "only_recipient_originated", + "channel", + } + + def __init__(self, project_id: str, request_data: ListMessagesRequest): + super(ListMessagesEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> ListMessagesResponse: + try: + super(ListMessagesEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListMessagesResponse) + + class DeleteMessageEndpoint(MessageEndpoint): ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" HTTP_METHOD = HTTPMethods.DELETE.value diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index 90bc7b84..f3a427c9 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -1,6 +1,8 @@ +from datetime import datetime from typing import Any, Dict, List, Optional, Union - +from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, @@ -11,12 +13,13 @@ SendMessageResponse, ) from sinch.domains.conversation.models.v1.messages.types import ( - MessagesSourceType, ConversationChannelType, - ProcessingStrategyType, - MetadataUpdateStrategyType, - MessageQueueType, + ConversationMessagesViewType, MessageContentType, + MessageQueueType, + MessagesSourceType, + MetadataUpdateStrategyType, + ProcessingStrategyType, CardMessageDict, CarouselMessageDict, ChoiceMessageDict, @@ -58,6 +61,7 @@ from sinch.domains.conversation.api.v1.internal import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListMessagesEndpoint, UpdateMessageMetadataEndpoint, SendMessageEndpoint, ) @@ -131,6 +135,80 @@ def get( ) return self._request(GetMessageEndpoint, request_data) + def list( + self, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + conversation_id: Optional[str] = None, + contact_id: Optional[str] = None, + app_id: Optional[str] = None, + channel_identity: Optional[str] = None, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + view: Optional[ConversationMessagesViewType] = None, + messages_source: Optional[MessagesSourceType] = None, + only_recipient_originated: Optional[bool] = None, + channel: Optional[ConversationChannelType] = None, + **kwargs, + ) -> Paginator[ConversationMessageResponse]: + """ + List messages sent or received via particular Processing Modes. + The messages are ordered by their accept_time property in descending order. + + :param page_size: Maximum number of messages to fetch. Defaults to 10, maximum is 1000. + :type page_size: Optional[int] + :param page_token: Next page token previously returned if any. + :type page_token: Optional[str] + :param conversation_id: Filter messages by conversation ID. + :type conversation_id: Optional[str] + :param contact_id: Filter messages by contact ID. + :type contact_id: Optional[str] + :param app_id: Filter messages by app ID. + :type app_id: Optional[str] + :param channel_identity: Channel identity of the contact. + :type channel_identity: Optional[str] + :param start_time: Filter messages with accept_time after this timestamp. + :type start_time: Optional[datetime] + :param end_time: Filter messages with accept_time before this timestamp. + :type end_time: Optional[datetime] + :param view: Messages view type. WITH_METADATA or WITHOUT_METADATA. + :type view: Optional[ConversationMessagesViewType] + :param messages_source: Specifies the message source for the request. + :type messages_source: Optional[MessagesSourceType] + :param only_recipient_originated: Only fetch recipient-originated messages. + :type only_recipient_originated: Optional[bool] + :param channel: Only fetch messages from the specified channel. + :type channel: Optional[ConversationChannelType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: TokenBasedPaginator with ConversationMessageResponse items + :rtype: Paginator[ConversationMessageResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListMessagesEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListMessagesRequest( + page_size=page_size, + page_token=page_token, + conversation_id=conversation_id, + contact_id=contact_id, + app_id=app_id, + channel_identity=channel_identity, + start_time=start_time, + end_time=end_time, + view=view, + messages_source=messages_source, + only_recipient_originated=only_recipient_originated, + channel=channel, + **kwargs, + ), + ), + ) + def update( self, message_id: str, diff --git a/sinch/domains/conversation/models/v1/messages/internal/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/__init__.py index a9a2c5b3..56c121c5 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/internal/__init__.py @@ -1 +1,7 @@ -__all__ = [] +from sinch.domains.conversation.models.v1.messages.internal.list_messages_response import ( + ListMessagesResponse, +) + +__all__ = [ + "ListMessagesResponse", +] diff --git a/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py b/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py new file mode 100644 index 00000000..d9604750 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py @@ -0,0 +1,24 @@ +from typing import List, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + ConversationMessageResponse, +) + + +class ListMessagesResponse(BaseModelConfiguration): + messages: Optional[List[ConversationMessageResponse]] = Field( + default=None, + description="List of messages associated to the referenced conversation.", + ) + next_page_token: Optional[StrictStr] = Field( + default=None, + description="Token that should be included in the next request to fetch the next page.", + ) + + @property + def content(self): + """Returns the messages as part of the response object for pagination compatibility.""" + return self.messages or [] diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py index da524ea8..43b2a215 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py @@ -1,3 +1,6 @@ +from sinch.domains.conversation.models.v1.messages.internal.request.list_messages_request import ( + ListMessagesRequest, +) from sinch.domains.conversation.models.v1.messages.internal.request.message_id_request import ( MessageIdRequest, ) @@ -17,6 +20,7 @@ ) __all__ = [ + "ListMessagesRequest", "MessageIdRequest", "UpdateMessageMetadataRequest", "Recipient", diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py new file mode 100644 index 00000000..21ae11c7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py @@ -0,0 +1,64 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ConversationChannelType, + ConversationMessagesViewType, + MessagesSourceType, +) + + +class ListMessagesRequest(BaseModelConfiguration): + """Request model for listing messages.""" + + conversation_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by conversation ID.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by contact ID.", + ) + app_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by app ID.", + ) + channel_identity: Optional[StrictStr] = Field( + default=None, + description="Channel identity of the contact.", + ) + start_time: Optional[datetime] = Field( + default=None, + description="Filter messages with accept_time after this timestamp.", + ) + end_time: Optional[datetime] = Field( + default=None, + description="Filter messages with accept_time before this timestamp.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="Maximum number of messages to fetch. Defaults to 10, maximum is 1000.", + ) + page_token: Optional[StrictStr] = Field( + default=None, + description="Next page token previously returned if any.", + ) + view: Optional[ConversationMessagesViewType] = Field( + default=None, + description="Messages view type. WITH_METADATA or WITHOUT_METADATA.", + ) + messages_source: Optional[MessagesSourceType] = Field( + default=None, + description="Specifies the message source for the request.", + ) + only_recipient_originated: Optional[bool] = Field( + default=None, + description="Only fetch recipient-originated messages.", + ) + channel: Optional[ConversationChannelType] = Field( + default=None, + description="Only fetch messages from the specified channel.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py index fb34138a..5834cd05 100644 --- a/sinch/domains/conversation/models/v1/messages/types/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/types/__init__.py @@ -7,6 +7,9 @@ from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( ConversationChannelType, ) +from sinch.domains.conversation.models.v1.messages.types.conversation_messages_view_type import ( + ConversationMessagesViewType, +) from sinch.domains.conversation.models.v1.messages.types.conversation_direction_type import ( ConversationDirectionType, ) @@ -87,6 +90,7 @@ __all__ = [ "AgentType", "ConversationChannelType", + "ConversationMessagesViewType", "ConversationDirectionType", "ProcessingModeType", "CardHeightType", diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py new file mode 100644 index 00000000..643df25f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py @@ -0,0 +1,8 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ConversationMessagesViewType = Union[ + Literal["WITH_METADATA", "WITHOUT_METADATA"], + StrictStr, +] diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py index e330ca95..db251a59 100644 --- a/tests/e2e/conversation/features/steps/conversation.steps.py +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -85,29 +85,60 @@ def step_validate_send_message_response(context): @when('I send a request to list the existing messages') def step_list_messages(context): - pass + context.list_response = context.messages.list(page_size=2) @then('the response contains "{count}" messages') def step_validate_message_count(context, count): - pass + expected_messages_count = int(count) + assert len(context.list_response.content()) == expected_messages_count, ( + f'Expected {expected_messages_count} messages, got {len(context.list_response.content())}' + ) @when('I send a request to list all the messages') def step_list_all_messages(context): - pass + """List all messages using iterator""" + response = context.messages.list(page_size=2) + messages_list = [] + + for message in response.iterator(): + messages_list.append(message) + + context.messages_list = messages_list @then('the messages list contains "{count}" messages') def step_validate_total_message_count(context, count): - pass + expected_messages_count = int(count) + assert len(context.messages_list) == expected_messages_count, ( + f'Expected {expected_messages_count} messages, got {len(context.messages_list)}' + ) @when('I iterate manually over the messages pages') def step_iterate_messages_pages(context): - pass + """Manually iterate over messages pages""" + context.list_response = context.messages.list( + page_size=2, + ) + + context.messages_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + + while not reached_end_of_pages: + context.messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True @then('the result contains the data from "{count}" pages') def step_validate_page_count(context, count): - pass + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, ( + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + ) diff --git a/tests/unit/domains/conversation/v1/test_conversation_messages.py b/tests/unit/domains/conversation/v1/test_conversation_messages.py index 144d30dd..f8d7f9d2 100644 --- a/tests/unit/domains/conversation/v1/test_conversation_messages.py +++ b/tests/unit/domains/conversation/v1/test_conversation_messages.py @@ -5,13 +5,19 @@ import pytest from sinch.domains.conversation.conversation import Conversation from sinch.domains.conversation.api.v1 import Messages +from sinch.core.pagination import TokenBasedPaginator from sinch.domains.conversation.api.v1.internal import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListMessagesEndpoint, SendMessageEndpoint, UpdateMessageMetadataEndpoint, ) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, @@ -84,6 +90,36 @@ def test_messages_delete_with_messages_source_expects_correct_request( assert kwargs["request_data"].messages_source == "DISPATCH_SOURCE" +def test_messages_list_expects_correct_request( + mock_sinch_client_conversation, mocker +): + """ + Test that the Messages.list() method sends the correct request + and handles the response properly. + """ + mock_response = ListMessagesResponse(messages=[], next_page_token=None) + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_response + ) + + # Spy on the ListMessagesEndpoint to capture calls + spy_endpoint = mocker.spy(ListMessagesEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.list(page_size=10) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListMessagesRequest(page_size=10) + + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + def test_messages_get_expects_correct_request( mock_sinch_client_conversation, mock_conversation_message_response, mocker ): From b6e18695b0942378c167a8edfc311f8a6f4eca8f Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 4 Feb 2026 10:59:13 +0100 Subject: [PATCH 2/5] PR comment --- .../api/v1/internal/messages_endpoints.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py index 11de5527..d371c95e 100644 --- a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py +++ b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py @@ -46,18 +46,19 @@ class ListMessagesEndpoint(MessageEndpoint): HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value QUERY_PARAM_FIELDS = { - "conversation_id", - "contact_id", "app_id", + "channel", "channel_identity", - "start_time", + "contact_id", + "conversation_id", + "direction", "end_time", + "messages_source", + "only_recipient_originated", "page_size", "page_token", + "start_time", "view", - "messages_source", - "only_recipient_originated", - "channel", } def __init__(self, project_id: str, request_data: ListMessagesRequest): From 20dfb23802a7004bc89d3342706a875179a061d7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 4 Feb 2026 12:11:31 +0100 Subject: [PATCH 3/5] update request model --- .../v1/messages/internal/request/list_messages_request.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py index 21ae11c7..7e5c8507 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py @@ -6,6 +6,7 @@ ) from sinch.domains.conversation.models.v1.messages.types import ( ConversationChannelType, + ConversationDirectionType, ConversationMessagesViewType, MessagesSourceType, ) @@ -62,3 +63,7 @@ class ListMessagesRequest(BaseModelConfiguration): default=None, description="Only fetch messages from the specified channel.", ) + direction: Optional[ConversationDirectionType] = Field( + default=None, + description="Only fetch messages with the specified direction. TO_APP or TO_CONTACT.", + ) From 96806362c73570da30788b88f55cefaedb3893f3 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 4 Feb 2026 16:28:07 +0100 Subject: [PATCH 4/5] update api file --- sinch/domains/conversation/api/v1/messages_apis.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index f3a427c9..d220712f 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -149,6 +149,7 @@ def list( messages_source: Optional[MessagesSourceType] = None, only_recipient_originated: Optional[bool] = None, channel: Optional[ConversationChannelType] = None, + direction: Optional[ConversationDirectionType] = None, **kwargs, ) -> Paginator[ConversationMessageResponse]: """ @@ -179,6 +180,8 @@ def list( :type only_recipient_originated: Optional[bool] :param channel: Only fetch messages from the specified channel. :type channel: Optional[ConversationChannelType] + :param direction: Only fetch messages with the specified direction. TO_APP or TO_CONTACT. + :type direction: Optional[ConversationDirectionType] :param **kwargs: Additional parameters for the request. :type **kwargs: dict @@ -204,6 +207,7 @@ def list( messages_source=messages_source, only_recipient_originated=only_recipient_originated, channel=channel, + direction=direction, **kwargs, ), ), From d7cd2661959ff7718e4973d181203e36b97e1581 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 4 Feb 2026 16:34:29 +0100 Subject: [PATCH 5/5] fix ci --- sinch/domains/conversation/api/v1/messages_apis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index d220712f..36222476 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -14,6 +14,7 @@ ) from sinch.domains.conversation.models.v1.messages.types import ( ConversationChannelType, + ConversationDirectionType, ConversationMessagesViewType, MessageContentType, MessageQueueType,