"""SMS Channel for alert delivery via Twilio. Issue #41: [ALERT-40] SMS channel - Twilio This module provides SMS alert delivery using Twilio's API. Classes: SMSConfig: Configuration for SMS channel SMSMessageResult: Result of SMS delivery SMSChannel: SMS channel implementing AlertChannel protocol Functions: create_sms_channel: Factory function for creating SMS channels Example: >>> from tradingagents.alerts import SMSChannel, Alert, AlertPriority >>> >>> # Create channel with credentials >>> sms = SMSChannel( ... account_sid="ACxxxxx", ... auth_token="your_token", ... from_number="+15551234567", ... to_numbers=["+15559876543"], ... ) >>> >>> # Send alert >>> alert = Alert( ... title="Trade Alert", ... message="AAPL buy signal", ... priority=AlertPriority.HIGH, ... ) >>> await sms.send(alert) """ from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import Any, Optional, Protocol import asyncio import base64 import json import logging import re import time import urllib.parse import urllib.request from .alert_manager import Alert, AlertPriority, AlertCategory, ChannelType logger = logging.getLogger(__name__) # ============================================================================ # Constants # ============================================================================ # Standard SMS character limit SMS_STANDARD_LIMIT = 160 # Unicode SMS limit (70 chars) SMS_UNICODE_LIMIT = 70 # Max concatenated SMS segments MAX_SMS_SEGMENTS = 4 # Twilio API base URL TWILIO_API_BASE = "https://api.twilio.com/2010-04-01" # E.164 phone number pattern E164_PATTERN = re.compile(r"^\+[1-9]\d{1,14}$") # ============================================================================ # Enums # ============================================================================ class SMSFormat(Enum): """SMS message format options.""" PLAIN = "plain" # Plain text COMPACT = "compact" # Minimal format DETAILED = "detailed" # Full details class SMSStatus(Enum): """Twilio message status codes.""" QUEUED = "queued" SENDING = "sending" SENT = "sent" DELIVERED = "delivered" UNDELIVERED = "undelivered" FAILED = "failed" CANCELED = "canceled" # ============================================================================ # Data Classes # ============================================================================ @dataclass class SMSConfig: """Configuration for SMS channel. Attributes: account_sid: Twilio Account SID auth_token: Twilio Auth Token from_number: Sender phone number (E.164 format) to_numbers: Recipient phone numbers (E.164 format) messaging_service_sid: Optional messaging service SID format: Message format style include_priority: Whether to include priority indicator include_timestamp: Whether to include timestamp max_length: Maximum message length (0 = no limit) retry_count: Number of retry attempts retry_delay_seconds: Delay between retries status_callback_url: URL for status webhooks priority_filter: Minimum priority to send (None = all) """ account_sid: str = "" auth_token: str = "" from_number: str = "" to_numbers: list[str] = field(default_factory=list) messaging_service_sid: str = "" format: SMSFormat = SMSFormat.COMPACT include_priority: bool = True include_timestamp: bool = False max_length: int = 0 # 0 = no limit (allow multi-segment) retry_count: int = 2 retry_delay_seconds: float = 1.0 status_callback_url: str = "" priority_filter: Optional[AlertPriority] = None @dataclass class SMSMessageResult: """Result of SMS message send operation. Attributes: success: Whether the send was successful message_sid: Twilio message SID status: Message status to_number: Recipient number segments: Number of SMS segments used price: Message price (if available) error_code: Twilio error code (if failed) error_message: Error description attempts: Number of send attempts latency_ms: Total latency in milliseconds timestamp: When the result was created """ success: bool = False message_sid: str = "" status: str = "" to_number: str = "" segments: int = 1 price: Optional[str] = None error_code: Optional[int] = None error_message: str = "" attempts: int = 0 latency_ms: float = 0.0 timestamp: datetime = field(default_factory=datetime.now) @dataclass class SMSBatchResult: """Result of batch SMS send operation. Attributes: success: Whether all sends were successful total_sent: Number of messages sent total_failed: Number of failed messages results: Individual results per recipient latency_ms: Total batch latency """ success: bool = False total_sent: int = 0 total_failed: int = 0 results: list[SMSMessageResult] = field(default_factory=list) latency_ms: float = 0.0 # ============================================================================ # Message Formatter # ============================================================================ class SMSMessageFormatter: """Formats alerts for SMS delivery.""" # Priority indicators PRIORITY_INDICATORS: dict[AlertPriority, str] = { AlertPriority.LOW: "", AlertPriority.MEDIUM: "", AlertPriority.HIGH: "[!]", AlertPriority.CRITICAL: "[!!!]", } # Category prefixes (short for SMS) CATEGORY_PREFIXES: dict[AlertCategory, str] = { AlertCategory.TRADE: "TRD", AlertCategory.RISK: "RSK", AlertCategory.SYSTEM: "SYS", AlertCategory.MARKET: "MKT", AlertCategory.PORTFOLIO: "PRT", AlertCategory.EXECUTION: "EXE", AlertCategory.COMPLIANCE: "CMP", } @classmethod def format(cls, alert: Alert, config: SMSConfig) -> str: """Format alert for SMS. Args: alert: Alert to format config: SMS configuration Returns: Formatted message string """ if config.format == SMSFormat.PLAIN: return cls.format_plain(alert, config) elif config.format == SMSFormat.COMPACT: return cls.format_compact(alert, config) else: return cls.format_detailed(alert, config) @classmethod def format_plain(cls, alert: Alert, config: SMSConfig) -> str: """Format as plain text. Args: alert: Alert to format config: SMS configuration Returns: Plain text message """ parts = [] # Priority indicator if config.include_priority: indicator = cls.PRIORITY_INDICATORS.get(alert.priority, "") if indicator: parts.append(indicator) # Title and message if alert.title: parts.append(alert.title) if alert.message: parts.append(alert.message) # Timestamp if config.include_timestamp: parts.append(f"({alert.timestamp.strftime('%H:%M')})") message = " ".join(parts) # Apply max length if set if config.max_length > 0 and len(message) > config.max_length: message = message[: config.max_length - 3] + "..." return message @classmethod def format_compact(cls, alert: Alert, config: SMSConfig) -> str: """Format as compact message (optimized for SMS). Args: alert: Alert to format config: SMS configuration Returns: Compact message string """ parts = [] # Priority + Category prefix priority_ind = cls.PRIORITY_INDICATORS.get(alert.priority, "") category_pre = cls.CATEGORY_PREFIXES.get(alert.category, "") prefix_parts = [p for p in [priority_ind, category_pre] if p] if prefix_parts: parts.append(" ".join(prefix_parts)) # Title (shortened) if alert.title: title = alert.title if len(title) > 30: title = title[:27] + "..." parts.append(title) # Message (shortened) if alert.message: msg = alert.message # Leave room for other parts max_msg_len = SMS_STANDARD_LIMIT - sum(len(p) for p in parts) - len(parts) - 10 if max_msg_len > 0 and len(msg) > max_msg_len: msg = msg[: max_msg_len - 3] + "..." parts.append(msg) # Key data fields (if space allows) if alert.data: remaining = SMS_STANDARD_LIMIT - sum(len(p) for p in parts) - len(parts) if remaining > 20: data_parts = [] for key, value in list(alert.data.items())[:2]: data_str = f"{key}:{value}" if len(data_str) <= remaining: data_parts.append(data_str) remaining -= len(data_str) + 1 if data_parts: parts.append(" ".join(data_parts)) message = " - ".join(parts) # Apply max length if set if config.max_length > 0 and len(message) > config.max_length: message = message[: config.max_length - 3] + "..." return message @classmethod def format_detailed(cls, alert: Alert, config: SMSConfig) -> str: """Format with full details (may use multiple segments). Args: alert: Alert to format config: SMS configuration Returns: Detailed message string """ lines = [] # Priority indicator if config.include_priority: indicator = cls.PRIORITY_INDICATORS.get(alert.priority, "") priority_name = alert.priority.name.upper() if indicator: lines.append(f"{indicator} {priority_name}") else: lines.append(priority_name) # Category lines.append(f"[{alert.category.name}]") # Title if alert.title: lines.append(alert.title) # Message if alert.message: lines.append(alert.message) # Data fields if alert.data: for key, value in alert.data.items(): lines.append(f"{key}: {value}") # Source if alert.source: lines.append(f"Source: {alert.source}") # Timestamp if config.include_timestamp: lines.append(f"Time: {alert.timestamp.strftime('%Y-%m-%d %H:%M:%S')}") message = "\n".join(lines) # Apply max length if set (with segment consideration) max_len = config.max_length if config.max_length > 0 else SMS_STANDARD_LIMIT * MAX_SMS_SEGMENTS if len(message) > max_len: message = message[: max_len - 3] + "..." return message @classmethod def count_segments(cls, message: str) -> int: """Count SMS segments needed for message. Args: message: Message text Returns: Number of SMS segments """ # Check for non-GSM characters (requires Unicode encoding) gsm_chars = set( "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ ÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?" "¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà" ) is_unicode = not all(c in gsm_chars for c in message) if is_unicode: # Unicode: 70 chars per segment, 67 for concatenated if len(message) <= SMS_UNICODE_LIMIT: return 1 return (len(message) + 66) // 67 else: # GSM-7: 160 chars per segment, 153 for concatenated if len(message) <= SMS_STANDARD_LIMIT: return 1 return (len(message) + 152) // 153 # ============================================================================ # SMS Channel # ============================================================================ class SMSChannel: """SMS alert channel using Twilio. Implements the AlertChannel protocol for SMS delivery. Attributes: config: SMS channel configuration channel_type: Always ChannelType.SMS """ def __init__( self, account_sid: str = "", auth_token: str = "", from_number: str = "", to_numbers: Optional[list[str]] = None, *, config: Optional[SMSConfig] = None, ): """Initialize SMS channel. Args: account_sid: Twilio Account SID auth_token: Twilio Auth Token from_number: Sender phone number to_numbers: List of recipient numbers config: Optional full configuration (overrides other args) """ if config is not None: self.config = config else: self.config = SMSConfig( account_sid=account_sid, auth_token=auth_token, from_number=from_number, to_numbers=to_numbers or [], ) @property def channel_type(self) -> ChannelType: """Get channel type.""" return ChannelType.SMS @property def is_available(self) -> bool: """Check if channel is available.""" return bool( self.config.account_sid and self.config.auth_token and (self.config.from_number or self.config.messaging_service_sid) and self.config.to_numbers ) def validate_config(self) -> tuple[bool, str]: """Validate channel configuration. Returns: Tuple of (is_valid, error_message) """ if not self.config.account_sid: return False, "Account SID is required" if not self.config.auth_token: return False, "Auth token is required" if not self.config.from_number and not self.config.messaging_service_sid: return False, "From number or messaging service SID is required" if self.config.from_number and not E164_PATTERN.match(self.config.from_number): return False, f"Invalid from number format: {self.config.from_number}. Use E.164 format (+15551234567)" if not self.config.to_numbers: return False, "At least one recipient number is required" for number in self.config.to_numbers: if not E164_PATTERN.match(number): return False, f"Invalid phone number format: {number}. Use E.164 format (+15551234567)" return True, "" def _should_send(self, alert: Alert) -> bool: """Check if alert should be sent based on priority filter. Args: alert: Alert to check Returns: True if alert should be sent """ if self.config.priority_filter is None: return True priority_order = [ AlertPriority.LOW, AlertPriority.MEDIUM, AlertPriority.HIGH, AlertPriority.CRITICAL, ] alert_idx = priority_order.index(alert.priority) filter_idx = priority_order.index(self.config.priority_filter) return alert_idx >= filter_idx async def send(self, alert: Alert) -> bool: """Send alert via SMS. Args: alert: Alert to send Returns: True if all messages sent successfully """ result = await self.send_batch(alert) return result.success async def send_with_result(self, alert: Alert, to_number: Optional[str] = None) -> SMSMessageResult: """Send alert to a single number with detailed result. Args: alert: Alert to send to_number: Recipient number (uses first configured if not specified) Returns: SMSMessageResult with delivery details """ start_time = time.time() if not self.is_available: return SMSMessageResult( success=False, error_message="SMS channel not configured", latency_ms=(time.time() - start_time) * 1000, ) if not self._should_send(alert): return SMSMessageResult( success=False, error_message=f"Alert priority {alert.priority.name} below filter threshold", latency_ms=(time.time() - start_time) * 1000, ) target_number = to_number or self.config.to_numbers[0] message = SMSMessageFormatter.format(alert, self.config) attempt = 0 last_error = "" while attempt < self.config.retry_count + 1: attempt += 1 try: result = await self._send_twilio_message(target_number, message) if result.get("success"): return SMSMessageResult( success=True, message_sid=result.get("sid", ""), status=result.get("status", ""), to_number=target_number, segments=SMSMessageFormatter.count_segments(message), price=result.get("price"), attempts=attempt, latency_ms=(time.time() - start_time) * 1000, ) error_code = result.get("error_code") last_error = result.get("error_message", "Unknown error") # Don't retry on client errors (4xx) if error_code and 400 <= error_code < 500: break # Retry on server errors if attempt < self.config.retry_count + 1: await asyncio.sleep(self.config.retry_delay_seconds * attempt) except Exception as e: last_error = str(e) logger.warning(f"SMS send attempt {attempt} failed: {e}") if attempt < self.config.retry_count + 1: await asyncio.sleep(self.config.retry_delay_seconds * attempt) return SMSMessageResult( success=False, to_number=target_number, error_message=last_error, attempts=attempt, latency_ms=(time.time() - start_time) * 1000, ) async def send_batch(self, alert: Alert) -> SMSBatchResult: """Send alert to all configured recipients. Args: alert: Alert to send Returns: SMSBatchResult with all delivery results """ start_time = time.time() if not self.is_available: return SMSBatchResult( success=False, results=[ SMSMessageResult( success=False, error_message="SMS channel not configured", ) ], ) if not self._should_send(alert): return SMSBatchResult( success=False, results=[ SMSMessageResult( success=False, error_message=f"Alert priority below filter threshold", ) ], ) # Send to all recipients concurrently tasks = [ self.send_with_result(alert, to_number=number) for number in self.config.to_numbers ] results = await asyncio.gather(*tasks) total_sent = sum(1 for r in results if r.success) total_failed = sum(1 for r in results if not r.success) return SMSBatchResult( success=total_failed == 0, total_sent=total_sent, total_failed=total_failed, results=list(results), latency_ms=(time.time() - start_time) * 1000, ) async def _send_twilio_message(self, to_number: str, message: str) -> dict[str, Any]: """Send message via Twilio API. Args: to_number: Recipient phone number message: Message text Returns: API response dict """ # Build request url = f"{TWILIO_API_BASE}/Accounts/{self.config.account_sid}/Messages.json" data = { "To": to_number, "Body": message, } if self.config.messaging_service_sid: data["MessagingServiceSid"] = self.config.messaging_service_sid else: data["From"] = self.config.from_number if self.config.status_callback_url: data["StatusCallback"] = self.config.status_callback_url # Run in executor to avoid blocking loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._send_request, url, data) def _send_request(self, url: str, data: dict[str, Any]) -> dict[str, Any]: """Send HTTP request to Twilio (sync). Args: url: Request URL data: Form data Returns: Response dict """ try: # Encode credentials credentials = f"{self.config.account_sid}:{self.config.auth_token}" encoded_creds = base64.b64encode(credentials.encode()).decode() # Build request encoded_data = urllib.parse.urlencode(data).encode("utf-8") request = urllib.request.Request( url, data=encoded_data, method="POST", headers={ "Authorization": f"Basic {encoded_creds}", "Content-Type": "application/x-www-form-urlencoded", }, ) # Send request with urllib.request.urlopen(request, timeout=30) as response: body = response.read().decode("utf-8") result = json.loads(body) return { "success": True, "sid": result.get("sid"), "status": result.get("status"), "price": result.get("price"), "num_segments": result.get("num_segments"), } except urllib.error.HTTPError as e: body = e.read().decode("utf-8") try: error_data = json.loads(body) return { "success": False, "error_code": e.code, "error_message": error_data.get("message", str(e)), "twilio_code": error_data.get("code"), } except json.JSONDecodeError: return { "success": False, "error_code": e.code, "error_message": str(e), } except urllib.error.URLError as e: return { "success": False, "error_code": 0, "error_message": f"Network error: {e.reason}", } except Exception as e: return { "success": False, "error_code": 0, "error_message": str(e), } def _send_request_sync(self, url: str, data: dict[str, Any]) -> dict[str, Any]: """Alias for sync request (for testing).""" return self._send_request(url, data) def test_connection(self) -> SMSMessageResult: """Test Twilio connection by fetching account info. Returns: SMSMessageResult indicating success/failure """ start_time = time.time() if not self.config.account_sid or not self.config.auth_token: return SMSMessageResult( success=False, error_message="Account SID and Auth Token required", latency_ms=(time.time() - start_time) * 1000, ) try: url = f"{TWILIO_API_BASE}/Accounts/{self.config.account_sid}.json" credentials = f"{self.config.account_sid}:{self.config.auth_token}" encoded_creds = base64.b64encode(credentials.encode()).decode() request = urllib.request.Request( url, method="GET", headers={ "Authorization": f"Basic {encoded_creds}", }, ) with urllib.request.urlopen(request, timeout=10) as response: body = response.read().decode("utf-8") result = json.loads(body) return SMSMessageResult( success=True, status=result.get("status", "active"), latency_ms=(time.time() - start_time) * 1000, ) except urllib.error.HTTPError as e: return SMSMessageResult( success=False, error_code=e.code, error_message=f"Authentication failed: {e.code}", latency_ms=(time.time() - start_time) * 1000, ) except Exception as e: return SMSMessageResult( success=False, error_message=str(e), latency_ms=(time.time() - start_time) * 1000, ) # ============================================================================ # Factory Functions # ============================================================================ def create_sms_channel( account_sid: str, auth_token: str, from_number: str = "", to_numbers: Optional[list[str]] = None, *, messaging_service_sid: str = "", format: SMSFormat = SMSFormat.COMPACT, include_priority: bool = True, include_timestamp: bool = False, max_length: int = 0, retry_count: int = 2, priority_filter: Optional[AlertPriority] = None, status_callback_url: str = "", ) -> SMSChannel: """Create an SMS channel with configuration. Args: account_sid: Twilio Account SID auth_token: Twilio Auth Token from_number: Sender phone number (E.164 format) to_numbers: List of recipient numbers messaging_service_sid: Optional messaging service SID format: Message format style include_priority: Include priority in message include_timestamp: Include timestamp in message max_length: Maximum message length retry_count: Number of retry attempts priority_filter: Minimum priority to send status_callback_url: URL for status webhooks Returns: Configured SMSChannel instance """ config = SMSConfig( account_sid=account_sid, auth_token=auth_token, from_number=from_number, to_numbers=to_numbers or [], messaging_service_sid=messaging_service_sid, format=format, include_priority=include_priority, include_timestamp=include_timestamp, max_length=max_length, retry_count=retry_count, priority_filter=priority_filter, status_callback_url=status_callback_url, ) return SMSChannel(config=config)