sendio/backend/app/services/messaging.py
2026-01-13 05:17:57 +02:00

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()
}