diff --git a/requirements.txt b/requirements.txt index 42458e8..02a9b84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,6 @@ simple-websocket==1.1.0 sniffio==1.3.1 sqlalchemy==2.0.23 starlette==0.27.0 -twilio==8.10.0 typing-extensions==4.14.1 uritemplate==4.2.0 urllib3==2.5.0 diff --git a/src/alerts.py b/src/alerts.py index 2464788..4fe9cce 100644 --- a/src/alerts.py +++ b/src/alerts.py @@ -11,7 +11,7 @@ from database import ( get_last_incoming_outgoing, get_thread_messages, ) -from whatsapp_sender import WhatsAppSender +from slack_sender import SlackSender @dataclass @@ -68,7 +68,7 @@ def _format_alert( 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 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") - to_number = cfg.get("whatsapp_to") or None - logging.info(f"WhatsApp target: {to_number}") - sender = WhatsAppSender(to_number=to_number) + webhook_url = cfg.get("slack_webhook_url") or None + logging.info( + f"Slack webhook URL: {'ā Configured' if webhook_url else 'ā Not configured'}" + ) + sender = SlackSender(webhook_url=webhook_url) alerted = [] 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") continue - # Compose and send WhatsApp + # Compose and send Slack 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)) - logging.info(f" Thread {t.id}: WhatsApp response: {res}") + logging.info(f" Thread {t.id}: Slack response: {res}") if res.get("status") == "success": t.last_alert_level_sent = target_level diff --git a/src/app.py b/src/app.py index 1980ab8..7137614 100644 --- a/src/app.py +++ b/src/app.py @@ -226,7 +226,7 @@ def load_config() -> dict: "agency_domains": [], "zoho_email": "", "zoho_app_password": "", - "whatsapp_to": "", + "slack_webhook_url": "", "auto_process": False, "auto_process_interval": 30, # Sync status @@ -309,8 +309,10 @@ async def config_save(request: Request): cfg["zoho_app_password"] = ( form.get("zoho_app_password") or cfg.get("zoho_app_password", "") ).strip() - # WhatsApp destination - cfg["whatsapp_to"] = (form.get("whatsapp_to") or cfg.get("whatsapp_to", "")).strip() + # Slack webhook URL + cfg["slack_webhook_url"] = ( + form.get("slack_webhook_url") or cfg.get("slack_webhook_url", "") + ).strip() # Time frames: collect indexed rows frames: list[dict] = [] diff --git a/src/slack_sender.py b/src/slack_sender.py new file mode 100644 index 0000000..6ffb23c --- /dev/null +++ b/src/slack_sender.py @@ -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}") diff --git a/src/whatsapp_sender.py b/src/whatsapp_sender.py deleted file mode 100644 index 60d0c2d..0000000 --- a/src/whatsapp_sender.py +++ /dev/null @@ -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}") diff --git a/templates/config.html b/templates/config.html index 0019d62..7064893 100644 --- a/templates/config.html +++ b/templates/config.html @@ -47,12 +47,15 @@ -