diff --git a/sinch/domains/conversation/api/v1/internal/__init__.py b/sinch/domains/conversation/api/v1/internal/__init__.py index bc4a708..3fbd813 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 d28bcd4..d371c95 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,44 @@ 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 = { + "app_id", + "channel", + "channel_identity", + "contact_id", + "conversation_id", + "direction", + "end_time", + "messages_source", + "only_recipient_originated", + "page_size", + "page_token", + "start_time", + "view", + } + + 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 90bc7b8..3622247 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,14 @@ SendMessageResponse, ) from sinch.domains.conversation.models.v1.messages.types import ( - MessagesSourceType, ConversationChannelType, - ProcessingStrategyType, - MetadataUpdateStrategyType, - MessageQueueType, + ConversationDirectionType, + ConversationMessagesViewType, MessageContentType, + MessageQueueType, + MessagesSourceType, + MetadataUpdateStrategyType, + ProcessingStrategyType, CardMessageDict, CarouselMessageDict, ChoiceMessageDict, @@ -58,6 +62,7 @@ from sinch.domains.conversation.api.v1.internal import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListMessagesEndpoint, UpdateMessageMetadataEndpoint, SendMessageEndpoint, ) @@ -131,6 +136,84 @@ 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, + direction: Optional[ConversationDirectionType] = 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 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 + + :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, + direction=direction, + **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 a9a2c5b..56c121c 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 0000000..d960475 --- /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 da524ea..43b2a21 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 0000000..7e5c850 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py @@ -0,0 +1,69 @@ +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, + ConversationDirectionType, + 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.", + ) + direction: Optional[ConversationDirectionType] = Field( + default=None, + description="Only fetch messages with the specified direction. TO_APP or TO_CONTACT.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py index fb34138..5834cd0 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 0000000..643df25 --- /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 e330ca9..db251a5 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 144d30d..f8d7f9d 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 ):