221 lines
7 KiB
Python
221 lines
7 KiB
Python
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
|