urmi-ai/backend/app/agent_message_service.py
2026-10-03 03:06:55 +05:30

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.",
],
}