diff --git a/examples/README.md b/examples/README.md
index 6942792..810fed8 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -6,6 +6,7 @@ This directory contains comprehensive examples for using the Devo Global Communi
### π Overview
- **`basic_usage.py`** - Interactive overview and launcher for all examples
+- **`omni_channel_example.py`** - β
**Complete Omni-channel Messaging** - Unified API for all channels
### π± Communication Resources
- **`sms_example.py`** - β
**Complete SMS API implementation**
@@ -14,9 +15,14 @@ This directory contains comprehensive examples for using the Devo Global Communi
- Search and purchase phone numbers
- Legacy compatibility methods
+- **`rcs_example.py`** - β
**Complete RCS API implementation**
+ - Account management and messaging operations
+ - Template and brand management
+ - Tester management and capability testing
+ - Rich messaging features (cards, carousels, suggestions)
+
- **`email_example.py`** - π§ **Placeholder** (Email functionality)
- **`whatsapp_example.py`** - π§ **Placeholder** (WhatsApp functionality)
-- **`rcs_example.py`** - π§ **Placeholder** (RCS functionality)
### π₯ Management Resources
- **`contacts_example.py`** - π§ **Placeholder** (Contact management)
@@ -47,14 +53,62 @@ This provides an interactive menu to choose and run specific examples.
#### Option 2: Run Individual Examples
```bash
+# Omni-channel messaging (fully implemented)
+python examples/omni_channel_example.py
+
# SMS functionality (fully implemented)
python examples/sms_example.py
+# RCS functionality (fully implemented)
+python examples/rcs_example.py
+
# Other resources (placeholder examples)
python examples/email_example.py
python examples/whatsapp_example.py
python examples/contacts_example.py
-python examples/rcs_example.py
+```
+
+## π Omni-channel Messaging Examples (Fully Implemented)
+
+The unified messaging resource provides a single API endpoint to send messages through any channel:
+
+### π§ Available Functions
+1. **Send Message** - `client.messages.send()`
+ - Uses POST `/api/v1/user-api/messages/send`
+ - Supports SMS, Email, WhatsApp, and RCS channels
+ - Channel-specific payload flexibility
+ - Unified response format
+
+### π‘ Key Features
+- **Unified Interface**: One endpoint for all channels
+- **Channel-specific Payloads**: Flexible payload structure for each channel
+- **Type Safety**: Full Pydantic model validation
+- **Metadata Support**: Custom metadata and webhook URLs
+- **Bulk Messaging**: Easy iteration across multiple channels
+
+### π Example Usage
+```python
+from devo_global_comms_python.models.messages import SendMessageDto
+
+# Send SMS
+sms_data = SendMessageDto(
+ channel="sms",
+ to="+1234567890",
+ payload={"text": "Hello World"}
+)
+result = client.messages.send(sms_data)
+
+# Send Email
+email_data = SendMessageDto(
+ channel="email",
+ to="user@example.com",
+ payload={
+ "subject": "Test Email",
+ "text": "Hello World",
+ "html": "
Hello World
"
+ }
+)
+result = client.messages.send(email_data)
```
## π± SMS Examples (Fully Implemented)
diff --git a/examples/omni_channel_example.py b/examples/omni_channel_example.py
new file mode 100644
index 0000000..23dbaa8
--- /dev/null
+++ b/examples/omni_channel_example.py
@@ -0,0 +1,442 @@
+import os
+from datetime import datetime
+
+from devo_global_comms_python import DevoClient
+from devo_global_comms_python.models.messages import SendMessageDto
+
+
+def main():
+ """
+ Demonstrate omni-channel messaging capabilities.
+
+ Shows how to send messages through different channels using
+ the unified messages.send() endpoint with channel-specific payloads.
+ """
+
+ # Initialize the client
+ api_key = os.getenv("DEVO_API_KEY")
+ if not api_key:
+ print("β Error: DEVO_API_KEY environment variable not set")
+ return
+
+ client = DevoClient(api_key=api_key)
+
+ print("π Devo Global Communications - Omni-channel Messaging Example")
+ print("=" * 70)
+
+ # Example 1: Send SMS Message
+ print("\nπ± Sending SMS Message...")
+ try:
+ sms_message = SendMessageDto(
+ channel="sms",
+ to="+1234567890",
+ **{"from": "+0987654321"}, # Use dict unpacking for 'from' field
+ payload={"text": "Hello from Devo! This is an SMS message sent via omni-channel API."},
+ callback_url="https://example.com/sms-webhook",
+ metadata={"campaign": "omni-demo", "type": "sms"},
+ )
+
+ sms_result = client.messages.send(sms_message)
+ print("β
SMS sent successfully!")
+ print(f" Message ID: {sms_result.id}")
+ print(f" Status: {sms_result.status}")
+ print(f" Channel: {sms_result.channel}")
+ print(f" Created: {sms_result.created_at}")
+
+ except Exception as e:
+ print(f"β SMS Error: {str(e)}")
+
+ # Example 2: Send Email Message
+ print("\nπ§ Sending Email Message...")
+ try:
+ email_message = SendMessageDto(
+ channel="email",
+ to="recipient@example.com",
+ **{"from": "sender@yourcompany.com"}, # Use dict unpacking for 'from' field
+ payload={
+ "subject": "Welcome to Devo Global Communications!",
+ "text": "Hello! This is a plain text email sent via our omni-channel API.",
+ "html": """
+
+
+ Welcome to Devo Global Communications!
+ This is an HTML email sent via our omni-channel API.
+ Key features:
+
+ - Unified API for all channels
+ - Channel-specific payloads
+ - Real-time status tracking
+
+ Best regards,
The Devo Team
+
+
+ """,
+ "attachments": [
+ {
+ "filename": "welcome.pdf",
+ "content_type": "application/pdf",
+ "url": "https://example.com/files/welcome.pdf",
+ }
+ ],
+ },
+ callback_url="https://example.com/email-webhook",
+ metadata={"campaign": "omni-demo", "type": "email"},
+ )
+
+ email_result = client.messages.send(email_message)
+ print("β
Email sent successfully!")
+ print(f" Message ID: {email_result.id}")
+ print(f" Status: {email_result.status}")
+ print(f" Channel: {email_result.channel}")
+ print(f" Subject: {email_result.content.get('subject', 'N/A')}")
+
+ except Exception as e:
+ print(f"β Email Error: {str(e)}")
+
+ # Example 3: Send WhatsApp Message
+ print("\n㪠Sending WhatsApp Message...")
+ try:
+ whatsapp_message = SendMessageDto(
+ channel="whatsapp",
+ to="+1234567890",
+ payload={
+ "type": "text",
+ "text": {
+ "body": (
+ "π Hello from Devo! This WhatsApp message was sent "
+ "using our omni-channel API. Pretty cool, right?"
+ )
+ },
+ },
+ callback_url="https://example.com/whatsapp-webhook",
+ metadata={"campaign": "omni-demo", "type": "whatsapp"},
+ )
+
+ whatsapp_result = client.messages.send(whatsapp_message)
+ print("β
WhatsApp message sent successfully!")
+ print(f" Message ID: {whatsapp_result.id}")
+ print(f" Status: {whatsapp_result.status}")
+ print(f" Channel: {whatsapp_result.channel}")
+
+ except Exception as e:
+ print(f"β WhatsApp Error: {str(e)}")
+
+ # Example 4: Send WhatsApp Template Message
+ print("\nπ Sending WhatsApp Template Message...")
+ try:
+ whatsapp_template = SendMessageDto(
+ channel="whatsapp",
+ to="+1234567890",
+ payload={
+ "type": "template",
+ "template": {
+ "name": "welcome_message",
+ "language": {"code": "en"},
+ "components": [
+ {
+ "type": "body",
+ "parameters": [
+ {"type": "text", "text": "John Doe"},
+ {
+ "type": "text",
+ "text": "Devo Communications",
+ },
+ ],
+ }
+ ],
+ },
+ },
+ metadata={"campaign": "omni-demo", "type": "whatsapp-template"},
+ )
+
+ template_result = client.messages.send(whatsapp_template)
+ print("β
WhatsApp template sent successfully!")
+ print(f" Message ID: {template_result.id}")
+ print(f" Status: {template_result.status}")
+
+ except Exception as e:
+ print(f"β WhatsApp Template Error: {str(e)}")
+
+ # Example 5: Send RCS Message
+ print("\nπ Sending RCS Message...")
+ try:
+ rcs_message = SendMessageDto(
+ channel="rcs",
+ to="+1234567890",
+ payload={
+ "message_type": "text",
+ "text": (
+ "Hello from Devo! This is a Rich Communication Services " "(RCS) message with enhanced features."
+ ),
+ "agent_id": "your-rcs-agent-id",
+ "suggestions": [
+ {
+ "reply": {
+ "text": "Learn More",
+ "postback_data": "learn_more",
+ }
+ },
+ {
+ "action": {
+ "text": "Visit Website",
+ "postback_data": "visit_website",
+ "open_url": {"url": "https://devo.com"},
+ }
+ },
+ ],
+ },
+ callback_url="https://example.com/rcs-webhook",
+ metadata={"campaign": "omni-demo", "type": "rcs"},
+ )
+
+ rcs_result = client.messages.send(rcs_message)
+ print("β
RCS message sent successfully!")
+ print(f" Message ID: {rcs_result.id}")
+ print(f" Status: {rcs_result.status}")
+ print(f" Channel: {rcs_result.channel}")
+
+ except Exception as e:
+ print(f"β RCS Error: {str(e)}")
+
+ # Example 6: Send RCS Rich Card
+ print("\nπ΄ Sending RCS Rich Card...")
+ try:
+ rcs_rich_card = SendMessageDto(
+ channel="rcs",
+ to="+1234567890",
+ payload={
+ "message_type": "rich_card",
+ "rich_card": {
+ "standalone_card": {
+ "card_content": {
+ "title": "Devo Global Communications",
+ "description": "Experience the power of omni-channel messaging with our unified API.",
+ "media": {
+ "height": "TALL",
+ "content_info": {
+ "file_url": "https://example.com/images/devo-card.jpg",
+ "force_refresh": False,
+ },
+ },
+ },
+ "card_actions": [
+ {
+ "action_type": "open_url",
+ "action_data": "https://devo.com",
+ "label": "Learn More",
+ },
+ {
+ "action_type": "dial",
+ "action_data": "+1234567890",
+ "label": "Call Us",
+ },
+ ],
+ }
+ },
+ },
+ metadata={"campaign": "omni-demo", "type": "rcs-rich-card"},
+ )
+
+ rich_card_result = client.messages.send(rcs_rich_card)
+ print("β
RCS rich card sent successfully!")
+ print(f" Message ID: {rich_card_result.id}")
+ print(f" Status: {rich_card_result.status}")
+
+ except Exception as e:
+ print(f"β RCS Rich Card Error: {str(e)}")
+
+ # Example 7: Bulk messaging across channels
+ print("\nπ Bulk Messaging Demo...")
+ try:
+ recipients = [
+ {
+ "channel": "sms",
+ "to": "+1234567890",
+ "message": "SMS bulk message",
+ },
+ {
+ "channel": "email",
+ "to": "user1@example.com",
+ "message": "Email bulk message",
+ },
+ {
+ "channel": "whatsapp",
+ "to": "+1234567891",
+ "message": "WhatsApp bulk message",
+ },
+ ]
+
+ bulk_results = []
+
+ for recipient in recipients:
+ if recipient["channel"] == "sms":
+ payload = {"text": recipient["message"]}
+ elif recipient["channel"] == "email":
+ payload = {
+ "subject": "Bulk Message from Devo",
+ "text": recipient["message"],
+ "html": f"{recipient['message']}
",
+ }
+ elif recipient["channel"] == "whatsapp":
+ payload = {
+ "type": "text",
+ "text": {"body": recipient["message"]},
+ }
+
+ bulk_message = SendMessageDto(
+ channel=recipient["channel"],
+ to=recipient["to"],
+ payload=payload,
+ metadata={"campaign": "bulk-demo", "batch_id": "batch_001"},
+ )
+
+ result = client.messages.send(bulk_message)
+ bulk_results.append(result)
+ print(f" β
{recipient['channel'].upper()}: {result.id} -> {result.status}")
+
+ print(f"π Bulk messaging completed! Sent {len(bulk_results)} messages")
+
+ except Exception as e:
+ print(f"β Bulk Messaging Error: {str(e)}")
+
+ print("\n" + "=" * 70)
+ print("π― Omni-channel messaging demo completed!")
+ print("\nKey Benefits:")
+ print("β’ Unified API for all communication channels")
+ print("β’ Channel-specific payload flexibility")
+ print("β’ Consistent response format")
+ print("β’ Real-time status tracking")
+ print("β’ Metadata and webhook support")
+ print("β’ Type-safe models with validation")
+
+
+def send_notification_example():
+ """
+ Example of a practical notification system using omni-channel messaging.
+
+ This demonstrates how you might implement a notification service that
+ sends the same message through different channels based on user preferences.
+ """
+
+ print("\n" + "=" * 50)
+ print("π’ Notification System Example")
+ print("=" * 50)
+
+ # Simulated user preferences
+ users = [
+ {
+ "name": "Alice",
+ "channel": "email",
+ "contact": "alice@example.com",
+ "language": "en",
+ },
+ {
+ "name": "Bob",
+ "channel": "sms",
+ "contact": "+1234567890",
+ "language": "en",
+ },
+ {
+ "name": "Carlos",
+ "channel": "whatsapp",
+ "contact": "+1234567891",
+ "language": "es",
+ },
+ {
+ "name": "Diana",
+ "channel": "rcs",
+ "contact": "+1234567892",
+ "language": "en",
+ },
+ ]
+
+ # Common notification content
+ notification = {
+ "en": {
+ "title": "System Maintenance Notice",
+ "message": (
+ "Our system will undergo maintenance on Sunday, 2:00 AM - 4:00 AM EST. "
+ "Services may be temporarily unavailable."
+ ),
+ },
+ "es": {
+ "title": "Aviso de Mantenimiento del Sistema",
+ "message": (
+ "Nuestro sistema se someterΓ‘ a mantenimiento el domingo de 2:00 AM a 4:00 AM EST. "
+ "Los servicios pueden no estar disponibles temporalmente."
+ ),
+ },
+ }
+
+ api_key = os.getenv("DEVO_API_KEY")
+ if not api_key:
+ print("β Error: DEVO_API_KEY environment variable not set")
+ return
+
+ client = DevoClient(api_key=api_key)
+
+ for user in users:
+ try:
+ content = notification[user["language"]]
+
+ # Create channel-specific payload
+ if user["channel"] == "email":
+ payload = {
+ "subject": content["title"],
+ "text": content["message"],
+ "html": f"""
+
+
+ {content["title"]}
+ {content["message"]}
+ This is an automated notification.
+
+
+ """,
+ }
+ elif user["channel"] == "sms":
+ payload = {"text": f"{content['title']}: {content['message']}"}
+ elif user["channel"] == "whatsapp":
+ payload = {
+ "type": "text",
+ "text": {"body": f"*{content['title']}*\n\n{content['message']}"},
+ }
+ elif user["channel"] == "rcs":
+ payload = {
+ "message_type": "text",
+ "text": f"{content['title']}\n\n{content['message']}",
+ "suggestions": [
+ {
+ "reply": {
+ "text": "Acknowledged",
+ "postback_data": "maintenance_ack",
+ }
+ }
+ ],
+ }
+
+ # Send notification
+ message = SendMessageDto(
+ channel=user["channel"],
+ to=user["contact"],
+ payload=payload,
+ metadata={
+ "notification_type": "maintenance",
+ "user_name": user["name"],
+ "language": user["language"],
+ "timestamp": datetime.now().isoformat(),
+ },
+ )
+
+ result = client.messages.send(message)
+ print(f"β
{user['name']} ({user['channel']}): {result.id} -> {result.status}")
+
+ except Exception as e:
+ print(f"β Failed to notify {user['name']}: {str(e)}")
+
+ print("\nπ Notification broadcast completed!")
+
+
+if __name__ == "__main__":
+ main()
+ send_notification_example()
diff --git a/src/devo_global_comms_python/models/__init__.py b/src/devo_global_comms_python/models/__init__.py
index 7e092d4..94a4502 100644
--- a/src/devo_global_comms_python/models/__init__.py
+++ b/src/devo_global_comms_python/models/__init__.py
@@ -1,6 +1,6 @@
from .contacts import Contact
from .email import EmailMessage
-from .messages import Message
+from .messages import Message, SendMessageDto, SendMessageSerializer
from .rcs import RCSMessage
from .sms import (
AvailableNumbersResponse,
@@ -26,6 +26,9 @@
"RCSMessage",
"Contact",
"Message",
+ # Omni-channel messaging models
+ "SendMessageDto",
+ "SendMessageSerializer",
# New SMS API models
"SMSQuickSendRequest",
"SMSQuickSendResponse",
diff --git a/src/devo_global_comms_python/models/messages.py b/src/devo_global_comms_python/models/messages.py
index cc3fb87..ce219bc 100644
--- a/src/devo_global_comms_python/models/messages.py
+++ b/src/devo_global_comms_python/models/messages.py
@@ -1,9 +1,57 @@
from datetime import datetime
-from typing import Any, Dict, Optional, Union
+from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, Field
+# Omni-channel send message models
+class SendMessageDto(BaseModel):
+ """
+ Unified send message DTO for omni-channel messaging.
+
+ Allows sending messages through any channel (SMS, Email, WhatsApp, RCS)
+ with channel-specific payloads.
+ """
+
+ channel: Literal["sms", "email", "whatsapp", "rcs"] = Field(..., description="Communication channel to use")
+ to: str = Field(..., description="Recipient identifier (phone/email)")
+ from_: Optional[str] = Field(None, alias="from", description="Sender identifier")
+
+ # Channel-specific payload
+ payload: Dict[str, Any] = Field(..., description="Channel-specific message payload")
+
+ # Common optional fields
+ callback_url: Optional[str] = Field(None, description="Webhook URL for status updates")
+ metadata: Optional[Dict[str, Any]] = Field(None, description="Custom metadata")
+
+ class Config:
+ populate_by_name = True
+
+
+class SendMessageSerializer(BaseModel):
+ """
+ Response serializer for omni-channel message sending.
+ """
+
+ id: str = Field(..., description="Message ID")
+ channel: str = Field(..., description="Channel used for sending")
+ to: str = Field(..., description="Recipient")
+ from_: Optional[str] = Field(None, alias="from", description="Sender")
+ status: str = Field(..., description="Message status")
+ direction: str = Field(..., description="Message direction")
+
+ # Channel-specific content
+ content: Dict[str, Any] = Field(..., description="Message content")
+
+ # Tracking and metadata
+ pricing: Optional[Dict[str, Any]] = Field(None, description="Pricing information")
+ created_at: datetime = Field(..., description="Creation timestamp")
+ metadata: Optional[Dict[str, Any]] = Field(None, description="Custom metadata")
+
+ class Config:
+ populate_by_name = True
+
+
class Message(BaseModel):
"""
Unified message model.
@@ -14,9 +62,7 @@ class Message(BaseModel):
id: str = Field(..., description="Unique identifier for the message")
account_id: Optional[str] = Field(None, description="Account identifier")
- channel: str = Field(
- ..., description="Communication channel (sms, email, whatsapp, rcs)"
- )
+ channel: str = Field(..., description="Communication channel (sms, email, whatsapp, rcs)")
type: str = Field(..., description="Message type")
# Recipient/sender information
@@ -24,18 +70,14 @@ class Message(BaseModel):
from_: Optional[str] = Field(None, alias="from", description="Sender identifier")
# Message content (varies by channel)
- content: Dict[str, Any] = Field(
- ..., description="Message content (channel-specific)"
- )
+ content: Dict[str, Any] = Field(..., description="Message content (channel-specific)")
# Status and delivery
status: str = Field(..., description="Message status")
direction: str = Field(..., description="Message direction (inbound/outbound)")
# Delivery tracking
- delivery_status: Optional[Dict[str, Any]] = Field(
- None, description="Detailed delivery status"
- )
+ delivery_status: Optional[Dict[str, Any]] = Field(None, description="Detailed delivery status")
# Pricing and billing
pricing: Optional[Dict[str, Any]] = Field(None, description="Pricing information")
@@ -45,17 +87,11 @@ class Message(BaseModel):
error_message: Optional[str] = Field(None, description="Error message if failed")
# Timestamps
- date_created: Optional[datetime] = Field(
- None, description="Message creation timestamp"
- )
+ date_created: Optional[datetime] = Field(None, description="Message creation timestamp")
date_sent: Optional[datetime] = Field(None, description="Message sent timestamp")
- date_delivered: Optional[datetime] = Field(
- None, description="Message delivered timestamp"
- )
+ date_delivered: Optional[datetime] = Field(None, description="Message delivered timestamp")
date_read: Optional[datetime] = Field(None, description="Message read timestamp")
- date_updated: Optional[datetime] = Field(
- None, description="Message last updated timestamp"
- )
+ date_updated: Optional[datetime] = Field(None, description="Message last updated timestamp")
# Metadata
metadata: Optional[Dict[str, Any]] = Field(None, description="Custom metadata")
diff --git a/src/devo_global_comms_python/resources/messages.py b/src/devo_global_comms_python/resources/messages.py
index 1169e4c..221e9e9 100644
--- a/src/devo_global_comms_python/resources/messages.py
+++ b/src/devo_global_comms_python/resources/messages.py
@@ -1,20 +1,83 @@
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
-from ..utils import validate_required_string
+from ..utils import validate_required_string, validate_response
from .base import BaseResource
if TYPE_CHECKING:
- from ..models.messages import Message
+ from ..models.messages import Message, SendMessageDto, SendMessageSerializer
class MessagesResource(BaseResource):
"""
Unified messages resource for managing messages across all channels.
- This resource provides a unified interface to view and manage messages
- sent through any channel (SMS, Email, WhatsApp, RCS).
+ This resource provides a unified interface to send, view and manage messages
+ across any channel (SMS, Email, WhatsApp, RCS).
"""
+ def send(self, data: "SendMessageDto") -> "SendMessageSerializer":
+ """
+ Send a message through any channel (omni-channel endpoint).
+
+ This unified endpoint allows sending messages through SMS, Email,
+ WhatsApp, or RCS channels using channel-specific payloads.
+
+ Args:
+ data: SendMessageDto containing channel, recipient, and payload
+
+ Returns:
+ SendMessageSerializer with sent message details
+
+ Example:
+ # Send SMS
+ from ..models.messages import SendMessageDto
+
+ sms_data = SendMessageDto(
+ channel="sms",
+ to="+1234567890",
+ payload={"text": "Hello World"}
+ )
+ message = client.messages.send(sms_data)
+
+ # Send Email
+ email_data = SendMessageDto(
+ channel="email",
+ to="user@example.com",
+ payload={
+ "subject": "Test Email",
+ "text": "Hello World",
+ "html": "Hello World
"
+ }
+ )
+ message = client.messages.send(email_data)
+
+ # Send WhatsApp
+ whatsapp_data = SendMessageDto(
+ channel="whatsapp",
+ to="+1234567890",
+ payload={
+ "type": "text",
+ "text": {"body": "Hello World"}
+ }
+ )
+ message = client.messages.send(whatsapp_data)
+
+ # Send RCS
+ rcs_data = SendMessageDto(
+ channel="rcs",
+ to="+1234567890",
+ payload={
+ "message_type": "text",
+ "text": "Hello World"
+ }
+ )
+ message = client.messages.send(rcs_data)
+ """
+ from ..models.messages import SendMessageSerializer
+
+ response = self.client.post("messages/send", data=data.model_dump(by_alias=True, exclude_none=True))
+ return validate_response(response, SendMessageSerializer)
+
def get(self, message_id: str) -> "Message":
"""
Retrieve a message by ID from any channel.
diff --git a/src/devo_global_comms_python/utils.py b/src/devo_global_comms_python/utils.py
index ec30454..4c4d38e 100644
--- a/src/devo_global_comms_python/utils.py
+++ b/src/devo_global_comms_python/utils.py
@@ -1,11 +1,11 @@
import re
-from typing import Optional
+from typing import Optional, Type, TypeVar
-from .exceptions import (
- DevoInvalidEmailException,
- DevoInvalidPhoneNumberException,
- DevoValidationException,
-)
+from pydantic import BaseModel
+
+from .exceptions import DevoInvalidEmailException, DevoInvalidPhoneNumberException, DevoValidationException
+
+T = TypeVar("T", bound=BaseModel)
def validate_phone_number(phone_number: str) -> str:
@@ -29,9 +29,7 @@ def validate_phone_number(phone_number: str) -> str:
# Check if it starts with + and has digits
if not re.match(r"^\+\d{10,15}$", cleaned):
- raise DevoInvalidPhoneNumberException(
- "Phone number must be in E.164 format (e.g., +1234567890)"
- )
+ raise DevoInvalidPhoneNumberException("Phone number must be in E.164 format (e.g., +1234567890)")
return cleaned
@@ -108,6 +106,27 @@ def format_datetime(dt) -> str:
return dt.isoformat()
+def validate_response(response, model_class: Type[T]) -> T:
+ """
+ Validate and parse API response into a Pydantic model.
+
+ Args:
+ response: HTTP response object with json() method
+ model_class: Pydantic model class to parse response into
+
+ Returns:
+ Parsed model instance
+
+ Raises:
+ DevoValidationException: If response parsing fails
+ """
+ try:
+ data = response.json()
+ return model_class(**data)
+ except Exception as e:
+ raise DevoValidationException(f"Failed to parse response: {str(e)}")
+
+
def parse_webhook_signature(signature_header: str) -> dict:
"""
Parse webhook signature header.
diff --git a/tests/test_messages.py b/tests/test_messages.py
new file mode 100644
index 0000000..2fad7b9
--- /dev/null
+++ b/tests/test_messages.py
@@ -0,0 +1,324 @@
+from datetime import datetime
+from unittest.mock import Mock
+
+import pytest
+
+from src.devo_global_comms_python.models.messages import SendMessageDto, SendMessageSerializer
+from src.devo_global_comms_python.resources.messages import MessagesResource
+
+
+class TestMessagesResource:
+ """Test cases for the MessagesResource class."""
+
+ def setup_method(self):
+ """Set up test fixtures."""
+ self.mock_client = Mock()
+ self.messages_resource = MessagesResource(self.mock_client)
+
+ def test_send_sms_message(self):
+ """Test sending an SMS message through omni-channel endpoint."""
+ # Arrange
+ send_data = SendMessageDto(channel="sms", to="+1234567890", payload={"text": "Hello World"})
+
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "id": "msg_123",
+ "channel": "sms",
+ "to": "+1234567890",
+ "from": "+0987654321",
+ "status": "sent",
+ "direction": "outbound",
+ "content": {"text": "Hello World"},
+ "created_at": "2024-01-01T12:00:00Z",
+ }
+ self.mock_client.post.return_value = mock_response
+
+ # Act
+ result = self.messages_resource.send(send_data)
+
+ # Assert
+ assert isinstance(result, SendMessageSerializer)
+ assert result.id == "msg_123"
+ assert result.channel == "sms"
+ assert result.to == "+1234567890"
+ assert result.status == "sent"
+ assert result.content == {"text": "Hello World"}
+
+ self.mock_client.post.assert_called_once_with(
+ "messages/send", data={"channel": "sms", "to": "+1234567890", "payload": {"text": "Hello World"}}
+ )
+
+ def test_send_email_message(self):
+ """Test sending an email message through omni-channel endpoint."""
+ # Arrange
+ send_data = SendMessageDto(
+ channel="email",
+ to="user@example.com",
+ **{"from": "sender@example.com"},
+ payload={"subject": "Test Email", "text": "Hello World", "html": "Hello World
"},
+ callback_url="https://example.com/webhook",
+ metadata={"campaign": "test"},
+ )
+
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "id": "email_456",
+ "channel": "email",
+ "to": "user@example.com",
+ "from": "sender@example.com",
+ "status": "queued",
+ "direction": "outbound",
+ "content": {"subject": "Test Email", "text": "Hello World", "html": "Hello World
"},
+ "created_at": "2024-01-01T12:00:00Z",
+ "metadata": {"campaign": "test"},
+ }
+ self.mock_client.post.return_value = mock_response
+
+ # Act
+ result = self.messages_resource.send(send_data)
+
+ # Assert
+ assert isinstance(result, SendMessageSerializer)
+ assert result.id == "email_456"
+ assert result.channel == "email"
+ assert result.to == "user@example.com"
+ assert result.from_ == "sender@example.com"
+ assert result.status == "queued"
+ assert result.metadata == {"campaign": "test"}
+
+ self.mock_client.post.assert_called_once_with(
+ "messages/send",
+ data={
+ "channel": "email",
+ "to": "user@example.com",
+ "from": "sender@example.com",
+ "payload": {"subject": "Test Email", "text": "Hello World", "html": "Hello World
"},
+ "callback_url": "https://example.com/webhook",
+ "metadata": {"campaign": "test"},
+ },
+ )
+
+ def test_send_whatsapp_message(self):
+ """Test sending a WhatsApp message through omni-channel endpoint."""
+ # Arrange
+ send_data = SendMessageDto(
+ channel="whatsapp", to="+1234567890", payload={"type": "text", "text": {"body": "Hello World"}}
+ )
+
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "id": "wa_789",
+ "channel": "whatsapp",
+ "to": "+1234567890",
+ "status": "sent",
+ "direction": "outbound",
+ "content": {"type": "text", "text": {"body": "Hello World"}},
+ "created_at": "2024-01-01T12:00:00Z",
+ }
+ self.mock_client.post.return_value = mock_response
+
+ # Act
+ result = self.messages_resource.send(send_data)
+
+ # Assert
+ assert isinstance(result, SendMessageSerializer)
+ assert result.id == "wa_789"
+ assert result.channel == "whatsapp"
+ assert result.to == "+1234567890"
+ assert result.status == "sent"
+
+ self.mock_client.post.assert_called_once_with(
+ "messages/send",
+ data={
+ "channel": "whatsapp",
+ "to": "+1234567890",
+ "payload": {"type": "text", "text": {"body": "Hello World"}},
+ },
+ )
+
+ def test_send_rcs_message(self):
+ """Test sending an RCS message through omni-channel endpoint."""
+ # Arrange
+ send_data = SendMessageDto(
+ channel="rcs",
+ to="+1234567890",
+ payload={"message_type": "text", "text": "Hello World", "agent_id": "agent_123"},
+ )
+
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "id": "rcs_abc",
+ "channel": "rcs",
+ "to": "+1234567890",
+ "status": "sent",
+ "direction": "outbound",
+ "content": {"message_type": "text", "text": "Hello World", "agent_id": "agent_123"},
+ "created_at": "2024-01-01T12:00:00Z",
+ }
+ self.mock_client.post.return_value = mock_response
+
+ # Act
+ result = self.messages_resource.send(send_data)
+
+ # Assert
+ assert isinstance(result, SendMessageSerializer)
+ assert result.id == "rcs_abc"
+ assert result.channel == "rcs"
+ assert result.to == "+1234567890"
+ assert result.status == "sent"
+
+ self.mock_client.post.assert_called_once_with(
+ "messages/send",
+ data={
+ "channel": "rcs",
+ "to": "+1234567890",
+ "payload": {"message_type": "text", "text": "Hello World", "agent_id": "agent_123"},
+ },
+ )
+
+ def test_send_message_with_validation_error(self):
+ """Test send message with invalid channel."""
+ # Arrange & Act & Assert
+ with pytest.raises(ValueError):
+ SendMessageDto(
+ channel="invalid_channel", to="+1234567890", payload={"text": "Hello World"} # Invalid channel
+ )
+
+ def test_send_message_api_error(self):
+ """Test send message when API returns error."""
+ # Arrange
+ send_data = SendMessageDto(channel="sms", to="+1234567890", payload={"text": "Hello World"})
+
+ self.mock_client.post.side_effect = Exception("API Error")
+
+ # Act & Assert
+ with pytest.raises(Exception, match="API Error"):
+ self.messages_resource.send(send_data)
+
+ def test_send_message_dto_alias_handling(self):
+ """Test that SendMessageDto properly handles the 'from' field alias."""
+ # Arrange
+ send_data = SendMessageDto(
+ channel="sms",
+ to="+1234567890",
+ **{"from": "+0987654321"}, # Use from as keyword argument
+ payload={"text": "Hello World"},
+ )
+
+ # Act
+ data_dict = send_data.model_dump(by_alias=True, exclude_none=True)
+
+ # Assert
+ assert "from" in data_dict
+ assert "from_" not in data_dict
+ assert data_dict["from"] == "+0987654321"
+
+ def test_send_message_serializer_datetime_parsing(self):
+ """Test that SendMessageSerializer properly parses datetime fields."""
+ # Arrange
+ data = {
+ "id": "msg_123",
+ "channel": "sms",
+ "to": "+1234567890",
+ "status": "sent",
+ "direction": "outbound",
+ "content": {"text": "Hello World"},
+ "created_at": "2024-01-01T12:00:00Z",
+ }
+
+ # Act
+ serializer = SendMessageSerializer(**data)
+
+ # Assert
+ assert isinstance(serializer.created_at, datetime)
+ assert serializer.created_at.year == 2024
+ assert serializer.created_at.month == 1
+ assert serializer.created_at.day == 1
+
+
+class TestSendMessageDto:
+ """Test cases for SendMessageDto model."""
+
+ def test_valid_sms_payload(self):
+ """Test creating valid SMS payload."""
+ dto = SendMessageDto(channel="sms", to="+1234567890", payload={"text": "Hello World"})
+
+ assert dto.channel == "sms"
+ assert dto.to == "+1234567890"
+ assert dto.payload == {"text": "Hello World"}
+
+ def test_valid_email_payload(self):
+ """Test creating valid email payload."""
+ dto = SendMessageDto(
+ channel="email", to="user@example.com", payload={"subject": "Test", "text": "Hello", "html": "Hello
"}
+ )
+
+ assert dto.channel == "email"
+ assert dto.to == "user@example.com"
+ assert dto.payload["subject"] == "Test"
+
+ def test_invalid_channel(self):
+ """Test that invalid channel raises validation error."""
+ with pytest.raises(ValueError):
+ SendMessageDto(channel="invalid", to="+1234567890", payload={"text": "Hello"})
+
+ def test_optional_fields(self):
+ """Test optional fields are handled correctly."""
+ dto = SendMessageDto(
+ channel="sms",
+ to="+1234567890",
+ **{"from": "+0987654321"}, # Use from as keyword argument
+ payload={"text": "Hello"},
+ callback_url="https://example.com/webhook",
+ metadata={"key": "value"},
+ )
+
+ assert dto.from_ == "+0987654321"
+ assert dto.callback_url == "https://example.com/webhook"
+ assert dto.metadata == {"key": "value"}
+
+
+class TestSendMessageSerializer:
+ """Test cases for SendMessageSerializer model."""
+
+ def test_basic_serialization(self):
+ """Test basic serialization of response data."""
+ data = {
+ "id": "msg_123",
+ "channel": "sms",
+ "to": "+1234567890",
+ "status": "sent",
+ "direction": "outbound",
+ "content": {"text": "Hello World"},
+ "created_at": "2024-01-01T12:00:00Z",
+ }
+
+ serializer = SendMessageSerializer(**data)
+
+ assert serializer.id == "msg_123"
+ assert serializer.channel == "sms"
+ assert serializer.to == "+1234567890"
+ assert serializer.status == "sent"
+ assert serializer.direction == "outbound"
+ assert serializer.content == {"text": "Hello World"}
+
+ def test_optional_fields_serialization(self):
+ """Test serialization with optional fields."""
+ data = {
+ "id": "msg_123",
+ "channel": "email",
+ "to": "user@example.com",
+ "from": "sender@example.com",
+ "status": "delivered",
+ "direction": "outbound",
+ "content": {"subject": "Test", "text": "Hello"},
+ "pricing": {"cost": 0.01, "currency": "USD"},
+ "created_at": "2024-01-01T12:00:00Z",
+ "metadata": {"campaign": "test"},
+ }
+
+ serializer = SendMessageSerializer(**data)
+
+ assert serializer.from_ == "sender@example.com"
+ assert serializer.pricing == {"cost": 0.01, "currency": "USD"}
+ assert serializer.metadata == {"campaign": "test"}