import re from dataclasses import dataclass from uuid import UUID from sqlalchemy import select from sqlalchemy.orm import Session, joinedload from .log_service import record_sms_logs from .models import SenderConfig, TemplateConfig, UserAccount from .template_message_validator import message_matches_template from .ultronsms_client import SendResult, UltronsmsClient PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$") class SendMessageError(Exception): def __init__(self, detail: str, status_code: int = 422) -> None: super().__init__(detail) self.detail = detail self.status_code = status_code @dataclass class RecipientSendResult: to: str success: bool job_id: str | None = None message_id: str | None = None error: str | None = None def normalize_recipients(raw_numbers: list[str]) -> list[str]: recipients: list[str] = [] seen: set[str] = set() for raw in raw_numbers: digits = "".join(character for character in raw if character.isdigit()) if len(digits) == 10: digits = f"91{digits}" if not PHONE_DIGITS_RE.match(digits): raise SendMessageError(f"Invalid recipient number: {raw.strip() or 'blank'}") if digits not in seen: seen.add(digits) recipients.append(digits) if not recipients: raise SendMessageError("At least one recipient number is required") if len(recipients) > 100: raise SendMessageError("A maximum of 100 recipient numbers is allowed per request") return recipients def parse_recipient_input(numbers: str | list[str]) -> list[str]: if isinstance(numbers, list): items = numbers else: items = [part.strip() for part in numbers.split(",")] return normalize_recipients([item for item in items if item.strip()]) def _load_sender_and_template( db: Session, user: UserAccount, *, sender_config_id: UUID | None = None, sender_id: str | None = None, template_id: UUID, ) -> tuple[SenderConfig, TemplateConfig]: sender_query = select(SenderConfig).where( SenderConfig.user_id == user.id, SenderConfig.status == "approved", ) if sender_config_id is not None: sender_query = sender_query.where(SenderConfig.id == sender_config_id) elif sender_id is not None: sender_query = sender_query.where(SenderConfig.sender_id == sender_id.strip().upper()) else: raise SendMessageError("Sender ID is required") sender = db.scalar(sender_query) if not sender: raise SendMessageError("Approved sender ID not found", status_code=404) template = db.scalar( select(TemplateConfig) .options(joinedload(TemplateConfig.sender_config)) .where( TemplateConfig.id == template_id, TemplateConfig.user_id == user.id, TemplateConfig.status == "approved", ) ) if not template: raise SendMessageError("Approved template not found", status_code=404) if template.sender_config_id != sender.id: raise SendMessageError("Template is not linked to the selected sender ID") return sender, template def validate_message_for_template(template_text: str, message: str) -> str: normalized = message.strip() if not normalized: raise SendMessageError("Message is required") if len(normalized) > 1000: raise SendMessageError("Message must be 1000 characters or fewer") if not message_matches_template(template_text, normalized): raise SendMessageError("Message does not match the approved DLT template") return normalized def _dispatch_sms( *, recipient: str, message: str, sender: SenderConfig, template: TemplateConfig, client: UltronsmsClient, ) -> RecipientSendResult: try: result: SendResult = client.send_template_sms( mobile=recipient, message=message, sender_id=sender.sender_id, pe_id=sender.dlt_entity_id, dlt_template_id=template.dlt_template_id, sender_type=sender.sender_type, ) except RuntimeError as exc: return RecipientSendResult(to=recipient, success=False, error=str(exc)) except Exception: return RecipientSendResult(to=recipient, success=False, error="SMS gateway request failed") if not result.success: error_message = result.raw.get("ErrorMessage") or "SMS gateway rejected the request" return RecipientSendResult( to=recipient, success=False, error=error_message, ) return RecipientSendResult( to=result.number or recipient, success=True, job_id=result.job_id, message_id=result.message_id, ) def send_template_messages( db: Session, user: UserAccount, *, template_id: UUID, message: str, recipients: list[str], sender_config_id: UUID | None = None, sender_id: str | None = None, campaign_name: str | None = None, ) -> dict: if campaign_name is not None and not campaign_name.strip(): raise SendMessageError("Campaign name cannot be blank") if campaign_name and len(campaign_name.strip()) > 120: raise SendMessageError("Campaign name must be 120 characters or fewer") sender, template = _load_sender_and_template( db, user, sender_config_id=sender_config_id, sender_id=sender_id, template_id=template_id, ) normalized_message = validate_message_for_template(template.template_text, message) normalized_recipients = normalize_recipients(recipients) client = UltronsmsClient() results: list[RecipientSendResult] = [] for recipient in normalized_recipients: results.append( _dispatch_sms( recipient=recipient, message=normalized_message, sender=sender, template=template, client=client, ) ) record_sms_logs( db, user, sender_id=sender.sender_id, template_name=template.template_name, template_id=template.id, results=results, ) sent = sum(1 for item in results if item.success) failed = len(results) - sent payload = { "success": failed == 0, "campaign_name": campaign_name.strip() if campaign_name else None, "sent": sent, "failed": failed, "sender_id": sender.sender_id, "template_id": str(template.id), "template_name": template.template_name, "dlt_template_id": template.dlt_template_id, "results": [ { "to": item.to, "success": item.success, "job_id": item.job_id, "message_id": item.message_id, "error": item.error, } for item in results ], } if sent == 0: raise SendMessageError( payload["results"][0]["error"] if payload["results"] else "SMS send failed", status_code=422, ) return payload