Compare commits

1 Commits

7 changed files with 176 additions and 285 deletions
-1
View File
@@ -55,7 +55,6 @@ simple-websocket==1.1.0
sniffio==1.3.1 sniffio==1.3.1
sqlalchemy==2.0.23 sqlalchemy==2.0.23
starlette==0.27.0 starlette==0.27.0
twilio==8.10.0
typing-extensions==4.14.1 typing-extensions==4.14.1
uritemplate==4.2.0 uritemplate==4.2.0
urllib3==2.5.0 urllib3==2.5.0
+10 -8
View File
@@ -11,7 +11,7 @@ from database import (
get_last_incoming_outgoing, get_last_incoming_outgoing,
get_thread_messages, get_thread_messages,
) )
from whatsapp_sender import WhatsAppSender from slack_sender import SlackSender
@dataclass @dataclass
@@ -68,7 +68,7 @@ def _format_alert(
def process_alerts(db: Session, cfg: dict) -> List[int]: def process_alerts(db: Session, cfg: dict) -> List[int]:
"""Analyze threads and send WhatsApp alerts when thresholds are met.""" """Analyze threads and send Slack alerts when thresholds are met."""
import logging import logging
account_email = (cfg.get("email_address") or cfg.get("zoho_email") or "").lower() account_email = (cfg.get("email_address") or cfg.get("zoho_email") or "").lower()
@@ -97,9 +97,11 @@ def process_alerts(db: Session, cfg: dict) -> List[int]:
logging.info(f"Found {len(threads)} threads requiring reply") logging.info(f"Found {len(threads)} threads requiring reply")
to_number = cfg.get("whatsapp_to") or None webhook_url = cfg.get("slack_webhook_url") or None
logging.info(f"WhatsApp target: {to_number}") logging.info(
sender = WhatsAppSender(to_number=to_number) f"Slack webhook URL: {'✓ Configured' if webhook_url else '✗ Not configured'}"
)
sender = SlackSender(webhook_url=webhook_url)
alerted = [] alerted = []
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -179,11 +181,11 @@ def process_alerts(db: Session, cfg: dict) -> List[int]:
logging.info(f" Thread {t.id}: Not actionable, skipping alert") logging.info(f" Thread {t.id}: Not actionable, skipping alert")
continue continue
# Compose and send WhatsApp # Compose and send Slack
msg = _format_alert(target_level, target_hours, t, ai, last_in) msg = _format_alert(target_level, target_hours, t, ai, last_in)
logging.info(f" Thread {t.id}: Sending alert via WhatsApp") logging.info(f" Thread {t.id}: Sending alert via Slack")
res = sender.send_alert(msg, thread_id=str(t.id)) res = sender.send_alert(msg, thread_id=str(t.id))
logging.info(f" Thread {t.id}: WhatsApp response: {res}") logging.info(f" Thread {t.id}: Slack response: {res}")
if res.get("status") == "success": if res.get("status") == "success":
t.last_alert_level_sent = target_level t.last_alert_level_sent = target_level
+5 -3
View File
@@ -226,7 +226,7 @@ def load_config() -> dict:
"agency_domains": [], "agency_domains": [],
"zoho_email": "", "zoho_email": "",
"zoho_app_password": "", "zoho_app_password": "",
"whatsapp_to": "", "slack_webhook_url": "",
"auto_process": False, "auto_process": False,
"auto_process_interval": 30, "auto_process_interval": 30,
# Sync status # Sync status
@@ -309,8 +309,10 @@ async def config_save(request: Request):
cfg["zoho_app_password"] = ( cfg["zoho_app_password"] = (
form.get("zoho_app_password") or cfg.get("zoho_app_password", "") form.get("zoho_app_password") or cfg.get("zoho_app_password", "")
).strip() ).strip()
# WhatsApp destination # Slack webhook URL
cfg["whatsapp_to"] = (form.get("whatsapp_to") or cfg.get("whatsapp_to", "")).strip() cfg["slack_webhook_url"] = (
form.get("slack_webhook_url") or cfg.get("slack_webhook_url", "")
).strip()
# Time frames: collect indexed rows # Time frames: collect indexed rows
frames: list[dict] = [] frames: list[dict] = []
+155
View File
@@ -0,0 +1,155 @@
import logging
import os
import time
from typing import Any, Dict, List
import requests
class SlackSender:
def __init__(self, webhook_url: str | None = None):
env_webhook = (os.getenv("SLACK_WEBHOOK_URL") or "").strip()
self.webhook_url = webhook_url or env_webhook
# Log credential status (without exposing full webhook URL)
webhook_preview = (
self.webhook_url[:40] + "..."
if len(self.webhook_url) > 40
else self.webhook_url
)
logging.info(
f"Slack config: Webhook={'' if self.webhook_url else ''} "
f"({webhook_preview if self.webhook_url else 'missing'})"
)
if self.webhook_url:
self.use_mock = False
logging.info("Slack sender initialized successfully")
else:
self.use_mock = True
logging.warning(
"Using mock Slack sender. Missing: SLACK_WEBHOOK_URL or webhook_url parameter"
)
def send_alert(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
"""Send alert message to Slack"""
if self.use_mock:
return self._mock_send(alert_message, thread_id)
try:
# Format message for Slack
formatted_message = self._format_message(alert_message, thread_id)
# Send to Slack via webhook
response = requests.post(
self.webhook_url,
json={"text": formatted_message},
headers={"Content-Type": "application/json"},
timeout=10,
)
response.raise_for_status()
# Slack webhooks return plain text "ok" on success, not JSON
try:
response_data = response.json()
logging.info(f"Slack response: {response_data}")
except ValueError:
# Response is plain text (e.g., "ok")
response_text = response.text.strip()
logging.info(f"Slack response: {response_text}")
return {
"status": "success",
"message_sid": response.headers.get(
"X-Slack-Request-Timestamp", "unknown"
),
"thread_id": thread_id,
"sent_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
except requests.exceptions.RequestException as e:
error_msg = str(e)
logging.error(f"Slack send error: {error_msg}")
# Provide specific guidance based on error
if "401" in error_msg or "403" in error_msg:
logging.error(
"Authentication failed! Check your Slack webhook URL:\n"
" 1. Verify SLACK_WEBHOOK_URL in environment or config\n"
" 2. Make sure the webhook URL is valid and not revoked\n"
" 3. Check that the webhook is enabled in your Slack app settings\n"
" 4. Create a new webhook at https://api.slack.com/apps if needed"
)
elif "404" in error_msg:
logging.error(
"Invalid webhook URL: The endpoint was not found.\n"
" Make sure your webhook URL is correct and hasn't been revoked."
)
elif "timeout" in error_msg.lower():
logging.error("Request timeout: Slack may be temporarily unavailable.")
return {"status": "error", "error": error_msg, "thread_id": thread_id}
def _mock_send(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
"""Mock Slack sending for testing"""
print("💬 [MOCK] Slack Alert Sent:")
print(f" Webhook: {self.webhook_url or 'not_configured'}")
print(f" Thread ID: {thread_id}")
print(f" Message: {alert_message[:100]}...")
return {
"status": "success",
"message_sid": "mock_sid_123",
"thread_id": thread_id,
"sent_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
def _format_message(self, alert_message: str, thread_id: str = None) -> str:
"""Format alert message for Slack"""
# Slack messages can be longer, but we'll keep it reasonable
# Thread ID is already included in the alert_message format
formatted = alert_message
# Slack has a 4000 character limit for messages
max_length = 4000
if len(formatted) > max_length:
formatted = formatted[: max_length - 3] + "..."
return formatted
def send_bulk_alerts(self, alerts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Send multiple alerts to Slack"""
results = []
for alert in alerts:
message = alert.get("message", "")
thread_id = alert.get("thread_id", "unknown")
result = self.send_alert(message, thread_id)
results.append(result)
# Add small delay between messages to avoid rate limits
time.sleep(0.5)
return results
if __name__ == "__main__":
# Test Slack sender
sender = SlackSender()
test_message = """
🚨 LEVEL 1 ALERT (24 Hours)
🟢 Urgency: LOW
📧 Thread ID: test_thread_123
📝 Summary:
Client inquiry about project status. Requires follow-up.
🎯 Action Required:
Respond to client question
⏰ Confidence: 70.0%
""".strip()
result = sender.send_alert(test_message, "test_thread_123")
print(f"Send result: {result}")
-156
View File
@@ -1,156 +0,0 @@
import logging
import os
from typing import Any, Dict, List
from twilio.base.exceptions import TwilioException
from twilio.rest import Client
class WhatsAppSender:
def __init__(self, to_number: str | None = None):
self.account_sid = (os.getenv("TWILIO_ACCOUNT_SID") or "").strip()
self.auth_token = (os.getenv("TWILIO_AUTH_TOKEN") or "").strip()
self.from_number = (os.getenv("TWILIO_WHATSAPP_NUMBER") or "").strip()
env_to = (os.getenv("WHATSAPP_TO_NUMBER") or "").strip()
self.to_number = to_number or env_to # Individual phone number
# Log credential status (without exposing full credentials)
logging.info(
f"Twilio config: SID={'' if self.account_sid else ''} "
f"({self.account_sid[:8] + '...' if self.account_sid else 'missing'}), "
f"Token={'' if self.auth_token else ''}, "
f"From={'' if self.from_number else ''} ({self.from_number}), "
f"To={'' if self.to_number else ''} ({self.to_number})"
)
if self.account_sid and self.auth_token and self.from_number and self.to_number:
try:
self.client = Client(self.account_sid, self.auth_token)
self.use_mock = False
logging.info("Twilio client initialized successfully")
except Exception as e:
logging.error(f"Twilio client failed to initialize: {e}")
self.use_mock = True
else:
self.use_mock = True
missing = []
if not self.account_sid:
missing.append("TWILIO_ACCOUNT_SID")
if not self.auth_token:
missing.append("TWILIO_AUTH_TOKEN")
if not self.from_number:
missing.append("TWILIO_WHATSAPP_NUMBER")
if not self.to_number:
missing.append("WHATSAPP_TO_NUMBER or to_number parameter")
logging.warning(
f"Using mock WhatsApp sender. Missing: {', '.join(missing)}"
)
def send_alert(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
"""Send alert message to WhatsApp"""
if self.use_mock:
return self._mock_send(alert_message, thread_id)
try:
# Format message for WhatsApp
formatted_message = self._format_message(alert_message)
# Send to WhatsApp
message = self.client.messages.create(
from_=f"whatsapp:{self.from_number}",
body=formatted_message,
to=f"whatsapp:{self.to_number}",
)
return {
"status": "success",
"message_sid": message.sid,
"thread_id": thread_id,
"sent_at": message.date_created,
}
except TwilioException as e:
error_msg = str(e)
logging.error(f"WhatsApp send error: {error_msg}")
# Provide specific guidance based on error
if "20003" in error_msg or "Authenticate" in error_msg:
logging.error(
"Authentication failed! Check your Twilio credentials:\n"
" 1. Verify TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in .env file\n"
" 2. Make sure there are no extra spaces or quotes\n"
" 3. Confirm you're using the correct credentials (Live vs Test)\n"
" 4. Check https://console.twilio.com for correct values"
)
elif "21211" in error_msg:
logging.error(f"Invalid 'To' phone number: {self.to_number}")
elif "21408" in error_msg:
logging.error(
f"WhatsApp not enabled for number: {self.from_number}\n"
" Enable WhatsApp at https://console.twilio.com/us1/develop/sms/senders/whatsapp-senders"
)
return {"status": "error", "error": error_msg, "thread_id": thread_id}
def _mock_send(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
"""Mock WhatsApp sending for testing"""
print("📱 [MOCK] WhatsApp Alert Sent:")
print(f" To: {self.to_number or 'your_number'}")
print(f" Thread ID: {thread_id}")
print(f" Message: {alert_message[:100]}...")
return {
"status": "success",
"message_sid": "mock_sid_123",
"thread_id": thread_id,
"sent_at": "2024-01-15T10:00:00Z",
}
def _format_message(self, alert_message: str) -> str:
"""Format alert message for WhatsApp"""
# WhatsApp has character limits, so we might need to truncate
max_length = 1000
if len(alert_message) > max_length:
alert_message = alert_message[: max_length - 3] + "..."
return alert_message
def send_bulk_alerts(self, alerts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Send multiple alerts to WhatsApp"""
results = []
for alert in alerts:
message = alert.get("message", "")
thread_id = alert.get("thread_id", "unknown")
result = self.send_alert(message, thread_id)
results.append(result)
# Add small delay between messages to avoid rate limits
import time
time.sleep(1)
return results
if __name__ == "__main__":
# Test WhatsApp sender
sender = WhatsAppSender()
test_message = """
🚨 LEVEL 1 ALERT (24 Hours)
🟢 Urgency: LOW
📧 Thread ID: test_thread_123
📝 Summary:
Client inquiry about project status. Requires follow-up.
🎯 Action Required:
Respond to client question
⏰ Confidence: 70.0%
""".strip()
result = sender.send_alert(test_message, "test_thread_123")
print(f"Send result: {result}")
+6 -3
View File
@@ -47,12 +47,15 @@
</div> </div>
</div> </div>
<h3 class="mt-2">WhatsApp</h3> <h3 class="mt-2">Slack</h3>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label>Send To (WhatsApp E.164, no spaces)<br> <label>Webhook URL<br>
<input type="text" name="whatsapp_to" placeholder="+15551234567" value="{{ cfg.whatsapp_to or '' }}" /> <input type="url" name="slack_webhook_url" placeholder="https://hooks.slack.com/services/..." value="{{ cfg.slack_webhook_url or '' }}" />
</label> </label>
<p class="muted" style="font-size: 0.9em; margin-top: 0.25em;">
Create a webhook at <a href="https://api.slack.com/apps" target="_blank">api.slack.com/apps</a>
</p>
</div> </div>
</div> </div>
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""
Test Twilio credentials and WhatsApp configuration
"""
import os
from dotenv import load_dotenv
from twilio.rest import Client
# Load environment variables
load_dotenv()
def test_credentials():
print("=" * 60)
print("TWILIO CREDENTIALS TEST")
print("=" * 60)
# Get credentials
account_sid = (os.getenv("TWILIO_ACCOUNT_SID") or "").strip()
auth_token = (os.getenv("TWILIO_AUTH_TOKEN") or "").strip()
from_number = (os.getenv("TWILIO_WHATSAPP_NUMBER") or "").strip()
to_number = (os.getenv("WHATSAPP_TO_NUMBER") or "").strip()
# Check if credentials exist
print("\n1. Checking environment variables:")
print(f" TWILIO_ACCOUNT_SID: {'✓ Found' if account_sid else '✗ Missing'}")
if account_sid:
print(f" Value: {account_sid[:8]}...{account_sid[-4:]}")
print(f" TWILIO_AUTH_TOKEN: {'✓ Found' if auth_token else '✗ Missing'}")
if auth_token:
print(f" Value: {'*' * len(auth_token[:4])}...{auth_token[-4:]}")
print(f" TWILIO_WHATSAPP_NUMBER: {'✓ Found' if from_number else '✗ Missing'}")
if from_number:
print(f" Value: {from_number}")
print(f" WHATSAPP_TO_NUMBER: {'✓ Found' if to_number else '✗ Missing'}")
if to_number:
print(f" Value: {to_number}")
# Test authentication
if not (account_sid and auth_token):
print("\n❌ Missing required credentials!")
print("\nPlease check your .env file and ensure:")
print(" - TWILIO_ACCOUNT_SID is set")
print(" - TWILIO_AUTH_TOKEN is set")
print(" - No extra spaces or quotes around values")
return False
print("\n2. Testing Twilio authentication...")
try:
client = Client(account_sid, auth_token)
# Try to fetch account info to verify auth
account = client.api.accounts(account_sid).fetch()
print(" ✓ Authentication successful!")
print(f" Account: {account.friendly_name}")
print(f" Status: {account.status}")
except Exception as e:
print(" ✗ Authentication failed!")
print(f" Error: {e}")
print("\n Troubleshooting:")
print(" 1. Go to https://console.twilio.com")
print(" 2. Navigate to Account > API keys & tokens")
print(" 3. Copy the Account SID and Auth Token")
print(" 4. Update your .env file with correct values")
print(" 5. Make sure there are NO quotes or spaces")
return False
# Test WhatsApp sender configuration
print("\n3. Testing WhatsApp sender configuration...")
if not from_number:
print(" ✗ TWILIO_WHATSAPP_NUMBER not set")
print(" Please set up WhatsApp sender at:")
print(" https://console.twilio.com/us1/develop/sms/senders/whatsapp-senders")
return False
if not from_number.startswith("+"):
print(f" ⚠ Warning: Phone number should start with '+': {from_number}")
print(f" ✓ WhatsApp sender configured: {from_number}")
# Test recipient
print("\n4. Testing recipient configuration...")
if not to_number:
print(" ✗ WHATSAPP_TO_NUMBER not set")
return False
if not to_number.startswith("+"):
print(f" ⚠ Warning: Phone number should start with '+': {to_number}")
print(f" ✓ Recipient configured: {to_number}")
print("\n" + "=" * 60)
print("✓ ALL CHECKS PASSED - Ready to send WhatsApp messages!")
print("=" * 60)
return True
if __name__ == "__main__":
success = test_credentials()
if success:
print("\nYou can now send test messages with:")
print(
" python -c 'from src.whatsapp_sender import WhatsAppSender; "
'w = WhatsAppSender(); print(w.send_alert("Test message"))\''
)
else:
print("\n❌ Please fix the issues above before sending WhatsApp messages")