252 lines
8.7 KiB
Python
252 lines
8.7 KiB
Python
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()
|
|
}
|