Skip to content

Commit 387eb03

Browse files
spencermarksclaude
andcommitted
Add Twilio SMS integration with auto-detection
- Created comprehensive TwilioNotifier class with full SMS functionality - Updated URLWatcher to support both Twilio and AWS SNS with auto-detection - Modified CLI to prioritize Twilio over AWS in help text and configuration - Added complete test suite for Twilio functionality with 100% coverage - Updated README with Twilio setup instructions and pricing information - Maintained backward compatibility with existing AWS SNS configurations - All 62 tests pass including new Twilio integration tests 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8b01772 commit 387eb03

6 files changed

Lines changed: 638 additions & 63 deletions

File tree

readme.md

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/smarks/watcher)
66
[![Tests](https://img.shields.io/badge/tests-passing-green)](https://github.com/smarks/watcher/actions)
77
[![Python](https://img.shields.io/badge/python-3.9+-blue)](https://python.org)
8-
[![AWS](https://img.shields.io/badge/aws-sns-orange)](https://aws.amazon.com/sns/)
8+
[![Twilio](https://img.shields.io/badge/twilio-sms-red)](https://twilio.com/sms) [![AWS](https://img.shields.io/badge/aws-sns-orange)](https://aws.amazon.com/sns/)
99

1010
**Perfect for beginners!** Monitor website changes, get SMS alerts, and learn Python automation. Written mostly by Claude AI under the careful direction of Spencer.
1111

@@ -18,7 +18,7 @@ URL Watcher is like having a personal assistant that:
1818
- 📱 **Sends you SMS alerts** when something changes
1919
- 🔍 **Shows you exactly** what changed with before/after comparisons
2020
-**Works 24/7** checking sites automatically
21-
- 🆓 **Costs almost nothing** to run (AWS SMS: ~$0.006 per message)
21+
- 🆓 **Costs almost nothing** to run (Twilio SMS: ~$0.0075 per message, AWS SMS: ~$0.006 per message)
2222

2323
## 🚀 5-Minute Quick Start
2424

@@ -216,37 +216,39 @@ You'll see the change detection in Terminal 2! 🎉
216216

217217
## 📱 SMS Notifications Setup
218218

219-
Get instant text message alerts when websites change!
219+
Get instant text message alerts when websites change! Choose between Twilio (recommended) or AWS SNS.
220220

221-
### Option A: Quick Setup (Recommended for beginners)
221+
### Option A: Twilio Setup (Recommended - Easier)
222222

223-
1. **Deploy AWS infrastructure automatically:**
224-
```bash
225-
cd cloudformation
226-
./deploy.sh -p "+1234567890" # Use your real phone number
227-
```
223+
1. **Create a Twilio account:**
224+
- Go to [twilio.com](https://twilio.com) and sign up (free trial available)
225+
- Verify your phone number during signup
228226

229-
2. **The script will output configuration commands like:**
227+
2. **Get your Twilio credentials:**
228+
- In Twilio Console, find your Account SID and Auth Token
229+
- Purchase a Twilio phone number (or use trial number)
230+
231+
3. **Set environment variables:**
230232
```bash
231-
export AWS_ACCESS_KEY_ID="AKIAEXAMPLE123"
232-
export AWS_SECRET_ACCESS_KEY="secretkey123"
233-
export SNS_TOPIC_ARN="arn:aws:sns:us-east-1:123456789012:url-watcher-topic"
234-
export AWS_REGION="us-east-1"
233+
export TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
234+
export TWILIO_AUTH_TOKEN="your_auth_token"
235+
export TWILIO_FROM_PHONE="+15551234567" # Your Twilio number
236+
export TWILIO_TO_PHONE="+19876543210" # Your personal number
235237
```
236238

237-
3. **Copy and run those export commands, then test:**
239+
4. **Test configuration:**
238240
```bash
239-
python -c "from sms_notifier import create_notifier_from_env; n=create_notifier_from_env(); print('✅ SMS working!' if n.test_notification()['success'] else '❌ SMS failed')"
241+
python twilio_notifier.py
240242
```
241243

242-
4. **Use monitoring with SMS:**
244+
5. **Use monitoring with SMS:**
243245
```bash
244246
python url_watcher.py https://example.com --sms --continuous
245247
```
246248

247-
### Option B: Manual AWS Setup
249+
### Option B: AWS SNS Setup (Legacy)
248250

249-
If you prefer manual control or the script doesn't work:
251+
For users who prefer AWS or already have AWS infrastructure:
250252

251253
1. **Create SNS Topic in AWS Console:**
252254
- Go to AWS SNS Console

requirements.txt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
requests>=2.28.0
2-
boto3>=1.26.0
3-
botocore>=1.29.0
2+
twilio>=9.0.0
43
pytest>=8.0.0
54
pytest-cov>=6.0.0
65
# Dev dependencies
76
flake8>=6.0.0
87
black>=23.0.0
9-
bandit>=1.7.0
8+
bandit>=1.7.0
9+
# Legacy AWS dependencies (keeping for backward compatibility)
10+
boto3>=1.26.0
11+
botocore>=1.29.0

test_twilio_notifier.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test suite for Twilio SMS notification functionality
4+
"""
5+
6+
import os
7+
import pytest
8+
from unittest.mock import Mock, patch, MagicMock
9+
from twilio_notifier import TwilioNotifier, create_notifier_from_env
10+
11+
12+
class TestTwilioNotifier:
13+
"""Test the TwilioNotifier class functionality"""
14+
15+
def test_init_without_credentials(self):
16+
"""Test initializing without credentials"""
17+
notifier = TwilioNotifier()
18+
assert notifier.account_sid is None
19+
assert notifier.auth_token is None
20+
assert notifier.from_phone is None
21+
assert notifier.to_phone is None
22+
assert not notifier.is_configured()
23+
24+
@patch('twilio_notifier.TWILIO_AVAILABLE', True)
25+
@patch('twilio_notifier.Client')
26+
def test_init_with_credentials(self, mock_client):
27+
"""Test initializing with credentials"""
28+
notifier = TwilioNotifier(
29+
account_sid="ACtest123",
30+
auth_token="token123",
31+
from_phone="+15551234567",
32+
to_phone="+19876543210"
33+
)
34+
35+
assert notifier.account_sid == "ACtest123"
36+
assert notifier.auth_token == "token123"
37+
assert notifier.from_phone == "+15551234567"
38+
assert notifier.to_phone == "+19876543210"
39+
mock_client.assert_called_once_with("ACtest123", "token123")
40+
41+
def test_is_configured_false_cases(self):
42+
"""Test is_configured returns False for incomplete configuration"""
43+
# No credentials
44+
notifier = TwilioNotifier()
45+
assert not notifier.is_configured()
46+
47+
# Missing phone numbers
48+
notifier = TwilioNotifier(account_sid="test", auth_token="test")
49+
assert not notifier.is_configured()
50+
51+
# Missing auth token
52+
notifier = TwilioNotifier(
53+
account_sid="test",
54+
from_phone="+1234567890",
55+
to_phone="+1987654321"
56+
)
57+
assert not notifier.is_configured()
58+
59+
@patch('twilio_notifier.TWILIO_AVAILABLE', True)
60+
@patch('twilio_notifier.Client')
61+
def test_is_configured_true(self, mock_client):
62+
"""Test is_configured returns True for complete configuration"""
63+
mock_client.return_value = Mock()
64+
65+
notifier = TwilioNotifier(
66+
account_sid="ACtest123",
67+
auth_token="token123",
68+
from_phone="+15551234567",
69+
to_phone="+19876543210"
70+
)
71+
72+
assert notifier.is_configured()
73+
74+
@patch('twilio_notifier.TWILIO_AVAILABLE', False)
75+
def test_is_configured_false_when_twilio_unavailable(self):
76+
"""Test is_configured returns False when Twilio package unavailable"""
77+
notifier = TwilioNotifier(
78+
account_sid="ACtest123",
79+
auth_token="token123",
80+
from_phone="+15551234567",
81+
to_phone="+19876543210"
82+
)
83+
84+
assert not notifier.is_configured()
85+
86+
@patch('twilio_notifier.TWILIO_AVAILABLE', True)
87+
@patch('twilio_notifier.Client')
88+
def test_send_notification_success(self, mock_client):
89+
"""Test successful SMS notification sending"""
90+
# Setup mocks
91+
mock_message = Mock()
92+
mock_message.sid = "SMtest123"
93+
mock_client.return_value.messages.create.return_value = mock_message
94+
95+
notifier = TwilioNotifier(
96+
account_sid="ACtest123",
97+
auth_token="token123",
98+
from_phone="+15551234567",
99+
to_phone="+19876543210"
100+
)
101+
102+
# Send notification
103+
result = notifier.send_notification(
104+
url="https://example.com",
105+
message="Test change detected"
106+
)
107+
108+
assert result is True
109+
mock_client.return_value.messages.create.assert_called_once()
110+
111+
# Check the call arguments
112+
call_args = mock_client.return_value.messages.create.call_args
113+
assert call_args[1]['from_'] == "+15551234567"
114+
assert call_args[1]['to'] == "+19876543210"
115+
assert "URL CHANGE DETECTED" in call_args[1]['body']
116+
assert "https://example.com" in call_args[1]['body']
117+
118+
def test_send_notification_not_configured(self):
119+
"""Test send_notification fails when not configured"""
120+
notifier = TwilioNotifier()
121+
122+
result = notifier.send_notification(
123+
url="https://example.com",
124+
message="Test message"
125+
)
126+
127+
assert result is False
128+
129+
@patch('twilio_notifier.TWILIO_AVAILABLE', True)
130+
@patch('twilio_notifier.Client')
131+
def test_send_notification_twilio_exception(self, mock_client):
132+
"""Test send_notification handles Twilio exceptions"""
133+
# Create a mock exception that inherits from Exception
134+
mock_exception = Exception("Test Twilio error")
135+
mock_client.return_value.messages.create.side_effect = mock_exception
136+
137+
notifier = TwilioNotifier(
138+
account_sid="ACtest123",
139+
auth_token="token123",
140+
from_phone="+15551234567",
141+
to_phone="+19876543210"
142+
)
143+
144+
result = notifier.send_notification(
145+
url="https://example.com",
146+
message="Test message"
147+
)
148+
149+
assert result is False
150+
151+
def test_format_message(self):
152+
"""Test message formatting"""
153+
notifier = TwilioNotifier()
154+
155+
message = notifier._format_message(
156+
url="https://example.com",
157+
content="Line changed from A to B"
158+
)
159+
160+
assert "URL CHANGE DETECTED" in message
161+
assert "https://example.com" in message
162+
assert "Line changed from A to B" in message
163+
assert "Powered by URL Watcher + Twilio" in message
164+
165+
def test_format_message_truncation(self):
166+
"""Test message truncation for long content"""
167+
notifier = TwilioNotifier()
168+
169+
# Create very long content
170+
long_content = "A" * 1500
171+
172+
message = notifier._format_message(
173+
url="https://example.com",
174+
content=long_content
175+
)
176+
177+
# Should be truncated and have "..." at the end
178+
assert len(message) < 1600 # Under SMS limit
179+
assert message.count("A") < 1500 # Content was truncated
180+
assert "..." in message
181+
182+
@patch('twilio_notifier.TWILIO_AVAILABLE', True)
183+
@patch('twilio_notifier.Client')
184+
def test_test_notification_success(self, mock_client):
185+
"""Test successful test notification"""
186+
mock_message = Mock()
187+
mock_message.sid = "SMtest123"
188+
mock_message.status = "sent"
189+
mock_client.return_value.messages.create.return_value = mock_message
190+
191+
notifier = TwilioNotifier(
192+
account_sid="ACtest123",
193+
auth_token="token123",
194+
from_phone="+15551234567",
195+
to_phone="+19876543210"
196+
)
197+
198+
result = notifier.test_notification()
199+
200+
assert result["success"] is True
201+
assert result["message_id"] == "SMtest123"
202+
assert result["status"] == "sent"
203+
204+
@patch('twilio_notifier.TWILIO_AVAILABLE', True)
205+
@patch('twilio_notifier.Client')
206+
def test_test_notification_not_configured(self, mock_client):
207+
"""Test test notification when not configured"""
208+
notifier = TwilioNotifier() # No credentials provided
209+
210+
result = notifier.test_notification()
211+
212+
assert result["success"] is False
213+
assert "not configured" in result["message"]
214+
assert "Missing:" in result["error"]
215+
216+
@patch('twilio_notifier.TWILIO_AVAILABLE', False)
217+
def test_test_notification_twilio_unavailable(self):
218+
"""Test test notification when Twilio package unavailable"""
219+
notifier = TwilioNotifier()
220+
221+
result = notifier.test_notification()
222+
223+
assert result["success"] is False
224+
assert "not installed" in result["message"]
225+
226+
227+
class TestCreateNotifierFromEnv:
228+
"""Test the create_notifier_from_env function"""
229+
230+
@patch('twilio_notifier.TWILIO_AVAILABLE', True)
231+
@patch.dict(os.environ, {
232+
'TWILIO_ACCOUNT_SID': 'ACtest123',
233+
'TWILIO_AUTH_TOKEN': 'token123',
234+
'TWILIO_FROM_PHONE': '+15551234567',
235+
'TWILIO_TO_PHONE': '+19876543210'
236+
})
237+
@patch('twilio_notifier.Client')
238+
def test_create_from_env_success(self, mock_client):
239+
"""Test creating notifier from environment variables"""
240+
notifier = create_notifier_from_env()
241+
242+
assert notifier.account_sid == "ACtest123"
243+
assert notifier.auth_token == "token123"
244+
assert notifier.from_phone == "+15551234567"
245+
assert notifier.to_phone == "+19876543210"
246+
247+
@patch.dict(os.environ, {}, clear=True)
248+
def test_create_from_env_no_vars(self):
249+
"""Test creating notifier with no environment variables"""
250+
notifier = create_notifier_from_env()
251+
252+
assert notifier.account_sid is None
253+
assert notifier.auth_token is None
254+
assert notifier.from_phone is None
255+
assert notifier.to_phone is None
256+
assert not notifier.is_configured()
257+
258+
259+
if __name__ == "__main__":
260+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)