diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 6ba05b98..e284fc00 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -1,7 +1,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from .models import BaseMessage +from .models import BaseMessage, ReplyingIndicatorText from .responses import SendMessageResponse @@ -66,7 +66,9 @@ def send( return SendMessageResponse(**response) @validate_call - def mark_whatsapp_message_read(self, message_uuid: str) -> None: + def mark_whatsapp_message_read( + self, message_uuid: str, replying_indicator: ReplyingIndicatorText = None + ) -> None: """Mark a WhatsApp message as read. Note: to use this method, update the `api_host` attribute of the @@ -78,11 +80,18 @@ def mark_whatsapp_message_read(self, message_uuid: str) -> None: Args: message_uuid (str): The unique identifier of the WhatsApp message to mark as read. + replying_indicator (ReplyingIndicatorText, optional): An object indicating whether to show the replying indicator on the WhatsApp message. """ + body = {'status': 'read'} + if replying_indicator is not None: + body['replying_indicator'] = replying_indicator.model_dump( + by_alias=True, exclude_none=True + ) + self._http_client.patch( self._http_client.api_host, f'/v1/messages/{message_uuid}', - {'status': 'read'}, + body, self._auth_type, ) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index bbd6d65d..212d2113 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -1,5 +1,16 @@ from .base_message import BaseMessage -from .enums import ChannelType, EncodingType, MessageType, WebhookVersion +from .enums import ( + ChannelType, + EncodingType, + MessageType, + MmsContentItemType, + RcsCardOrientation, + RcsCardWidth, + RcsCategory, + RcsImageAlignment, + RcsMediaHeight, + WebhookVersion, +) from .messenger import ( MessengerAudio, MessengerFile, @@ -9,8 +20,44 @@ MessengerText, MessengerVideo, ) -from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo +from .mms import ( + MmsAudio, + MmsContent, + MmsContentItemAudio, + MmsContentItemFile, + MmsContentItemImage, + MmsContentItemVcard, + MmsContentItemVideo, + MmsFile, + MmsImage, + MmsResource, + MmsText, + MmsVcard, + MmsVideo, +) +from .rcs import ( + RcsCard, + RcsCardMessage, + RcsCarousel, + RcsCarouselMessage, + RcsCustom, + RcsFile, + RcsImage, + RcsOptions, + RcsOptionsCard, + RcsOptionsCarousel, + RcsResource, + RcsSuggestionActionCreateCalendarEvent, + RcsSuggestionActionDial, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionShareLocation, + RcsSuggestionActionViewLocation, + RcsSuggestionBase, + RcsSuggestionReply, + RcsText, + RcsVideo, +) from .sms import Sms, SmsOptions from .viber import ( ViberAction, @@ -27,6 +74,7 @@ ViberVideoResource, ) from .whatsapp import ( + ReplyingIndicatorText, WhatsappAudio, WhatsappAudioResource, WhatsappContext, @@ -59,16 +107,46 @@ 'MessengerText', 'MessengerVideo', 'MmsAudio', + 'MmsContent', + 'MmsContentItemImage', + 'MmsContentItemAudio', + 'MmsContentItemVideo', + 'MmsContentItemFile', + 'MmsContentItemVcard', + 'MmsContentItemType', + 'MmsFile', 'MmsImage', 'MmsResource', + 'MmsText', 'MmsVcard', 'MmsVideo', + 'RcsCard', + 'RcsCardMessage', + 'RcsCarousel', + 'RcsCarouselMessage', 'RcsCustom', 'RcsFile', 'RcsImage', + 'RcsOptions', + 'RcsOptionsCard', + 'RcsOptionsCarousel', 'RcsResource', + 'RcsSuggestionActionCreateCalendarEvent', + 'RcsSuggestionActionDial', + 'RcsSuggestionActionOpenUrl', + 'RcsSuggestionActionOpenUrlWebview', + 'RcsSuggestionActionShareLocation', + 'RcsSuggestionActionViewLocation', + 'RcsSuggestionBase', + 'RcsSuggestionReply', 'RcsText', 'RcsVideo', + 'RcsCategory', + 'RcsCardOrientation', + 'RcsImageAlignment', + 'RcsCardWidth', + 'RcsMediaHeight', + 'ReplyingIndicatorText', 'Sms', 'SmsOptions', 'ViberAction', diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index f0bb501a..d6aeec16 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -13,6 +13,9 @@ class MessageType(str, Enum): STICKER = 'sticker' CUSTOM = 'custom' VCARD = 'vcard' + CARD = 'card' + CAROUSEL = 'carousel' + CONTENT = 'content' class ChannelType(str, Enum): @@ -37,3 +40,78 @@ class EncodingType(str, Enum): TEXT = 'text' UNICODE = 'unicode' AUTO = 'auto' + + +class SuggestionType(str, Enum): + """The type of RCS suggestion.""" + + REPLY = 'reply' + DIAL = 'dial' + VIEW_LOCATION = 'view_location' + SHARE_LOCATION = 'share_location' + OPEN_URL = 'open_url' + OPEN_URL_IN_WEBVIEW = 'open_url_in_webview' + CREATE_CALENDAR_EVENT = 'create_calendar_event' + + +class UrlWebviewViewMode(str, Enum): + """The view mode for an RCS suggestion that opens a URL in a webview.""" + + FULL = 'FULL' + TALL = 'TALL' + HALF = 'HALF' + + +class RcsCategory(str, Enum): + """The category of an RCS message.""" + + ACKNOWLEDGEMENT = 'acknowledgement' + AUTHENTICATION = 'authentication' + PROMOTION = 'promotion' + SERVICE_REQUEST = 'service-request' + TRANSACTION = 'transaction' + + +class RcsCardOrientation(str, Enum): + """The orientation of an RCS card.""" + + VERTICAL = 'VERTICAL' + HORIZONTAL = 'HORIZONTAL' + + +class RcsImageAlignment(str, Enum): + """The alignment of an image on an RCS card.""" + + LEFT = 'LEFT' + RIGHT = 'RIGHT' + + +class RcsCardWidth(str, Enum): + """The width of a card in an RCS carousel.""" + + SMALL = 'SMALL' + MEDIUM = 'MEDIUM' + + +class RcsMediaHeight(str, Enum): + """The height of media on an RCS card.""" + + SHORT = 'SHORT' + MEDIUM = 'MEDIUM' + TALL = 'TALL' + + +class MmsContentItemType(str, Enum): + """The type of a content item in an MMS Content message.""" + + IMAGE = 'image' + AUDIO = 'audio' + VIDEO = 'video' + FILE = 'file' + VCARD = 'vcard' + + +class ReplyingIndicatorType(str, Enum): + """The type of a WhatsApp replying indicator.""" + + TEXT = 'text' diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index 6220ed11..bdc54be5 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType +from .enums import ChannelType, MessageType, MmsContentItemType class MmsResource(BaseModel): @@ -12,11 +12,11 @@ class MmsResource(BaseModel): Args: url (str): The URL of the resource. - caption (str, Optional): Additional text to accompany the resource. + caption (str, Optional): Additional text to accompany the resource, with a maximum length of 3000 characters. """ url: str - caption: Optional[str] = Field(None, min_length=1, max_length=2000) + caption: Optional[str] = Field(None, min_length=1, max_length=3000) class BaseMms(BaseMessage): @@ -26,6 +26,7 @@ class BaseMms(BaseMessage): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -34,9 +35,28 @@ class BaseMms(BaseMessage): to: PhoneNumber from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') ttl: Optional[int] = Field(None, ge=300, le=259200) + trusted_recipient: Optional[bool] = None channel: ChannelType = ChannelType.MMS +class MmsText(BaseMms): + """Model for an MMS text message. + + Args: + text (str): The text of the message. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + text: str + message_type: MessageType = MessageType.TEXT + + class MmsImage(BaseMms): """Model for an MMS image message. @@ -45,6 +65,7 @@ class MmsImage(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -62,6 +83,7 @@ class MmsVcard(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -79,6 +101,7 @@ class MmsAudio(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -96,6 +119,7 @@ class MmsVideo(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -103,3 +127,110 @@ class MmsVideo(BaseMms): video: MmsResource message_type: MessageType = MessageType.VIDEO + + +class MmsFile(BaseMms): + """Model for an MMS file message. + + Args: + file (MmsResource): The file resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + file: MmsResource + message_type: MessageType = MessageType.FILE + + +class MmsContentItemImage(MmsResource): + """Model for an image content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.IMAGE, serialization_alias='type' + ) + + +class MmsContentItemAudio(MmsResource): + """Model for an audio content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.AUDIO, serialization_alias='type' + ) + + +class MmsContentItemVideo(MmsResource): + """Model for a video content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.VIDEO, serialization_alias='type' + ) + + +class MmsContentItemFile(MmsResource): + """Model for a file content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field(MmsContentItemType.FILE, serialization_alias='type') + + +class MmsContentItemVcard(MmsResource): + """Model for a vCard content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.VCARD, serialization_alias='type' + ) + + +class MmsContent(BaseMms): + """Model for an MMS message with content that can be of various types. + + Args: + content (list[MmsContentItem]): A list of content items for the message (images, audio, video, files, or vCards). + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + content: list[ + Union[ + MmsContentItemImage, + MmsContentItemAudio, + MmsContentItemVideo, + MmsContentItemFile, + MmsContentItemVcard, + ] + ] + message_type: MessageType = MessageType.CONTENT diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 6ed1e7b7..0b906523 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -1,10 +1,20 @@ -from typing import Optional +from typing import List, Optional, Union from pydantic import BaseModel, Field from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType +from .enums import ( + ChannelType, + MessageType, + RcsCardOrientation, + RcsCardWidth, + RcsCategory, + RcsImageAlignment, + RcsMediaHeight, + SuggestionType, + UrlWebviewViewMode, +) class RcsResource(BaseModel): @@ -17,21 +27,186 @@ class RcsResource(BaseModel): url: str +class RcsSuggestionBase(BaseModel): + """Base model for a suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + text: str = Field(..., min_length=1, max_length=25) + postback_data: str + + +class RcsSuggestionReply(RcsSuggestionBase): + """Model for a reply suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + type_: SuggestionType = Field(SuggestionType.REPLY, serialization_alias='type') + + +class RcsSuggestionActionDial(RcsSuggestionBase): + """Model for a dial action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + phone_number (str): The phone number to dial when the suggestion is selected. In E.164 format without the leading plus sign. + fallback_url (str, Optional): The URL to open if the device doesn't support the dial action. + """ + + type_: SuggestionType = Field(SuggestionType.DIAL, serialization_alias='type') + phone_number: PhoneNumber + fallback_url: Optional[str] = None + + +class RcsSuggestionActionViewLocation(RcsSuggestionBase): + """Model for a view location action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + latitude (float): The latitude of the location to view when the suggestion is selected. + longitude (float): The longitude of the location to view when the suggestion is selected. + pin_label (str): The label to display on the location pin. + fallback_url (str, Optional): The URL to open if the device doesn't support the view location action. + """ + + type_: SuggestionType = Field( + SuggestionType.VIEW_LOCATION, serialization_alias='type' + ) + latitude: str + longitude: str + pin_label: str + fallback_url: Optional[str] = None + + +class RcsSuggestionActionShareLocation(RcsSuggestionBase): + """Model for a share location action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + type_: SuggestionType = Field( + SuggestionType.SHARE_LOCATION, serialization_alias='type' + ) + + +class RcsSuggestionActionOpenUrl(RcsSuggestionBase): + """Model for an open URL action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + url (str): The URL to open when the suggestion is selected. + description (str): A short description of the URL for accessibility purposes. + """ + + type_: SuggestionType = Field(SuggestionType.OPEN_URL, serialization_alias='type') + url: str + description: str = Field(..., min_length=1, max_length=500) + + +class RcsSuggestionActionOpenUrlWebview(RcsSuggestionActionOpenUrl): + """Model for an open URL in webview action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + url (str): The URL to open in a webview when the suggestion is selected. + description (str): A short description of the URL for accessibility purposes. + view_mode (str, Optional): The view mode for the webview (FULL, TALL, HALF). If not specified, the default view mode for the device will be used. + """ + + type_: SuggestionType = Field( + SuggestionType.OPEN_URL_IN_WEBVIEW, serialization_alias='type' + ) + view_mode: Optional[UrlWebviewViewMode] = None + + +class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): + """Model for a create calendar event action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + start_time (str): The start time of the calendar event in ISO 8601 format. + end_time (str): The end time of the calendar event in ISO 8601 format + title (str): The title of the calendar event. + description (str): The description of the calendar event. + fallback_url (str, Optional): The URL to open if the device doesn't support the create calendar event action. + """ + + type_: SuggestionType = Field( + SuggestionType.CREATE_CALENDAR_EVENT, serialization_alias='type' + ) + start_time: str + end_time: str + title: str = Field(..., min_length=1, max_length=100) + description: str = Field(..., min_length=1, max_length=500) + fallback_url: Optional[str] = None + + +class RcsOptions(BaseModel): + """Base model for RCS message options. + + Args: + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). + """ + + category: Optional[RcsCategory] = None + + +class RcsOptionsCard(RcsOptions): + """Model for an RCS card message options. + + Args: + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). + card_orientation (str): The orientation of the card (HORIZONTAL or VERTICAL). + image_alignment (str): The alignment of the image on the card (LEFT or RIGHT). + """ + + card_orientation: Optional[RcsCardOrientation] = None + image_alignment: Optional[RcsImageAlignment] = None + + +class RcsOptionsCarousel(RcsOptions): + """Model for an RCS carousel message options. + + Args: + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). + card_width (str): The width of each card in the carousel (SMALL or MEDIUM). + """ + + card_width: RcsCardWidth + + class BaseRcs(BaseMessage): - """Model for a base RCS message. + """Base model for a base RCS message. Args: to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: RcsOptions, Optional: An optional RcsOptions object to include in the message. """ to: PhoneNumber from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9-_&]+$') - ttl: Optional[int] = Field(None, ge=300, le=259200) + ttl: Optional[int] = Field(None, ge=20, le=259200) + trusted_recipient: Optional[bool] = None + rcs: Optional[RcsOptions] = None channel: ChannelType = ChannelType.RCS @@ -39,30 +214,48 @@ class RcsText(BaseRcs): """Model for an RCS text message. Args: - text (str): The text of the message. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + text (str): The text of the message. + suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ text: str = Field(..., min_length=1, max_length=3072) message_type: MessageType = MessageType.TEXT + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=11) class RcsImage(BaseRcs): """Model for an RCS image message. Args: - image (RcsResource): The image resource. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + image (RcsResource): The image resource. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ image: RcsResource @@ -73,13 +266,15 @@ class RcsVideo(BaseRcs): """Model for an RCS video message. Args: - video (RcsResource): The video resource. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + video (RcsResource): The video resource. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ video: RcsResource @@ -90,30 +285,134 @@ class RcsFile(BaseRcs): """Model for an RCS file message. Args: - file (RcsResource): The file resource. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + file (RcsResource): The file resource. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ file: RcsResource message_type: MessageType = MessageType.FILE +class RcsCard(BaseModel): + """Base model for the content of an RCS card. + + Args: + title (str): The title of the card. + text (str): The text of the card. + media_url (str): The media URL for the card. Can be an image or a video. + media_height (str, Optional): The height of the media on the card (SHORT, MEDIUM, TALL). + media_description (str, Optional): A description of the media for accessibility purposes. + thumbnail_url (str, Optional): The URL of the thumbnail image for the media. If not specified, the media URL will be used as the thumbnail. + media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. + suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. + """ + + title: str = Field(..., min_length=1, max_length=200) + text: str = Field(..., min_length=1, max_length=2000) + media_url: str + media_description: Optional[str] = None + media_height: Optional[RcsMediaHeight] = None + thumbnail_url: Optional[str] = None + media_force_refresh: Optional[bool] = None + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=4) + + +class RcsCardMessage(BaseRcs): + """Model for an RCS card message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + card (RcsCard): The content of the card. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptionsCard, Optional): An optional RcsOptionsCard object to include in the message. + """ + + card: RcsCard + rcs: Optional[RcsOptionsCard] = None + message_type: MessageType = MessageType.CARD + + +class RcsCarousel(BaseModel): + """Model for the content of an RCS carousel. + + Args: + cards (List[RcsCard]): A list of card items to include in the carousel. Can include up to 10 cards. + """ + + cards: List[RcsCard] = Field(..., min_length=2, max_length=10) + + +class RcsCarouselMessage(BaseRcs): + """Model for an RCS carousel message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + carousel (RcsCarousel): The content of the carousel. + suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptionsCarousel): An RcsOptionsCarousel object to include in the message. + """ + + carousel: RcsCarousel + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=11) + rcs: RcsOptionsCarousel + message_type: MessageType = MessageType.CAROUSEL + + class RcsCustom(BaseRcs): """Model for an RCS custom message. Args: - custom (dict): The custom message data. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + custom (dict): The custom message data. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ custom: dict diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index dd52541f..a37139db 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -21,11 +21,16 @@ class SmsOptions(BaseModel): entity_id (str, Optional): A string parameter that satisfies regulatory requirements when sending an SMS to specific countries. Not needed unless sending SMS in a country that requires a specific entity ID. + pool_id (str, Optional): The ID of the Number Pool to use as the sender of this message. + If specified, a number from the pool will be used as the from number. + The from number is still required even when specifying a pool_id and will be used as a fall-back if the number pool cannot be used. + See the Number Pools documentation for more information: https://developer.vonage.com/numbers/number-pools-api/overview. """ encoding_type: Optional[EncodingType] = None content_id: Optional[str] = None entity_id: Optional[str] = None + pool_id: Optional[str] = None class Sms(BaseMessage): @@ -38,6 +43,7 @@ class Sms(BaseMessage): Don't use a leading plus sign. text (str): The text of the message. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. sms (SmsOptions, Optional): SMS options. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. @@ -47,6 +53,7 @@ class Sms(BaseMessage): from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') text: str = Field(..., max_length=1000) ttl: Optional[int] = None + trusted_recipient: Optional[bool] = None sms: Optional[SmsOptions] = None channel: ChannelType = ChannelType.SMS message_type: MessageType = MessageType.TEXT diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index d4f4582e..a6b44980 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType +from .enums import ChannelType, MessageType, ReplyingIndicatorType class WhatsappContext(BaseModel): @@ -361,3 +361,18 @@ class WhatsappCustom(BaseWhatsapp): custom: Optional[dict] = None message_type: MessageType = MessageType.CUSTOM + + +class ReplyingIndicatorText(BaseModel): + """Model for the replying indicator of type `text` in a WhatsApp conversation. + + This is used to indicate activity within the WhatsApp UI that a message is being replied to. + + Args: + show (bool): Must be set to True to activate the replying indicator. If not included or set to False, the replying indicator will not be shown in the WhatsApp UI. + """ + + show: bool + type_: ReplyingIndicatorType = Field( + ReplyingIndicatorType.TEXT, serialization_alias='type' + ) diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index 5cde1bc0..b367b3e4 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -9,6 +9,7 @@ MessengerImage, MessengerOptions, MessengerResource, + ReplyingIndicatorText, SendMessageResponse, Sms, ) @@ -191,6 +192,30 @@ def test_mark_whatsapp_message_read_not_found(): assert e.value.response.json()['title'] == 'Not Found' +@responses.activate +def test_mark_whatsapp_message_read_with_replying_indicator(): + responses.add( + responses.PATCH, + 'https://api-eu.vonage.com/v1/messages/asdf', + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + messages.http_client.http_client_options.api_host = 'api-eu.vonage.com' + messages.mark_whatsapp_message_read( + message_uuid='asdf', + replying_indicator=ReplyingIndicatorText( + show=True, + ), + ) + + request_body = loads(responses.calls[0].request.body) + assert request_body == { + "status": "read", + "replying_indicator": {"show": True, "type": "text"}, + } + + @responses.activate def test_revoke_rcs_message(): responses.add( diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index c74d6994..72ca6059 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -1,7 +1,117 @@ -from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +import pytest +from pydantic import ValidationError +from vonage_messages.models import ( + MmsAudio, + MmsContent, + MmsContentItemAudio, + MmsContentItemFile, + MmsContentItemImage, + MmsContentItemVcard, + MmsContentItemVideo, + MmsFile, + MmsImage, + MmsResource, + MmsText, + MmsVcard, + MmsVideo, +) from vonage_messages.models.enums import WebhookVersion +def test_create_mms_resource(): + mms_resource = MmsResource( + url='https://example.com/resource', + ) + mms_resource_dict = { + 'url': 'https://example.com/resource', + } + + assert mms_resource.model_dump(exclude_none=True) == mms_resource_dict + + +def test_create_mms_resource_with_caption(): + mms_resource = MmsResource( + url='https://example.com/resource', + caption='Resource caption', + ) + mms_resource_dict = { + 'url': 'https://example.com/resource', + 'caption': 'Resource caption', + } + + assert mms_resource.model_dump(exclude_none=True) == mms_resource_dict + + +def test_create_mms_resource_without_url(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + caption='Resource caption', + ) + assert "Field required" in str(err.value) + + +def test_create_mms_resource_with_caption_too_short(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + url='https://example.com/resource', + caption='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_mms_resource_with_caption_too_long(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + url='https://example.com/resource', + caption='a' * 3001, + ) + assert "String should have at most 3000 characters" in str(err.value) + + +def test_create_mms_text(): + mms_model = MmsText( + to='1234567890', + from_='1234567890', + text='Hello, world!', + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, world!', + 'channel': 'mms', + 'message_type': 'text', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_text_all_fields(): + mms_model = MmsText( + to='1234567890', + from_='1234567890', + text='Hello, world!', + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, world!', + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'text', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + def test_create_mms_image(): mms_model = MmsImage( to='1234567890', @@ -35,6 +145,7 @@ def test_create_mms_image_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -47,6 +158,7 @@ def test_create_mms_image_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'image', } @@ -87,6 +199,7 @@ def test_create_mms_vcard_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -99,6 +212,7 @@ def test_create_mms_vcard_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'vcard', } @@ -139,6 +253,7 @@ def test_create_mms_audio_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -151,6 +266,7 @@ def test_create_mms_audio_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'audio', } @@ -191,6 +307,7 @@ def test_create_mms_video_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -203,8 +320,225 @@ def test_create_mms_video_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'video', } assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_file(): + mms_model = MmsFile( + to='1234567890', + from_='1234567890', + file=MmsResource( + url='https://example.com/file.pdf', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + }, + 'channel': 'mms', + 'message_type': 'file', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_file_all_fields(): + mms_model = MmsFile( + to='1234567890', + from_='1234567890', + file=MmsResource( + url='https://example.com/file.pdf', + caption='File caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'file', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_content(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + ], + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + ], + 'channel': 'mms', + 'message_type': 'content', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_content_all_fields(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + ], + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + ], + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'content', + } + + +def test_create_mms_content_all_content_types(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + MmsContentItemAudio( + url='https://example.com/audio.mp3', + caption='Audio caption', + ), + MmsContentItemVideo( + url='https://example.com/video.mp4', + caption='Video caption', + ), + MmsContentItemFile( + url='https://example.com/file.pdf', + caption='File caption', + ), + MmsContentItemVcard( + url='https://example.com/vcard.vcf', + caption='Vcard caption', + ), + ], + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + { + 'type': 'audio', + 'url': 'https://example.com/audio.mp3', + 'caption': 'Audio caption', + }, + { + 'type': 'video', + 'url': 'https://example.com/video.mp4', + 'caption': 'Video caption', + }, + { + 'type': 'file', + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + }, + { + 'type': 'vcard', + 'url': 'https://example.com/vcard.vcf', + 'caption': 'Vcard caption', + }, + ], + 'channel': 'mms', + 'message_type': 'content', + } + + +def test_create_mms_content_with_invalid_content_item(): + with pytest.raises(ValidationError) as err: + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsResource( + url='https://example.com/resource', + ), + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_mms_with_ttl_too_low(): + with pytest.raises(ValidationError) as err: + MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource(url='https://example.com/image.jpg'), + ttl=299, + ) + assert 'greater than or equal to 300' in str(err.value) + + +def test_create_mms_with_ttl_too_high(): + with pytest.raises(ValidationError) as err: + MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource(url='https://example.com/image.jpg'), + ttl=259201, + ) + assert 'less than or equal to 259200' in str(err.value) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 4114bcad..30dc0a03 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1,8 +1,25 @@ +import pytest +from pydantic import ValidationError from vonage_messages.models import ( + RcsCard, + RcsCardMessage, + RcsCarousel, + RcsCarouselMessage, RcsCustom, RcsFile, RcsImage, + RcsOptions, + RcsOptionsCard, + RcsOptionsCarousel, RcsResource, + RcsSuggestionActionCreateCalendarEvent, + RcsSuggestionActionDial, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionShareLocation, + RcsSuggestionActionViewLocation, + RcsSuggestionBase, + RcsSuggestionReply, RcsText, RcsVideo, ) @@ -54,6 +71,10 @@ def test_create_rcs_text_all_fields(): client_ref='client-ref', webhook_url='https://example.com', ttl=600, + trusted_recipient=True, + rcs=RcsOptions( + category='transaction', + ), ) rcs_dict = { 'to': '1234567890', @@ -62,6 +83,51 @@ def test_create_rcs_text_all_fields(): 'client_ref': 'client-ref', 'webhook_url': 'https://example.com', 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'rcs', + 'message_type': 'text', + 'rcs': { + 'category': 'transaction', + }, + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_with_suggestions(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], 'channel': 'rcs', 'message_type': 'text', } @@ -69,6 +135,165 @@ def test_create_rcs_text_all_fields(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict +def test_create_rcs_text_with_all_suggestion_types(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + }, + ], + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_text_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] + * 12, + ) + assert "List should have at most 11 items" in str(err.value) + + +def test_create_rcs_text_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + def test_create_rcs_image(): rcs_model = RcsImage( to='1234567890', @@ -132,6 +357,875 @@ def test_create_rcs_file(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict +def test_create_rcs_card(): + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ) + card_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_optional_params(): + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + card_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_suggestions(): + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + card_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_all_suggestion_types(): + card_1 = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + ], + ) + card_2 = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + card_dict_1 = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + ], + } + card_dict_2 = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + }, + ], + } + assert card_1.model_dump(by_alias=True, exclude_none=True) == card_dict_1 + assert card_2.model_dump(by_alias=True, exclude_none=True) == card_dict_2 + + +def test_create_rcs_card_without_title(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_without_text(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_without_media_url(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_with_title_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_with_title_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='A' * 200 + 'B', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 200 characters" in str(err.value) + + +def test_create_rcs_card_with_text_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_with_text_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='A' * 2000 + 'B', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 2000 characters" in str(err.value) + + +def test_create_rcs_card_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_card_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] + * 5, + ) + assert "List should have at most 4 items" in str(err.value) + + +def test_create_rcs_card_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_card_message(): + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + ) + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'card': { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + }, + 'channel': 'rcs', + 'message_type': 'card', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_message_with_optional_params(): + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + rcs=RcsOptionsCard( + card_orientation='VERTICAL', + image_alignment='LEFT', + ), + ) + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'card': { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + }, + 'rcs': { + 'card_orientation': 'VERTICAL', + 'image_alignment': 'LEFT', + }, + 'channel': 'rcs', + 'message_type': 'card', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_message_without_card(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_carousel(): + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ) + carousel_dict = { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_with_insufficient_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ], + ) + assert "List should have at least 2 items" in str(err.value) + + +def test_create_rcs_carousel_with_too_many_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 11, + ) + assert "List should have at most 10 items" in str(err.value) + + +def test_create_rcs_carousel_with_invalid_card_type(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ), + RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + ), + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_carousel_message(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_optional_params(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + ] + * 2, + ), + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + ] + * 2, + }, + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_suggestions(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_all_suggestion_types(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + }, + ], + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_without_carousel(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_carousel_message_without_rcs_options(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_carousel_message_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_carousel_message_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] + * 12, + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at most 11 items" in str(err.value) + + +def test_create_rcs_carousel_message_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + def test_create_rcs_custom(): rcs_model = RcsCustom( to='1234567890', @@ -147,3 +1241,618 @@ def test_create_rcs_custom(): } assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_rcs_suggestion_base(): + suggestion = RcsSuggestionBase( + text='Reply', + postback_data='postback-data', + ) + suggestion_dict = { + 'text': 'Reply', + 'postback_data': 'postback-data', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_base_without_text(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + postback_data='postback-data', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_base_without_postback_data(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='Reply', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_base_with_text_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='', + postback_data='postback-data', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_base_with_text_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='A' * 25 + 'B', + postback_data='postback-data', + ) + assert "String should have at most 25 characters" in str(err.value) + + +def test_rcs_suggestion_reply(): + suggestion = RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ) + suggestion_dict = { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_dial(): + suggestion = RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + fallback_url='https://example.com/dial', + ) + suggestion_dict = { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + 'fallback_url': 'https://example.com/dial', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_dial_without_phone_number(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location(): + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ) + suggestion_dict = { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_view_location_without_latitude(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location_without_longitude(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + pin_label='London', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location_without_pin_label(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_share_location(): + suggestion = RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ) + suggestion_dict = { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url(): + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ) + suggestion_dict = { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url_without_url(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + description='Click to open the URL', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_open_url_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview(): + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + view_mode='FULL', + ) + suggestion_dict = { + 'type': 'open_url_in_webview', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + 'view_mode': 'FULL', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url_in_webview_without_url(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + description='Click to open the URL', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_with_invalid_view_mode(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + view_mode='INVALID_VIEW_MODE', + ) + assert "Input should be 'FULL', 'TALL' or 'HALF'" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event(): + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ) + suggestion_dict = { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_create_calendar_event_without_start_time(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_end_time(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_title(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_title_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='', + description='Discuss project updates', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_title_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='A' * 100 + 'B', + description='Discuss project updates', + ) + assert "String should have at most 100 characters" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) + + +def test_create_rcs_options(): + options = RcsOptions( + category='transaction', + ) + options_dict = { + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_with_each_valid_category(): + valid_options = [ + 'acknowledgement', + 'authentication', + 'promotion', + 'service-request', + 'transaction', + ] + for option in valid_options: + options = RcsOptions( + category=option, + ) + options_dict = { + 'category': option, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_with_invalid_category(): + with pytest.raises(ValidationError) as err: + options = RcsOptions( + category='invalid-category', + ) + assert ( + "Input should be 'acknowledgement', 'authentication', 'promotion', 'service-request' or 'transaction'" + in str(err.value) + ) + + +def test_create_rcs_options_card(): + options = RcsOptionsCard(card_orientation='HORIZONTAL', image_alignment='LEFT') + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': 'LEFT', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_with_all_options(): + options = RcsOptionsCard( + card_orientation='HORIZONTAL', + image_alignment='LEFT', + category='transaction', + ) + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': 'LEFT', + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_card_orientation_with_each_valid_option(): + valid_orientations = ['VERTICAL', 'HORIZONTAL'] + for orientation in valid_orientations: + options = RcsOptionsCard(card_orientation=orientation, image_alignment='LEFT') + options_dict = { + 'card_orientation': orientation, + 'image_alignment': 'LEFT', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_image_alignment_with_each_valid_option(): + valid_alignments = ['LEFT', 'RIGHT'] + for alignment in valid_alignments: + options = RcsOptionsCard(card_orientation='HORIZONTAL', image_alignment=alignment) + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': alignment, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_card_orientation_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard( + card_orientation='INVALID_ORIENTATION', image_alignment='LEFT' + ) + assert "Input should be 'VERTICAL' or 'HORIZONTAL'" in str(err.value) + + +def test_create_rcs_options_card_image_alignment_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard( + card_orientation='HORIZONTAL', image_alignment='INVALID_ALIGNMENT' + ) + assert "Input should be 'LEFT' or 'RIGHT'" in str(err.value) + + +def test_create_rcs_options_carousel(): + options = RcsOptionsCarousel( + card_width='MEDIUM', + ) + options_dict = { + 'card_width': 'MEDIUM', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_with_all_options(): + options = RcsOptionsCarousel( + card_width='MEDIUM', + category='transaction', + ) + options_dict = { + 'card_width': 'MEDIUM', + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_without_card_width(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCarousel( + category='transaction', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_options_carousel_card_width_with_each_valid_option(): + valid_widths = ['SMALL', 'MEDIUM'] + for width in valid_widths: + options = RcsOptionsCarousel( + card_width=width, + ) + options_dict = { + 'card_width': width, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_card_width_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCarousel( + card_width='INVALID_WIDTH', + ) + assert "Input should be 'SMALL' or 'MEDIUM'" in str(err.value) + + +def test_create_rcs_text_too_short(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='', + ) + assert 'String should have at least 1 character' in str(err.value) + + +def test_create_rcs_text_too_long(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='a' * 3073, + ) + assert 'String should have at most 3072 characters' in str(err.value) + + +def test_create_rcs_with_ttl_too_low(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ttl=19, + ) + assert 'greater than or equal to 20' in str(err.value) + + +def test_create_rcs_with_ttl_too_high(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ttl=259201, + ) + assert 'less than or equal to 259200' in str(err.value) + + +def test_create_rcs_with_invalid_from_field(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='invalid from!', + text='Hello, World!', + ) + assert 'String should match pattern' in str(err.value) diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index 49b19771..4068f1ec 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -1,3 +1,5 @@ +import pytest +from pydantic import ValidationError from vonage_messages.models import Sms, SmsOptions from vonage_messages.models.enums import EncodingType, WebhookVersion @@ -28,11 +30,13 @@ def test_create_sms_all_fields(): encoding_type=EncodingType.TEXT, content_id='content-id', entity_id='entity-id', + pool_id='abc123', ), client_ref='client-ref', webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) sms_dict = { 'to': '1234567890', @@ -42,13 +46,36 @@ def test_create_sms_all_fields(): 'encoding_type': 'text', 'content_id': 'content-id', 'entity_id': 'entity-id', + 'pool_id': 'abc123', }, 'client_ref': 'client-ref', 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'sms', 'message_type': 'text', } assert sms_model.model_dump(by_alias=True) == sms_dict + + +def test_create_sms_text_too_long(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='a' * 1001, + ) + assert 'String should have at most 1000 characters' in str(err.value) + + +def test_create_sms_with_invalid_encoding_type(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + sms=SmsOptions(encoding_type='invalid'), + ) + assert 'Input should be' in str(err.value) diff --git a/messages/tests/test_whatsapp_models.py b/messages/tests/test_whatsapp_models.py index 6967d9dc..c0ae7e58 100644 --- a/messages/tests/test_whatsapp_models.py +++ b/messages/tests/test_whatsapp_models.py @@ -1,6 +1,9 @@ from copy import deepcopy +import pytest +from pydantic import ValidationError from vonage_messages.models import ( + ReplyingIndicatorText, WhatsappAudio, WhatsappAudioResource, WhatsappContext, @@ -382,3 +385,66 @@ def test_whatsapp_custom_all_fields(): } assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_create_replying_indicator(): + whatsapp_model = ReplyingIndicatorText( + show=True, + ) + whatsapp_dict = { + 'show': True, + 'type': 'text', + } + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_text_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappText( + to='1234567890', + from_='1234567890', + text='a' * 4097, + ) + assert 'String should have at most 4096 characters' in str(err.value) + + +def test_whatsapp_audio_url_too_short(): + with pytest.raises(ValidationError) as err: + WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='short'), + ) + assert 'String should have at least 10 characters' in str(err.value) + + +def test_whatsapp_audio_url_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='https://' + 'a' * 2000), + ) + assert 'String should have at most 2000 characters' in str(err.value) + + +def test_whatsapp_image_caption_too_short(): + with pytest.raises(ValidationError) as err: + WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource(url='https://example.com/image.jpg', caption=''), + ) + assert 'String should have at least 1 character' in str(err.value) + + +def test_whatsapp_image_caption_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource( + url='https://example.com/image.jpg', caption='a' * 3001 + ), + ) + assert 'String should have at most 3000 characters' in str(err.value)