from sqlalchemy.orm import Session from datetime import datetime, timedelta from typing import Dict, Optional import re from app.models.contact import Contact, DNDList from app.models.template import Template from app.models.campaign import Campaign, CampaignRecipient, RecipientStatus from app.models.send_log import SendLog from app.providers import get_provider from app.core.config import settings import time import logging logger = logging.getLogger(__name__) class MessagingService: """Service for sending WhatsApp messages with rate limiting and compliance""" def __init__(self, db: Session): self.db = db self.provider = get_provider() def extract_variables(self, template_text: str) -> list: """Extract variable names from template text like {{first_name}}""" pattern = r'\{\{(\w+)\}\}' return re.findall(pattern, template_text) def build_variables(self, template_text: str, contact: Contact) -> Dict[str, str]: """Build variable dict from contact data""" variables = {} var_names = self.extract_variables(template_text) for var_name in var_names: if var_name == 'first_name': variables[var_name] = contact.first_name or '' elif var_name == 'last_name': variables[var_name] = contact.last_name or '' elif var_name == 'email': variables[var_name] = contact.email or '' elif var_name == 'phone': variables[var_name] = contact.phone_e164 or '' else: variables[var_name] = '' return variables def check_daily_limit(self, campaign_id: int, user_id: int) -> bool: """Check if daily limit has been reached for campaign""" today = datetime.utcnow().date() count = self.db.query(SendLog).filter( SendLog.campaign_id == campaign_id, SendLog.user_id == user_id, SendLog.created_at >= today ).count() return count < settings.DAILY_LIMIT_PER_CAMPAIGN def can_send_to_contact( self, contact: Contact, template: Template, user_id: int ) -> tuple[bool, Optional[str]]: """ Check if we can send to a contact based on compliance rules. Returns: (can_send, reason_if_not) """ # Check if opted in if not contact.opted_in: # If not opted in, MUST use approved WhatsApp template if not template.is_whatsapp_template: return False, "Contact not opted in; must use approved template" # Check DND list dnd_entry = self.db.query(DNDList).filter( DNDList.user_id == user_id, DNDList.phone_e164 == contact.phone_e164 ).first() if dnd_entry: return False, "Contact in DND list" # If using free-form message (not WhatsApp template), check conversation window if not template.is_whatsapp_template: if not contact.conversation_window_open: return False, "No conversation window; must use approved template" return True, None def send_single_message( self, recipient: CampaignRecipient, campaign: Campaign, template: Template, contact: Contact, user_id: int ) -> bool: """ Send a single message with full compliance checks. Returns: True if sent successfully, False otherwise """ # Check compliance can_send, reason = self.can_send_to_contact(contact, template, user_id) if not can_send: recipient.status = RecipientStatus.FAILED recipient.last_error = reason self.db.commit() return False # Build variables variables = self.build_variables(template.body_text, contact) # Prepare request request_payload = { "to": contact.phone_e164, "template_name": template.provider_template_name if template.is_whatsapp_template else None, "template_body": template.body_text, "variables": variables, "language": template.language } try: # Send via provider message_id = self.provider.send_message( to=contact.phone_e164, template_name=template.provider_template_name if template.is_whatsapp_template else None, template_body=template.body_text, variables=variables, language=template.language ) # Update recipient status recipient.status = RecipientStatus.SENT recipient.provider_message_id = message_id recipient.last_error = None # Log send send_log = SendLog( user_id=user_id, campaign_id=campaign.id, contact_id=contact.id, provider=self.provider.get_provider_name(), request_payload_json=request_payload, response_payload_json={"message_id": message_id}, status="sent" ) self.db.add(send_log) self.db.commit() logger.info(f"Sent message to {contact.phone_e164}, message_id: {message_id}") return True except Exception as e: error_msg = str(e) logger.error(f"Error sending to {contact.phone_e164}: {error_msg}") recipient.status = RecipientStatus.FAILED recipient.last_error = error_msg # Log failure send_log = SendLog( user_id=user_id, campaign_id=campaign.id, contact_id=contact.id, provider=self.provider.get_provider_name(), request_payload_json=request_payload, response_payload_json={"error": error_msg}, status="failed" ) self.db.add(send_log) self.db.commit() return False def send_campaign_batch( self, campaign_id: int, user_id: int, batch_size: Optional[int] = None ) -> dict: """ Send a batch of messages for a campaign with rate limiting. Returns: stats dict """ batch_size = batch_size or settings.BATCH_SIZE # Get campaign campaign = self.db.query(Campaign).filter(Campaign.id == campaign_id).first() if not campaign: raise ValueError("Campaign not found") # Get template template = self.db.query(Template).filter(Template.id == campaign.template_id).first() if not template: raise ValueError("Template not found") # Check daily limit if not self.check_daily_limit(campaign_id, user_id): return { "status": "limit_reached", "message": "Daily limit reached for this campaign" } # Get pending recipients recipients = self.db.query(CampaignRecipient).filter( CampaignRecipient.campaign_id == campaign_id, CampaignRecipient.status == RecipientStatus.PENDING ).limit(batch_size).all() if not recipients: return { "status": "done", "message": "No pending recipients" } sent_count = 0 failed_count = 0 for recipient in recipients: # Get contact contact = self.db.query(Contact).filter(Contact.id == recipient.contact_id).first() if not contact: recipient.status = RecipientStatus.FAILED recipient.last_error = "Contact not found" failed_count += 1 continue # Send message success = self.send_single_message(recipient, campaign, template, contact, user_id) if success: sent_count += 1 else: failed_count += 1 # Rate limiting: sleep between messages if sent_count < len(recipients): time.sleep(60.0 / settings.MAX_MESSAGES_PER_MINUTE) return { "status": "batch_sent", "sent": sent_count, "failed": failed_count, "remaining": self.db.query(CampaignRecipient).filter( CampaignRecipient.campaign_id == campaign_id, CampaignRecipient.status == RecipientStatus.PENDING ).count() }