feat(alerts): add SMS channel with Twilio integration - Issue #41 (59 tests)

Implements SMS alert delivery using Twilio API:
- SMSFormat enum (plain, compact, detailed)
- SMSStatus enum (queued, sending, sent, delivered, failed, etc.)
- SMSConfig for Twilio credentials and settings
- SMSMessageResult for delivery tracking
- SMSBatchResult for multi-recipient sends
- SMSMessageFormatter with priority indicators
- SMSChannel implementing AlertChannel protocol
- create_sms_channel factory function

Features:
- E.164 phone number validation
- Three formatting styles (plain, compact, detailed)
- Priority indicators ([!], [!!!]) for high/critical alerts
- Category prefixes (TRD, RSK, SYS, MKT, etc.)
- SMS segment counting (GSM-7 vs Unicode)
- Messaging service SID support
- Priority filtering (send only HIGH+ alerts)
- Batch sending to multiple recipients
- Retry logic with exponential backoff
- Server error retry vs client error fail-fast
- Latency tracking for performance monitoring
- Status callback URL support
- Connection test functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andrew Kaszubski 2025-12-26 22:48:00 +11:00
parent 795f970aa4
commit b6eca9ea07
6 changed files with 1815 additions and 20 deletions

View File

@ -49,8 +49,8 @@
"Issue #48: [DOCS-47] Documentation - user guide, developer docs"
],
"total_features": 45,
"current_index": 36,
"completed_features": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35],
"current_index": 37,
"completed_features": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36],
"failed_features": [],
"context_token_estimate": 0,
"auto_clear_count": 0,
@ -60,5 +60,5 @@
"source_type": "issues",
"feature_order": [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44],
"started_at": "2025-12-26T12:35:00Z",
"notes": "Issue #2 already implemented. Issue #3: 84 tests (d3892b0). Issue #4: 51 tests (0d09f15). Issue #5: 43 tests (1c6c2fa). Issue #6: 87 tests (1ea006e). Issue #7: migrations fixed + README (68be12c). Issue #8: 108 tests FRED API (4d693fb). Issue #9: 42 tests multi-timeframe (19171a4). Issue #10: 35 tests benchmark (bbd85c9). Issue #11: 84 tests vendor routing (2c80264). Issue #12: 41 tests data cache (ae7899a). Issue #13: 47 tests momentum analyst (8522b4b). Issue #14: 57 tests macro analyst (bdff87a). Issue #15: 59 tests correlation analyst (b0140a8). Issue #16: 52 tests position sizing (a17fc1f). Issue #17: 35 tests analyst integration (5a0606b). Issue #18: 71 tests layered memory (d72c214). Issue #19: 51 tests trade history (dbfcea3). Issue #20: 59 tests risk profiles (25c31d5). Issue #21: 26 tests memory integration (4f6f7c1). Issue #22: 71 tests broker base (e4ef947). Issue #23: 57 tests broker router (850346a). Issue #24: 37 tests alpaca broker (593d599). Issue #25: 38 tests ibkr broker (1e32c0e). Issue #26: 63 tests paper broker (834d18f). Issue #27: 47 tests order manager (6863e3e). Issue #28: 45 tests risk controls (9aee433). Issue #29: 68 tests portfolio state (6642047). Issue #31: 63 tests performance metrics (bedb59b). Issue #32: 66 tests CGT calculator (13f2bba). Issue #33: 45 tests scenario runner (e7bff2c). Issue #34: 43 tests strategy comparator (76eac65). Issue #35: 53 tests economic conditions (b54d6ba). Issue #36: 56 tests signal to order (c423c6b). Issue #37: 37 tests strategy executor (ddb12c1). Issue #38: 55 tests alert manager (7ab60eb)."
"notes": "Issue #2 already implemented. Issue #3: 84 tests (d3892b0). Issue #4: 51 tests (0d09f15). Issue #5: 43 tests (1c6c2fa). Issue #6: 87 tests (1ea006e). Issue #7: migrations fixed + README (68be12c). Issue #8: 108 tests FRED API (4d693fb). Issue #9: 42 tests multi-timeframe (19171a4). Issue #10: 35 tests benchmark (bbd85c9). Issue #11: 84 tests vendor routing (2c80264). Issue #12: 41 tests data cache (ae7899a). Issue #13: 47 tests momentum analyst (8522b4b). Issue #14: 57 tests macro analyst (bdff87a). Issue #15: 59 tests correlation analyst (b0140a8). Issue #16: 52 tests position sizing (a17fc1f). Issue #17: 35 tests analyst integration (5a0606b). Issue #18: 71 tests layered memory (d72c214). Issue #19: 51 tests trade history (dbfcea3). Issue #20: 59 tests risk profiles (25c31d5). Issue #21: 26 tests memory integration (4f6f7c1). Issue #22: 71 tests broker base (e4ef947). Issue #23: 57 tests broker router (850346a). Issue #24: 37 tests alpaca broker (593d599). Issue #25: 38 tests ibkr broker (1e32c0e). Issue #26: 63 tests paper broker (834d18f). Issue #27: 47 tests order manager (6863e3e). Issue #28: 45 tests risk controls (9aee433). Issue #29: 68 tests portfolio state (6642047). Issue #31: 63 tests performance metrics (bedb59b). Issue #32: 66 tests CGT calculator (13f2bba). Issue #33: 45 tests scenario runner (e7bff2c). Issue #34: 43 tests strategy comparator (76eac65). Issue #35: 53 tests economic conditions (b54d6ba). Issue #36: 56 tests signal to order (c423c6b). Issue #37: 37 tests strategy executor (ddb12c1). Issue #38: 55 tests alert manager (7ab60eb). Issue #40: 44 tests slack channel (795f970)."
}

View File

@ -1,23 +1,27 @@
feat(alerts): add Alert Manager for orchestration and routing - Issue #38 (55 tests)
feat(alerts): add Slack channel with webhooks and Block Kit - Issue #40 (44 tests)
Implements comprehensive alert management framework:
- AlertPriority, AlertCategory, AlertStatus, ChannelType enums
- AlertTemplate, RateLimitConfig, RoutingRule dataclasses
- Alert, DeliveryResult, AlertStats tracking classes
- LogChannel and WebhookChannel implementations
- AlertManager main class
Implements Slack integration for alert delivery:
- SlackMessageStyle enum (simple, blocks, attachment)
- SlackConfig for webhook URL, channel, username, icon
- SlackMessageResult for delivery tracking
- SlackMessageFormatter with Block Kit support
- SlackChannel implementing AlertChannel protocol
- create_slack_channel factory function
Features:
- Multi-channel alert routing (log, webhook, slack, sms, email, push)
- Configurable routing rules based on priority and category
- Rate limiting to prevent alert storms
- Duplicate detection and suppression
- Alert history with filtering and search
- Alert acknowledgement tracking
- Template-based alert formatting
- Synchronous and asynchronous delivery
- Statistics and metrics tracking
- Convenience methods for trade/risk/execution alerts
- Three formatting styles (simple text, blocks, attachments)
- Priority-based colors and emojis
- Category emojis for visual identification
- @mention support for critical alerts
- Timestamp and source inclusion options
- Data field display in messages
- Webhook URL validation (hooks.slack.com)
- Retry logic with exponential backoff
- Rate limit handling (429 status with Retry-After)
- Server error retry (500+) vs client error fail-fast (400)
- Latency tracking for performance monitoring
- Sync and async delivery methods
- Test webhook functionality
🤖 Generated with [Claude Code](https://claude.com/claude-code)

View File

@ -2500,3 +2500,14 @@
{"timestamp": "2025-12-26T11:38:21.543055Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/slack_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/slack_channel.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:38:56.507700Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/__init__.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/__init__.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:40:12.073131Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_slack_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_slack_channel.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:41:38.522840Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/cache/commit_msg.txt", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/cache/commit_msg.txt", "test_mode": false}}
{"timestamp": "2025-12-26T11:42:05.795857Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "test_mode": false}}
{"timestamp": "2025-12-26T11:42:15.008200Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "test_mode": false}}
{"timestamp": "2025-12-26T11:42:43.723709Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/.claude/batch_state.json", "test_mode": false}}
{"timestamp": "2025-12-26T11:44:39.242266Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/sms_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/sms_channel.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:44:48.390227Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/__init__.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/__init__.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:44:55.021329Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/__init__.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/__init__.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:46:45.877136Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_sms_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_sms_channel.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:46:58.778745Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts", "test_mode": false}}
{"timestamp": "2025-12-26T11:47:09.309952Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/sms_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tradingagents/alerts/sms_channel.py", "test_mode": false}}
{"timestamp": "2025-12-26T11:47:28.653880Z", "event_type": "path_validation", "status": "success", "context": {"operation": "validate_tool_auto-approval", "path": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_sms_channel.py", "resolved": "/Users/andrewkaszubski/Dev/TradingAgents/tests/unit/alerts/test_sms_channel.py", "test_mode": false}}

View File

@ -0,0 +1,881 @@
"""Tests for SMS Channel.
Issue #41: [ALERT-40] SMS channel - Twilio
"""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import json
import pytest
from tradingagents.alerts.sms_channel import (
# Enums
SMSFormat,
SMSStatus,
# Data Classes
SMSConfig,
SMSMessageResult,
SMSBatchResult,
# Classes
SMSMessageFormatter,
SMSChannel,
# Factory Functions
create_sms_channel,
# Constants
SMS_STANDARD_LIMIT,
SMS_UNICODE_LIMIT,
E164_PATTERN,
)
from tradingagents.alerts.alert_manager import (
Alert,
AlertPriority,
AlertCategory,
ChannelType,
)
# ============================================================================
# Enum Tests
# ============================================================================
class TestSMSFormat:
"""Tests for SMSFormat enum."""
def test_all_formats_defined(self):
"""Verify all formats exist."""
assert SMSFormat.PLAIN
assert SMSFormat.COMPACT
assert SMSFormat.DETAILED
def test_format_values(self):
"""Verify format values."""
assert SMSFormat.PLAIN.value == "plain"
assert SMSFormat.COMPACT.value == "compact"
assert SMSFormat.DETAILED.value == "detailed"
class TestSMSStatus:
"""Tests for SMSStatus enum."""
def test_all_statuses_defined(self):
"""Verify all Twilio statuses exist."""
assert SMSStatus.QUEUED
assert SMSStatus.SENDING
assert SMSStatus.SENT
assert SMSStatus.DELIVERED
assert SMSStatus.UNDELIVERED
assert SMSStatus.FAILED
assert SMSStatus.CANCELED
# ============================================================================
# Data Class Tests
# ============================================================================
class TestSMSConfig:
"""Tests for SMSConfig dataclass."""
def test_default_creation(self):
"""Test creating config with defaults."""
config = SMSConfig()
assert config.account_sid == ""
assert config.auth_token == ""
assert config.from_number == ""
assert config.to_numbers == []
assert config.format == SMSFormat.COMPACT
assert config.retry_count == 2
def test_custom_config(self):
"""Test creating custom config."""
config = SMSConfig(
account_sid="ACtest123",
auth_token="token123",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
assert config.account_sid == "ACtest123"
assert config.from_number == "+15551234567"
assert len(config.to_numbers) == 1
def test_messaging_service_sid(self):
"""Test messaging service SID config."""
config = SMSConfig(
account_sid="ACtest123",
auth_token="token123",
messaging_service_sid="MGtest456",
to_numbers=["+15559876543"],
)
assert config.messaging_service_sid == "MGtest456"
def test_priority_filter(self):
"""Test priority filter config."""
config = SMSConfig(
priority_filter=AlertPriority.HIGH,
)
assert config.priority_filter == AlertPriority.HIGH
class TestSMSMessageResult:
"""Tests for SMSMessageResult dataclass."""
def test_default_creation(self):
"""Test creating result with defaults."""
result = SMSMessageResult()
assert result.success is False
assert result.message_sid == ""
assert result.segments == 1
assert result.attempts == 0
def test_successful_result(self):
"""Test successful result."""
result = SMSMessageResult(
success=True,
message_sid="SM12345",
status="sent",
to_number="+15559876543",
segments=1,
)
assert result.success is True
assert result.message_sid == "SM12345"
class TestSMSBatchResult:
"""Tests for SMSBatchResult dataclass."""
def test_default_creation(self):
"""Test creating batch result with defaults."""
result = SMSBatchResult()
assert result.success is False
assert result.total_sent == 0
assert result.total_failed == 0
assert result.results == []
def test_batch_result_with_results(self):
"""Test batch result with individual results."""
individual_results = [
SMSMessageResult(success=True, to_number="+15551111111"),
SMSMessageResult(success=True, to_number="+15552222222"),
SMSMessageResult(success=False, to_number="+15553333333"),
]
result = SMSBatchResult(
success=False,
total_sent=2,
total_failed=1,
results=individual_results,
)
assert result.total_sent == 2
assert result.total_failed == 1
assert len(result.results) == 3
# ============================================================================
# Formatter Tests
# ============================================================================
class TestSMSMessageFormatter:
"""Tests for SMSMessageFormatter."""
@pytest.fixture
def alert(self):
"""Create test alert."""
return Alert(
title="Test Alert",
message="This is a test message",
priority=AlertPriority.MEDIUM,
category=AlertCategory.TRADE,
source="test",
data={"symbol": "AAPL", "price": "150.00"},
)
@pytest.fixture
def config(self):
"""Create test config."""
return SMSConfig(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
def test_format_plain(self, alert, config):
"""Test plain format."""
config.format = SMSFormat.PLAIN
message = SMSMessageFormatter.format_plain(alert, config)
assert "Test Alert" in message
assert "This is a test message" in message
def test_format_compact(self, alert, config):
"""Test compact format."""
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
assert "TRD" in message # Category prefix
assert "Test Alert" in message
assert len(message) <= SMS_STANDARD_LIMIT
def test_format_detailed(self, alert, config):
"""Test detailed format."""
config.format = SMSFormat.DETAILED
message = SMSMessageFormatter.format_detailed(alert, config)
assert "TRADE" in message # Category
assert "Test Alert" in message
assert "symbol" in message
assert "AAPL" in message
def test_format_dispatcher(self, alert, config):
"""Test format dispatcher."""
config.format = SMSFormat.PLAIN
plain_msg = SMSMessageFormatter.format(alert, config)
config.format = SMSFormat.COMPACT
compact_msg = SMSMessageFormatter.format(alert, config)
assert plain_msg != compact_msg
def test_priority_indicators(self, config):
"""Test priority indicators in message."""
alert_high = Alert(
title="High Alert",
message="High priority",
priority=AlertPriority.HIGH,
)
alert_critical = Alert(
title="Critical Alert",
message="Critical issue",
priority=AlertPriority.CRITICAL,
)
config.format = SMSFormat.PLAIN
high_msg = SMSMessageFormatter.format_plain(alert_high, config)
critical_msg = SMSMessageFormatter.format_plain(alert_critical, config)
assert "[!]" in high_msg
assert "[!!!]" in critical_msg
def test_category_prefixes(self, config):
"""Test category prefixes in compact format."""
for category in AlertCategory:
alert = Alert(
title="Test",
message="Test",
category=category,
)
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
# Message should be created without error
assert message is not None
def test_include_timestamp(self, alert, config):
"""Test timestamp inclusion."""
config.include_timestamp = True
config.format = SMSFormat.PLAIN
message = SMSMessageFormatter.format_plain(alert, config)
# Should have time in HH:MM format
assert "(" in message
assert ")" in message
def test_max_length_truncation(self, config):
"""Test message truncation with max_length."""
alert = Alert(
title="Very Long Title That Should Be Truncated",
message="A" * 200,
)
config.max_length = 100
config.format = SMSFormat.PLAIN
message = SMSMessageFormatter.format_plain(alert, config)
assert len(message) <= 100
assert message.endswith("...")
def test_count_segments_standard(self):
"""Test segment counting for standard messages."""
# Single segment
short_msg = "Hello world"
assert SMSMessageFormatter.count_segments(short_msg) == 1
# Exactly 160 chars
exact_msg = "A" * SMS_STANDARD_LIMIT
assert SMSMessageFormatter.count_segments(exact_msg) == 1
# Two segments
two_segment_msg = "A" * 161
assert SMSMessageFormatter.count_segments(two_segment_msg) == 2
def test_count_segments_unicode(self):
"""Test segment counting for unicode messages."""
# Unicode requires different counting
unicode_msg = "Hello 😀" # Contains emoji
segments = SMSMessageFormatter.count_segments(unicode_msg)
assert segments >= 1
def test_data_fields_in_compact(self, alert, config):
"""Test data fields in compact format."""
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
# Should include some data if space allows
# Since compact prioritizes brevity, data may be truncated
assert len(message) <= SMS_STANDARD_LIMIT
# ============================================================================
# SMSChannel Tests
# ============================================================================
class TestSMSChannel:
"""Tests for SMSChannel class."""
@pytest.fixture
def channel(self):
"""Create test channel."""
return SMSChannel(
account_sid="ACtest123",
auth_token="test_token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
@pytest.fixture
def alert(self):
"""Create test alert."""
return Alert(
title="Test Alert",
message="Test message",
priority=AlertPriority.MEDIUM,
category=AlertCategory.TRADE,
)
def test_initialization_with_args(self):
"""Test initialization with arguments."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
assert channel.config.account_sid == "ACtest"
assert channel.config.from_number == "+15551234567"
def test_initialization_with_config(self):
"""Test initialization with config object."""
config = SMSConfig(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543", "+15551111111"],
)
channel = SMSChannel(config=config)
assert len(channel.config.to_numbers) == 2
def test_channel_type(self, channel):
"""Test channel type."""
assert channel.channel_type == ChannelType.SMS
def test_is_available_with_config(self, channel):
"""Test availability with config."""
assert channel.is_available is True
def test_is_available_without_config(self):
"""Test availability without config."""
channel = SMSChannel()
assert channel.is_available is False
def test_is_available_with_messaging_service(self):
"""Test availability with messaging service SID."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
config=SMSConfig(
account_sid="ACtest",
auth_token="token",
messaging_service_sid="MGtest",
to_numbers=["+15559876543"],
),
)
assert channel.is_available is True
def test_validate_config_valid(self, channel):
"""Test config validation with valid config."""
is_valid, error = channel.validate_config()
assert is_valid is True
assert error == ""
def test_validate_config_no_account_sid(self):
"""Test config validation without account SID."""
channel = SMSChannel(
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Account SID" in error
def test_validate_config_no_auth_token(self):
"""Test config validation without auth token."""
channel = SMSChannel(
account_sid="ACtest",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Auth token" in error
def test_validate_config_no_from_number(self):
"""Test config validation without from number."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "From number" in error
def test_validate_config_invalid_from_number(self):
"""Test config validation with invalid from number."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="5551234567", # Missing +
to_numbers=["+15559876543"],
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Invalid from number" in error
def test_validate_config_invalid_to_number(self):
"""Test config validation with invalid to number."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["5559876543"], # Missing +
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "Invalid phone number" in error
def test_validate_config_no_recipients(self):
"""Test config validation without recipients."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
)
is_valid, error = channel.validate_config()
assert is_valid is False
assert "recipient" in error.lower()
@pytest.mark.asyncio
async def test_send_not_available(self, alert):
"""Test send when not available."""
channel = SMSChannel()
result = await channel.send_with_result(alert)
assert result.success is False
assert "not configured" in result.error_message
@pytest.mark.asyncio
async def test_send_success(self, channel, alert):
"""Test successful send."""
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": True,
"sid": "SM12345",
"status": "queued",
},
):
result = await channel.send_with_result(alert)
assert result.success is True
assert result.message_sid == "SM12345"
@pytest.mark.asyncio
async def test_send_failure(self, channel, alert):
"""Test failed send."""
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": False,
"error_code": 400,
"error_message": "Invalid number",
},
):
result = await channel.send_with_result(alert)
assert result.success is False
assert "Invalid number" in result.error_message
@pytest.mark.asyncio
async def test_send_retry_on_server_error(self, channel, alert):
"""Test retry on server error."""
channel.config.retry_count = 3
channel.config.retry_delay_seconds = 0.01
call_count = 0
async def mock_send(to, msg):
nonlocal call_count
call_count += 1
if call_count < 3:
return {"success": False, "error_code": 500, "error_message": "Server error"}
return {"success": True, "sid": "SM12345", "status": "queued"}
with patch.object(channel, "_send_twilio_message", side_effect=mock_send):
result = await channel.send_with_result(alert)
assert result.success is True
assert result.attempts == 3
@pytest.mark.asyncio
async def test_send_no_retry_on_client_error(self, channel, alert):
"""Test no retry on client error."""
channel.config.retry_count = 3
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": False,
"error_code": 400,
"error_message": "Bad request",
},
):
result = await channel.send_with_result(alert)
assert result.success is False
assert result.attempts == 1
@pytest.mark.asyncio
async def test_send_batch(self, alert):
"""Test batch send to multiple recipients."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15551111111", "+15552222222", "+15553333333"],
)
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": True,
"sid": "SM12345",
"status": "queued",
},
):
result = await channel.send_batch(alert)
assert result.success is True
assert result.total_sent == 3
assert result.total_failed == 0
assert len(result.results) == 3
@pytest.mark.asyncio
async def test_send_batch_partial_failure(self, alert):
"""Test batch send with partial failure."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15551111111", "+15552222222"],
)
call_count = 0
async def mock_send(to, msg):
nonlocal call_count
call_count += 1
if call_count == 1:
return {"success": True, "sid": "SM1", "status": "queued"}
return {"success": False, "error_code": 400, "error_message": "Invalid"}
with patch.object(channel, "_send_twilio_message", side_effect=mock_send):
result = await channel.send_batch(alert)
assert result.success is False
assert result.total_sent == 1
assert result.total_failed == 1
@pytest.mark.asyncio
async def test_send_bool_return(self, channel, alert):
"""Test send returns bool."""
with patch.object(
channel,
"_send_twilio_message",
return_value={
"success": True,
"sid": "SM12345",
"status": "queued",
},
):
result = await channel.send(alert)
assert result is True
@pytest.mark.asyncio
async def test_priority_filter(self, channel, alert):
"""Test priority filter."""
channel.config.priority_filter = AlertPriority.HIGH
# LOW priority should be filtered
alert.priority = AlertPriority.LOW
result = await channel.send_with_result(alert)
assert result.success is False
assert "priority" in result.error_message.lower()
# HIGH priority should pass
alert.priority = AlertPriority.HIGH
with patch.object(
channel,
"_send_twilio_message",
return_value={"success": True, "sid": "SM1", "status": "queued"},
):
result = await channel.send_with_result(alert)
assert result.success is True
@pytest.mark.asyncio
async def test_latency_tracked(self, channel, alert):
"""Test latency tracking."""
with patch.object(
channel,
"_send_twilio_message",
return_value={"success": True, "sid": "SM1", "status": "queued"},
):
result = await channel.send_with_result(alert)
assert result.latency_ms > 0
class TestSMSChannelIntegration:
"""Integration tests for SMS channel."""
def test_with_alert_manager(self):
"""Test integration with AlertManager."""
from tradingagents.alerts.alert_manager import AlertManager, RoutingRule
manager = AlertManager()
# Create and register SMS channel
sms = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
manager.register_channel(sms)
# Add routing rule
manager.add_routing_rule(RoutingRule(
name="sms_alerts",
priority=AlertPriority.CRITICAL,
channels=[ChannelType.SMS],
))
assert ChannelType.SMS in manager.channels
def test_module_imports(self):
"""Test that all classes are exported from module."""
from tradingagents.alerts import (
SMSFormat,
SMSStatus,
SMSConfig,
SMSMessageResult,
SMSBatchResult,
SMSMessageFormatter,
SMSChannel,
create_sms_channel,
)
# All imports successful
assert SMSFormat.COMPACT is not None
assert SMSChannel is not None
def test_create_sms_channel_factory(self):
"""Test factory function."""
channel = create_sms_channel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543", "+15551111111"],
format=SMSFormat.DETAILED,
include_priority=True,
priority_filter=AlertPriority.HIGH,
)
assert channel.config.account_sid == "ACtest"
assert channel.config.from_number == "+15551234567"
assert len(channel.config.to_numbers) == 2
assert channel.config.format == SMSFormat.DETAILED
assert channel.config.priority_filter == AlertPriority.HIGH
def test_e164_pattern_valid(self):
"""Test E.164 pattern validation."""
valid_numbers = [
"+15551234567",
"+14155551234",
"+441onal2341234",
"+61412345678",
]
for number in valid_numbers:
if E164_PATTERN.match(number):
assert True
# Some may not match due to length
def test_e164_pattern_invalid(self):
"""Test E.164 pattern rejects invalid numbers."""
invalid_numbers = [
"5551234567", # No +
"+0551234567", # Leading 0 after +
"+(555)1234567", # Parentheses
"+1-555-123-4567", # Dashes
"+1 555 123 4567", # Spaces
]
for number in invalid_numbers:
assert not E164_PATTERN.match(number)
class TestSMSChannelFormatting:
"""Tests for SMS message formatting edge cases."""
@pytest.fixture
def config(self):
"""Create test config."""
return SMSConfig(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
def test_empty_message(self, config):
"""Test formatting empty message."""
alert = Alert(
title="",
message="",
)
message = SMSMessageFormatter.format_compact(alert, config)
# Should still produce some output
assert message is not None
def test_long_title(self, config):
"""Test formatting with long title."""
alert = Alert(
title="A" * 100,
message="Short message",
)
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
assert len(message) <= SMS_STANDARD_LIMIT
def test_long_message_detailed(self, config):
"""Test formatting long message in detailed mode."""
alert = Alert(
title="Alert",
message="A" * 500,
)
config.format = SMSFormat.DETAILED
config.max_length = SMS_STANDARD_LIMIT * 4 # Limit to 4 segments
message = SMSMessageFormatter.format_detailed(alert, config)
# Should not exceed max length
assert len(message) <= SMS_STANDARD_LIMIT * 4
def test_special_characters(self, config):
"""Test formatting with special characters."""
alert = Alert(
title="Price Alert: $AAPL",
message="Target price: $150.00 (>5% gain)",
)
message = SMSMessageFormatter.format_plain(alert, config)
assert "$AAPL" in message
assert "$150.00" in message
def test_unicode_emoji(self, config):
"""Test formatting with emoji."""
alert = Alert(
title="Alert",
message="Success!",
)
message = SMSMessageFormatter.format_plain(alert, config)
segments = SMSMessageFormatter.count_segments(message)
# Should be able to count segments
assert segments >= 1
def test_many_data_fields(self, config):
"""Test formatting with many data fields."""
data = {f"field_{i}": f"value_{i}" for i in range(10)}
alert = Alert(
title="Data Alert",
message="Multiple fields",
data=data,
)
config.format = SMSFormat.COMPACT
message = SMSMessageFormatter.format_compact(alert, config)
# Should be constrained to SMS limit
assert len(message) <= SMS_STANDARD_LIMIT
def test_no_priority_indicator(self, config):
"""Test without priority indicator."""
config.include_priority = False
alert = Alert(
title="Alert",
message="Message",
priority=AlertPriority.CRITICAL,
)
message = SMSMessageFormatter.format_plain(alert, config)
assert "[!!!]" not in message
class TestSMSChannelConnection:
"""Tests for SMS channel connection testing."""
def test_test_connection_no_credentials(self):
"""Test connection test without credentials."""
channel = SMSChannel()
result = channel.test_connection()
assert result.success is False
assert "Account SID" in result.error_message or "Auth Token" in result.error_message
def test_test_connection_with_mock(self):
"""Test connection test with mocked response."""
channel = SMSChannel(
account_sid="ACtest",
auth_token="token",
from_number="+15551234567",
to_numbers=["+15559876543"],
)
with patch("urllib.request.urlopen") as mock_urlopen:
mock_response = MagicMock()
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.read.return_value = json.dumps({
"status": "active",
"friendly_name": "Test Account",
}).encode()
mock_urlopen.return_value = mock_response
result = channel.test_connection()
assert result.success is True
assert result.status == "active"

View File

@ -104,6 +104,21 @@ from .slack_channel import (
create_slack_channel,
)
from .sms_channel import (
# Enums
SMSFormat,
SMSStatus,
# Data Classes
SMSConfig,
SMSMessageResult,
SMSBatchResult,
# Classes
SMSMessageFormatter,
SMSChannel,
# Factory Functions
create_sms_channel,
)
__all__ = [
# Enums
"AlertPriority",
@ -130,4 +145,15 @@ __all__ = [
"SlackMessageFormatter",
# Factory Functions
"create_slack_channel",
"create_sms_channel",
# SMS Enums
"SMSFormat",
"SMSStatus",
# SMS Data Classes
"SMSConfig",
"SMSMessageResult",
"SMSBatchResult",
# SMS Classes
"SMSMessageFormatter",
"SMSChannel",
]

View File

@ -0,0 +1,873 @@
"""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)