first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
221
backend/app/message_send_service.py
Normal file
221
backend/app/message_send_service.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue