327 lines
12 KiB
Python
327 lines
12 KiB
Python
import re
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .models import (
|
|
AgentMessageDispatchLog,
|
|
AgentMessageTemplate,
|
|
AgentTemplateSubscription,
|
|
AgentTemplateTestLog,
|
|
UserAccount,
|
|
)
|
|
from .whatsapp_config import resolved_whatsapp_phone_number_id
|
|
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
|
from .whatsapp_messages import extract_wa_message_id, normalize_wa_number
|
|
|
|
class SendAgentMessagePayload(BaseModel):
|
|
template: str = Field(min_length=2, max_length=64)
|
|
to: str = Field(min_length=10, max_length=20)
|
|
customer_name: str = Field(min_length=1, max_length=120)
|
|
report_group: str = Field(min_length=3, max_length=50)
|
|
report_id: str | None = Field(default=None, max_length=120)
|
|
report_url: str | None = Field(default=None, max_length=500)
|
|
|
|
|
|
def serialize_agent_template(template: AgentMessageTemplate, *, subscribed: bool = False) -> dict:
|
|
return {
|
|
"id": str(template.id),
|
|
"slug": template.slug,
|
|
"name": template.name,
|
|
"description": template.description,
|
|
"header_text": template.header_text,
|
|
"body_preview": template.body_preview,
|
|
"footer_text": template.footer_text,
|
|
"meta_template_name": template.meta_template_name,
|
|
"language": template.language,
|
|
"category": template.category,
|
|
"parameters": template.parameters,
|
|
"button_label": template.button_label,
|
|
"button_url_base": template.button_url_base,
|
|
"is_active": template.is_active,
|
|
"subscribed": subscribed,
|
|
}
|
|
|
|
|
|
def get_template_by_slug(db: Session, slug: str) -> AgentMessageTemplate:
|
|
template = db.scalar(
|
|
select(AgentMessageTemplate).where(
|
|
AgentMessageTemplate.slug == slug,
|
|
AgentMessageTemplate.is_active.is_(True),
|
|
)
|
|
)
|
|
if not template:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent template not found")
|
|
return template
|
|
|
|
|
|
def require_subscription(db: Session, user: UserAccount, template: AgentMessageTemplate) -> AgentTemplateSubscription:
|
|
subscription = db.scalar(
|
|
select(AgentTemplateSubscription).where(
|
|
AgentTemplateSubscription.user_id == user.id,
|
|
AgentTemplateSubscription.template_id == template.id,
|
|
)
|
|
)
|
|
if not subscription:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Subscribe to this agent template before sending messages",
|
|
)
|
|
return subscription
|
|
|
|
|
|
def _validate_report_group(report_group: str) -> str:
|
|
cleaned = report_group.strip().lower()
|
|
if not cleaned or len(cleaned) > 50:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid report_group")
|
|
if not re.fullmatch(r"[a-z][a-z0-9-]{1,48}[a-z0-9]", cleaned) or "--" in cleaned:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="report_group must be 3-50 chars: lowercase letters, numbers, hyphens",
|
|
)
|
|
return cleaned
|
|
|
|
|
|
def _validate_report_id(report_id: str) -> str:
|
|
cleaned = report_id.strip()
|
|
if not cleaned or len(cleaned) > 120:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid report_id")
|
|
if not re.fullmatch(r"[A-Za-z0-9._-]+", cleaned):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="report_id may only contain letters, numbers, dots, underscores, and hyphens",
|
|
)
|
|
return cleaned
|
|
|
|
|
|
def _build_report_url(
|
|
template: AgentMessageTemplate,
|
|
report_group: str,
|
|
report_id: str,
|
|
override: str | None = None,
|
|
) -> str:
|
|
if override:
|
|
return override.strip()
|
|
base = (template.button_url_base or "https://urmiai.com/reports/").rstrip("/")
|
|
return f"{base}/{report_group}/{report_id}"
|
|
|
|
|
|
def _build_template_components(
|
|
template: AgentMessageTemplate,
|
|
customer_name: str,
|
|
report_url: str,
|
|
) -> list[dict]:
|
|
"""Build Meta template components for agent_run_completion.
|
|
|
|
Meta template uses parameter_format=NAMED for the body (customer_name) and a
|
|
positional URL-button suffix for the View Report link.
|
|
"""
|
|
components: list[dict] = [
|
|
{
|
|
"type": "body",
|
|
"parameters": [
|
|
{
|
|
"type": "text",
|
|
"parameter_name": "customer_name",
|
|
"text": customer_name.strip(),
|
|
}
|
|
],
|
|
}
|
|
]
|
|
if template.button_label and template.button_url_base:
|
|
base = template.button_url_base.rstrip("/")
|
|
button_param = report_url[len(base) + 1 :] if report_url.startswith(f"{base}/") else report_url
|
|
components.append(
|
|
{
|
|
"type": "button",
|
|
"sub_type": "url",
|
|
"index": "0",
|
|
"parameters": [{"type": "text", "text": button_param}],
|
|
}
|
|
)
|
|
return components
|
|
|
|
|
|
def _whatsapp_client(db: Session) -> WhatsAppGraphClient:
|
|
return WhatsAppGraphClient(phone_number_id=resolved_whatsapp_phone_number_id(db))
|
|
|
|
|
|
async def send_agent_template_message(
|
|
db: Session,
|
|
user: UserAccount,
|
|
payload: SendAgentMessagePayload,
|
|
*,
|
|
source: str = "api",
|
|
) -> dict:
|
|
template = get_template_by_slug(db, payload.template)
|
|
require_subscription(db, user, template)
|
|
|
|
try:
|
|
recipient = normalize_wa_number(payload.to)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
|
|
report_group = _validate_report_group(payload.report_group)
|
|
report_id = _validate_report_id(payload.report_id or f"run-{secrets.token_hex(3)}")
|
|
report_url = _build_report_url(template, report_group, report_id, payload.report_url)
|
|
components = _build_template_components(template, payload.customer_name, report_url)
|
|
|
|
client = _whatsapp_client(db)
|
|
try:
|
|
result = await client.send_template(
|
|
recipient,
|
|
template.meta_template_name,
|
|
language_code=template.language,
|
|
components=components,
|
|
)
|
|
except WhatsAppGraphError as exc:
|
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
|
|
|
wa_message_id = extract_wa_message_id(result)
|
|
log = AgentMessageDispatchLog(
|
|
user_id=user.id,
|
|
template_id=template.id,
|
|
recipient=recipient,
|
|
parameters={
|
|
"customer_name": payload.customer_name.strip(),
|
|
"report_group": report_group,
|
|
"report_id": report_id,
|
|
"report_url": report_url,
|
|
},
|
|
wa_message_id=wa_message_id,
|
|
status="sent",
|
|
source=source,
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
db.refresh(log)
|
|
|
|
return {
|
|
"success": True,
|
|
"template": template.slug,
|
|
"to": recipient,
|
|
"customer_name": payload.customer_name.strip(),
|
|
"report_group": report_group,
|
|
"report_id": report_id,
|
|
"report_url": report_url,
|
|
"wa_message_id": wa_message_id,
|
|
"dispatch_id": str(log.id),
|
|
}
|
|
|
|
|
|
def can_run_console_test(db: Session, user: UserAccount, template: AgentMessageTemplate) -> dict:
|
|
since = datetime.now(timezone.utc) - timedelta(days=1)
|
|
recent = db.scalar(
|
|
select(AgentTemplateTestLog)
|
|
.where(
|
|
AgentTemplateTestLog.user_id == user.id,
|
|
AgentTemplateTestLog.template_id == template.id,
|
|
AgentTemplateTestLog.tested_at >= since,
|
|
AgentTemplateTestLog.success.is_(True),
|
|
)
|
|
.order_by(AgentTemplateTestLog.tested_at.desc())
|
|
)
|
|
if recent:
|
|
retry_after = recent.tested_at + timedelta(days=1)
|
|
return {
|
|
"allowed": False,
|
|
"retry_after": retry_after,
|
|
"last_tested_at": recent.tested_at,
|
|
}
|
|
return {"allowed": True, "retry_after": None, "last_tested_at": None}
|
|
|
|
|
|
async def run_console_template_test(
|
|
db: Session,
|
|
user: UserAccount,
|
|
template: AgentMessageTemplate,
|
|
*,
|
|
to: str,
|
|
customer_name: str,
|
|
report_group: str,
|
|
report_id: str | None = None,
|
|
) -> dict:
|
|
require_subscription(db, user, template)
|
|
test_status = can_run_console_test(db, user, template)
|
|
if not test_status["allowed"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Console test limit reached. You can test this template once per day.",
|
|
)
|
|
|
|
payload = SendAgentMessagePayload(
|
|
template=template.slug,
|
|
to=to,
|
|
customer_name=customer_name,
|
|
report_group=report_group,
|
|
report_id=report_id,
|
|
)
|
|
result = await send_agent_template_message(db, user, payload, source="console_test")
|
|
db.add(
|
|
AgentTemplateTestLog(
|
|
user_id=user.id,
|
|
template_id=template.id,
|
|
recipient=result["to"],
|
|
success=True,
|
|
)
|
|
)
|
|
db.commit()
|
|
return result
|
|
|
|
|
|
def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
|
|
return {
|
|
"template": serialize_agent_template(template),
|
|
"api": {
|
|
"method": "POST",
|
|
"path": "/api/v1/agent/messages/send",
|
|
"summary": (
|
|
"Send an approved agent WhatsApp template via Urmi. Urmi authenticates your API key "
|
|
"and relays the message through the Urmi AI WhatsApp Business account."
|
|
),
|
|
"authentication": {
|
|
"type": "api_key",
|
|
"header": "Authorization",
|
|
"format": "Bearer urmi_live_<key>",
|
|
"alternate_header": "X-Api-Key",
|
|
},
|
|
"parameters": {
|
|
"template": f"Template slug. Use `{template.slug}`.",
|
|
"to": "Recipient WhatsApp number with country code, e.g. 919149154337",
|
|
"customer_name": "Recipient name inserted into the template body",
|
|
"report_group": "Urmiport report group slug used in the hosted report URL",
|
|
"report_id": "Optional report postfix; auto-generated when omitted",
|
|
"report_url": "Optional full report URL override for the WhatsApp button (auto-built when omitted)",
|
|
},
|
|
"example": {
|
|
"template": template.slug,
|
|
"to": "919149154337",
|
|
"customer_name": "Suyash",
|
|
"report_group": "desi-clans-test",
|
|
"report_id": "2026-10-03",
|
|
"report_url": "https://urmiai.com/reports/desi-clans-test/2026-10-03",
|
|
},
|
|
"response_example": {
|
|
"success": True,
|
|
"template": template.slug,
|
|
"to": "919149154337",
|
|
"customer_name": "Suyash",
|
|
"report_group": "desi-clans-test",
|
|
"report_id": "2026-10-03",
|
|
"report_url": "https://urmiai.com/reports/desi-clans-test/2026-10-03",
|
|
"wa_message_id": "wamid.HBg...",
|
|
"dispatch_id": "00000000-0000-0000-0000-000000000001",
|
|
},
|
|
},
|
|
"notes": [
|
|
"Subscribe to the template in the Urmi console before calling this API.",
|
|
"Messages are sent from the Urmi AI WhatsApp business number on your behalf.",
|
|
"Create the report group in Console → Agent → Urmiport before sending.",
|
|
"If report_id is omitted, Urmi generates a unique postfix automatically.",
|
|
"Body uses named parameter customer_name; button URL suffix is report_group/report_id.",
|
|
],
|
|
}
|