Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sinch/domains/conversation/api/v1/internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
DeleteMessageEndpoint,
GetMessageEndpoint,
UpdateMessageMetadataEndpoint,
SendMessageEndpoint,
)

__all__ = [
"DeleteMessageEndpoint",
"GetMessageEndpoint",
"UpdateMessageMetadataEndpoint",
"SendMessageEndpoint",
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
from sinch.domains.conversation.models.v1.messages.internal.request import (
MessageIdRequest,
UpdateMessageMetadataRequest,
SendMessageRequest,
)
from sinch.domains.conversation.models.v1.messages.response.types import (
ConversationMessageResponse,
SendMessageResponse,
)
from sinch.domains.conversation.api.v1.internal.base import (
ConversationEndpoint,
Expand Down Expand Up @@ -124,3 +126,32 @@ def handle_response(
return self.process_response_model(
response.body, ConversationMessageResponse
)


class SendMessageEndpoint(ConversationEndpoint):
ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages:send"
HTTP_METHOD = HTTPMethods.POST.value
HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value

def __init__(self, project_id: str, request_data: SendMessageRequest):
super(SendMessageEndpoint, self).__init__(project_id, request_data)
self.project_id = project_id
self.request_data = request_data

def request_body(self):
path_params = self._get_path_params_from_url()
request_data_dict = self.request_data.model_dump(
mode="json", by_alias=True, exclude_none=True, exclude=path_params
)
return json.dumps(request_data_dict)

def handle_response(self, response: HTTPResponse) -> SendMessageResponse:
try:
super(SendMessageEndpoint, 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, SendMessageResponse)
1,004 changes: 1,003 additions & 1 deletion sinch/domains/conversation/api/v1/messages_apis.py

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions sinch/domains/conversation/api/v1/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Utility functions for Conversation API message operations.
"""

from sinch.domains.conversation.api.v1.utils.message_helpers import (
build_recipient_dict,
coerce_recipient,
split_send_kwargs,
)

__all__ = [
"build_recipient_dict",
"coerce_recipient",
"split_send_kwargs",
]
122 changes: 122 additions & 0 deletions sinch/domains/conversation/api/v1/utils/message_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""
Helper functions for building and processing message requests.

This module contains pure utility functions that handle common operations
for message sending, such as recipient validation, type coercion, and
parameter splitting.
"""

from typing import List, Optional, Union

from sinch.domains.conversation.models.v1.messages.internal.request.recipient import (
ChannelRecipientIdentity,
IdentifiedBy,
Recipient,
)
from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request_body import (
SendMessageRequestBody,
)
from sinch.domains.conversation.models.v1.messages.types import (
ChannelRecipientIdentityDict,
RecipientDict,
)


def build_recipient_dict(
contact_id: Optional[str] = None,
recipient_identities: Optional[List[ChannelRecipientIdentityDict]] = None,
) -> RecipientDict:
"""
Build a RecipientDict from optional contact_id or recipient_identities.

Validates that exactly one of the parameters is provided and returns
the appropriate dictionary structure.

:param contact_id: The contact ID of the recipient.
:type contact_id: Optional[str]
:param recipient_identities: List of channel identities for the recipient.
:type recipient_identities: Optional[List[ChannelRecipientIdentityDict]]

:returns: A RecipientDict with either contact_id or channel_identities.
:rtype: RecipientDict

:raises ValueError: If both or neither parameters are provided.
"""
has_contact_id = contact_id is not None
has_identities = recipient_identities is not None

if has_contact_id and has_identities:
raise ValueError(
"Cannot specify both 'contact_id' and 'recipient_identities'. "
"Provide exactly one."
)
if not has_contact_id and not has_identities:
raise ValueError(
"Must provide either 'contact_id' or 'recipient_identities'."
)

return (
{"contact_id": contact_id}
if has_contact_id
else {"channel_identities": recipient_identities}
)


def coerce_recipient(recipient: Union[Recipient, dict]) -> Recipient:
"""
Coerce a recipient input to a Recipient model instance.

Handles multiple input formats:
- Recipient model instance (returns as-is)
- Simplified dict: {"channel_identities": [...]}
- Simplified dict: {"contact_id": "..."}
- Full form dict: {"identified_by": {"channel_identities": [...]}}

:param recipient: The recipient as a Recipient model or dict.
:type recipient: Union[Recipient, dict]

:returns: A Recipient model instance.
:rtype: Recipient
"""
if isinstance(recipient, dict):
# Allow passing recipient dict in simplified form:
# - {"channel_identities": [...]} -> converts to {"identified_by": {"channel_identities": [...]}}
# - {"contact_id": "..."}
# - Or full form: {"identified_by": {"channel_identities": [...]}}
if (
"channel_identities" in recipient
and "identified_by" not in recipient
):
channel_identities = [
ChannelRecipientIdentity(**ci) if isinstance(ci, dict) else ci
for ci in recipient["channel_identities"]
]
return Recipient(
identified_by=IdentifiedBy(
channel_identities=channel_identities
)
)
return Recipient(**recipient)
return recipient


def split_send_kwargs(kwargs: dict) -> tuple[dict, dict]:
"""
Split kwargs into message-level and request-level parameters.

Separates keyword arguments into two groups:
- message_kwargs: Fields that belong under the `message` field
- request_kwargs: Fields that belong on the SendMessageRequest itself

:param kwargs: Dictionary of keyword arguments to split.
:type kwargs: dict

:returns: A tuple of (message_kwargs, request_kwargs).
:rtype: tuple[dict, dict]
"""
message_fields = set(SendMessageRequestBody.model_fields.keys())
message_kwargs = {k: v for k, v in kwargs.items() if k in message_fields}
request_kwargs = {
k: v for k, v in kwargs.items() if k not in message_fields
}
return message_kwargs, request_kwargs
Original file line number Diff line number Diff line change
Expand Up @@ -27,52 +27,44 @@
TextMessage,
)
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)
from sinch.domains.conversation.models.v1.messages.shared.app_message_common_props import (
AppMessageCommonProps,
)


class CardAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse):
class CardAppMessage(AppMessageCommonProps, BaseModelConfiguration):
card_message: Optional[CardMessage] = None


class CarouselAppMessage(
AppMessageCommonProps, BaseModelConfigurationResponse
):
class CarouselAppMessage(AppMessageCommonProps, BaseModelConfiguration):
carousel_message: Optional[CarouselMessage] = None


class ChoiceAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse):
class ChoiceAppMessage(AppMessageCommonProps, BaseModelConfiguration):
choice_message: Optional[ChoiceMessage] = None


class LocationAppMessage(
AppMessageCommonProps, BaseModelConfigurationResponse
):
class LocationAppMessage(AppMessageCommonProps, BaseModelConfiguration):
location_message: Optional[LocationMessage] = None


class MediaAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse):
class MediaAppMessage(AppMessageCommonProps, BaseModelConfiguration):
media_message: Optional[MediaProperties] = None


class TemplateAppMessage(
AppMessageCommonProps, BaseModelConfigurationResponse
):
class TemplateAppMessage(AppMessageCommonProps, BaseModelConfiguration):
template_message: Optional[TemplateMessage] = None


class TextAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse):
class TextAppMessage(AppMessageCommonProps, BaseModelConfiguration):
text_message: Optional[TextMessage] = None


class ListAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse):
class ListAppMessage(AppMessageCommonProps, BaseModelConfiguration):
list_message: Optional[ListMessage] = None


class ContactInfoAppMessage(
AppMessageCommonProps, BaseModelConfigurationResponse
):
class ContactInfoAppMessage(AppMessageCommonProps, BaseModelConfiguration):
contact_info_message: Optional[ContactInfoMessage] = None
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
from datetime import datetime
from pydantic import Field, StrictStr
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class CalendarMessage(BaseModelConfigurationResponse):
class CalendarMessage(BaseModelConfiguration):
title: StrictStr = Field(
...,
description="The title is shown close to the button that leads to open a user calendar.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from pydantic import Field, StrictStr
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class CallMessage(BaseModelConfigurationResponse):
class CallMessage(BaseModelConfiguration):
phone_number: StrictStr = Field(
default=..., description="Phone number in E.164 with leading +."
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
from sinch.domains.conversation.models.v1.messages.categories.media import (
MediaProperties,
)
from sinch.domains.conversation.models.v1.messages.response.types.choice_option import (
from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import (
ChoiceOption,
)
from sinch.domains.conversation.models.v1.messages.categories.card.message_properties import (
MessageProperties,
)
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class CardMessage(BaseModelConfigurationResponse):
class CardMessage(BaseModelConfiguration):
choices: Optional[conlist(ChoiceOption)] = Field(
default=None,
description="You may include choices in your Card Message. The number of choices is limited to 10.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
CardMessage,
)
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class CardMessageField(BaseModelConfigurationResponse):
class CardMessageField(BaseModelConfiguration):
card_message: Optional[CardMessage] = None
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from typing import Optional
from pydantic import Field, StrictStr
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class MessageProperties(BaseModelConfigurationResponse):
class MessageProperties(BaseModelConfiguration):
whatsapp_header: Optional[StrictStr] = Field(
default=None,
description=(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from sinch.domains.conversation.models.v1.messages.categories.card.card_message import (
CardMessage,
)
from sinch.domains.conversation.models.v1.messages.response.types.choice_option import (
from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import (
ChoiceOption,
)
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class CarouselMessage(BaseModelConfigurationResponse):
class CarouselMessage(BaseModelConfiguration):
cards: conlist(CardMessage) = Field(
default=..., description="A list of up to 10 cards."
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
CarouselMessage,
)
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class CarouselMessageField(BaseModelConfigurationResponse):
class CarouselMessageField(BaseModelConfiguration):
carousel_message: Optional[CarouselMessage] = None
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
WhatsAppInteractiveNfmReplyMessage,
)
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class ChannelSpecificContactMessageMessage(BaseModelConfigurationResponse):
class ChannelSpecificContactMessageMessage(BaseModelConfiguration):
message_type: Literal["nfm_reply"] = Field(
..., description="The message type."
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
from sinch.domains.conversation.models.v1.messages.types.channel_specific_message_type import (
ChannelSpecificMessageType,
)
from sinch.domains.conversation.models.v1.messages.response.types.channel_specific_message_content import (
from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_message_content import (
ChannelSpecificMessageContent,
)
from sinch.domains.conversation.models.v1.messages.internal.base import (
BaseModelConfigurationResponse,
BaseModelConfiguration,
)


class ChannelSpecificMessage(BaseModelConfigurationResponse):
class ChannelSpecificMessage(BaseModelConfiguration):
message_type: ChannelSpecificMessageType = Field(
..., description="The type of the channel specific message."
)
Expand Down
Loading