WA template

This commit is contained in:
debu-2 2026-10-02 21:59:13 +05:30
parent 7a2e21ba61
commit d272ae0bd3
12 changed files with 1359 additions and 5 deletions

View file

@ -0,0 +1,277 @@
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_id: str | None = Field(default=None, max_length=120)
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_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_template_components(template: AgentMessageTemplate, customer_name: str, report_id: str) -> list[dict]:
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:
components.append(
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{"type": "text", "text": report_id}],
}
)
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_id = _validate_report_id(payload.report_id or f"run-{secrets.token_hex(3)}")
components = _build_template_components(template, payload.customer_name, report_id)
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_id": report_id,
},
wa_message_id=wa_message_id,
status="sent",
source=source,
)
db.add(log)
db.commit()
db.refresh(log)
report_url = f"{template.button_url_base.rstrip('/')}/{report_id}" if template.button_url_base else None
return {
"success": True,
"template": template.slug,
"to": recipient,
"customer_name": payload.customer_name.strip(),
"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_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_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_id": "Optional unique report id for the View Report button URL suffix",
},
"example": {
"template": template.slug,
"to": "919149154337",
"customer_name": "Suyash",
"report_id": "run-abc123",
},
"response_example": {
"success": True,
"template": template.slug,
"to": "919149154337",
"customer_name": "Suyash",
"report_id": "run-abc123",
"report_url": f"{template.button_url_base or ''}run-abc123",
"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.",
"If report_id is omitted, Urmi generates a unique id automatically.",
],
}

View file

@ -0,0 +1,63 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import AgentMessageTemplate
DEFAULT_AGENT_TEMPLATES = [
{
"slug": "agent_run_completion",
"name": "Agent Run Success",
"description": (
"Notify end users when an Urmi AI agent run completes, with a dynamic "
"View Report button linking to the completion report."
),
"header_text": "Agent Run Complete",
"body_preview": (
"Hello {{customer_name}}, your agent run is complete, you can access "
"the completion report at your URMI AI Portal."
),
"footer_text": "Thanks and regards, team - URMI AI",
"meta_template_name": "agent_run_completion",
"language": "en",
"category": "utility",
"parameters": [
{
"name": "customer_name",
"type": "string",
"required": True,
"description": "Recipient display name shown in the greeting",
"example": "Suyash",
},
{
"name": "report_id",
"type": "string",
"required": True,
"description": "Unique report identifier appended to the View Report button URL",
"example": "run-abc123",
},
],
"button_label": "View Report",
"button_url_base": "https://urmiai.com/reports/",
},
]
def seed_agent_message_templates(db: Session) -> None:
for item in DEFAULT_AGENT_TEMPLATES:
existing = db.scalar(select(AgentMessageTemplate).where(AgentMessageTemplate.slug == item["slug"]))
if existing:
existing.name = item["name"]
existing.description = item["description"]
existing.header_text = item["header_text"]
existing.body_preview = item["body_preview"]
existing.footer_text = item["footer_text"]
existing.meta_template_name = item["meta_template_name"]
existing.language = item["language"]
existing.category = item["category"]
existing.parameters = item["parameters"]
existing.button_label = item["button_label"]
existing.button_url_base = item["button_url_base"]
existing.is_active = True
else:
db.add(AgentMessageTemplate(**item))
db.commit()

View file

@ -26,11 +26,16 @@ from .routes_numbers import router as numbers_router
from .routes_urmiotp import router as urmiotp_router
from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_router
from .routes_agent_templates import router as agent_templates_router
from .routes_agent_messages import router as agent_messages_router
from .agent_templates_seed import seed_agent_message_templates
@asynccontextmanager
async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine)
with Session(engine) as db:
seed_agent_message_templates(db)
with engine.begin() as conn:
conn.execute(
text(
@ -88,6 +93,8 @@ app.include_router(numbers_router)
app.include_router(integrations_router)
app.include_router(voice_router)
app.include_router(whatsapp_router)
app.include_router(agent_templates_router)
app.include_router(agent_messages_router)
app.include_router(admin_router)

View file

@ -103,6 +103,12 @@ class UserAccount(Base):
sms_delivery_webhooks: Mapped[list["SmsDeliveryWebhook"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
agent_template_subscriptions: Mapped[list["AgentTemplateSubscription"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
agent_message_dispatches: Mapped[list["AgentMessageDispatchLog"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class PasswordCredential(Base):
@ -527,6 +533,82 @@ class WhatsAppMessage(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class AgentMessageTemplate(Base):
"""Platform WhatsApp agent message templates available for user subscription."""
__tablename__ = "agent_message_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[str] = mapped_column(String(120))
description: Mapped[str] = mapped_column(String(1000))
header_text: Mapped[str | None] = mapped_column(String(120), nullable=True)
body_preview: Mapped[str] = mapped_column(String(2000))
footer_text: Mapped[str | None] = mapped_column(String(120), nullable=True)
meta_template_name: Mapped[str] = mapped_column(String(80), index=True)
language: Mapped[str] = mapped_column(String(12), default="en")
category: Mapped[str] = mapped_column(String(32), default="utility")
parameters: Mapped[list] = mapped_column(JSON, default=list)
button_label: Mapped[str | None] = mapped_column(String(40), nullable=True)
button_url_base: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
subscriptions: Mapped[list["AgentTemplateSubscription"]] = relationship(
back_populates="template", cascade="all, delete-orphan"
)
class AgentTemplateSubscription(Base):
__tablename__ = "agent_template_subscriptions"
__table_args__ = (UniqueConstraint("user_id", "template_id", name="uq_agent_template_subscription"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
template_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("agent_message_templates.id", ondelete="CASCADE"), index=True
)
subscribed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[UserAccount] = relationship(back_populates="agent_template_subscriptions")
template: Mapped[AgentMessageTemplate] = relationship(back_populates="subscriptions")
class AgentTemplateTestLog(Base):
__tablename__ = "agent_template_test_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
template_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("agent_message_templates.id", ondelete="CASCADE"), index=True
)
recipient: Mapped[str] = mapped_column(String(20))
success: Mapped[bool] = mapped_column(Boolean, default=True)
tested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class AgentMessageDispatchLog(Base):
__tablename__ = "agent_message_dispatch_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
template_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("agent_message_templates.id", ondelete="CASCADE"), index=True
)
recipient: Mapped[str] = mapped_column(String(20), index=True)
parameters: Mapped[dict] = mapped_column(JSON, default=dict)
wa_message_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
status: Mapped[str] = mapped_column(String(24), default="sent", index=True)
source: Mapped[str] = mapped_column(String(16), default="api", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
user: Mapped[UserAccount] = relationship(back_populates="agent_message_dispatches")
template: Mapped[AgentMessageTemplate] = relationship()
class PlatformSetting(Base):
"""Runtime overrides for platform credentials (admin-editable)."""

View file

@ -0,0 +1,106 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from .agent_message_service import SendAgentMessagePayload, get_template_by_slug, send_agent_template_message
from .api_key_auth import current_api_user
from .database import get_db
from .models import AgentMessageTemplate, UserAccount
router = APIRouter(prefix="/api/v1/agent", tags=["agent-messages"])
def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict:
return {
"id": "send-agent-message",
"method": "POST",
"path": "/api/v1/agent/messages/send",
"title": "Send agent WhatsApp message",
"summary": (
"Send an approved agent completion template through Urmi. Urmi authenticates your API key "
"and delivers the WhatsApp message from the Urmi AI business number on your behalf."
),
"authentication": {
"type": "api_key",
"header": "Authorization",
"format": "Bearer urmi_live_<key>",
"alternate_header": "X-Api-Key",
},
"request_body": {
"template": {
"type": "string",
"required": True,
"description": "Subscribed agent template slug",
"example": template.slug,
},
"to": {
"type": "string",
"required": True,
"description": "Recipient WhatsApp number with country code",
"example": "919149154337",
},
"customer_name": {
"type": "string",
"required": True,
"description": "Recipient name shown in the template body",
"example": "Suyash",
},
"report_id": {
"type": "string",
"required": False,
"description": "Report id suffix for the dynamic View Report button URL",
"example": "run-abc123",
},
},
"responses": {
"200": {
"description": "WhatsApp template accepted by Meta",
"example": {
"success": True,
"template": template.slug,
"to": "919149154337",
"customer_name": "Suyash",
"report_id": "run-abc123",
"report_url": f"{template.button_url_base or ''}run-abc123",
"wa_message_id": "wamid.HBg...",
"dispatch_id": "00000000-0000-0000-0000-000000000001",
},
},
"401": {"description": "Missing or invalid API key"},
"403": {"description": "Template not subscribed"},
"429": {"description": "Console test limit (console only)"},
"502": {"description": "Meta WhatsApp API error"},
},
"notes": [
"Subscribe to agent templates in Console → Agent → UrmiAI before calling this API.",
"Urmi acts as the WhatsApp tech provider and sends from the Urmi AI WABA.",
],
"available_templates": [
{
"slug": template.slug,
"name": template.name,
"meta_template_name": template.meta_template_name,
}
],
}
@router.get("/messages/docs/send")
def send_agent_message_docs(db: Session = Depends(get_db)) -> dict:
template = db.scalar(
select(AgentMessageTemplate)
.where(AgentMessageTemplate.is_active.is_(True))
.order_by(AgentMessageTemplate.created_at.asc())
)
if not template:
return {"id": "send-agent-message", "method": "POST", "path": "/api/v1/agent/messages/send"}
return _explorer_doc_for_template(template)
@router.post("/messages/send")
async def send_agent_message(
payload: SendAgentMessagePayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_api_user),
) -> dict:
return await send_agent_template_message(db, user, payload, source="api")

View file

@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from .agent_message_service import (
build_agent_message_docs,
can_run_console_test,
get_template_by_slug,
run_console_template_test,
serialize_agent_template,
)
from .auth import current_user
from .database import get_db
from .models import AgentMessageTemplate, AgentTemplateSubscription, UserAccount
router = APIRouter(prefix="/api/v1/agent-templates", tags=["agent-templates"])
class AgentTemplateTestRequest(BaseModel):
to: str = Field(min_length=10, max_length=20)
customer_name: str = Field(min_length=1, max_length=120)
report_id: str | None = Field(default=None, max_length=120)
def _subscription_map(db: Session, user: UserAccount) -> set[str]:
rows = db.scalars(
select(AgentTemplateSubscription.template_id).where(AgentTemplateSubscription.user_id == user.id)
).all()
return set(rows)
@router.get("")
def list_agent_templates(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
subscribed_ids = _subscription_map(db, user)
templates = db.scalars(
select(AgentMessageTemplate)
.where(AgentMessageTemplate.is_active.is_(True))
.order_by(AgentMessageTemplate.created_at.asc())
).all()
return [
serialize_agent_template(template, subscribed=template.id in subscribed_ids)
for template in templates
]
@router.post("/{slug}/subscribe")
def subscribe_agent_template(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
template = get_template_by_slug(db, slug)
existing = db.scalar(
select(AgentTemplateSubscription).where(
AgentTemplateSubscription.user_id == user.id,
AgentTemplateSubscription.template_id == template.id,
)
)
if existing:
return serialize_agent_template(template, subscribed=True)
db.add(AgentTemplateSubscription(user_id=user.id, template_id=template.id))
db.commit()
return serialize_agent_template(template, subscribed=True)
@router.delete("/{slug}/subscribe")
def unsubscribe_agent_template(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
template = get_template_by_slug(db, slug)
subscription = db.scalar(
select(AgentTemplateSubscription).where(
AgentTemplateSubscription.user_id == user.id,
AgentTemplateSubscription.template_id == template.id,
)
)
if subscription:
db.delete(subscription)
db.commit()
return serialize_agent_template(template, subscribed=False)
@router.get("/{slug}/docs")
def agent_template_docs(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
template = get_template_by_slug(db, slug)
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 template to view API documentation",
)
return build_agent_message_docs(template)
@router.get("/{slug}/test-status")
def agent_template_test_status(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
template = get_template_by_slug(db, slug)
require = db.scalar(
select(AgentTemplateSubscription).where(
AgentTemplateSubscription.user_id == user.id,
AgentTemplateSubscription.template_id == template.id,
)
)
if not require:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Subscribe first")
status_payload = can_run_console_test(db, user, template)
return {"template": template.slug, **status_payload}
@router.post("/{slug}/test")
async def test_agent_template(
slug: str,
payload: AgentTemplateTestRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
template = get_template_by_slug(db, slug)
return await run_console_template_test(
db,
user,
template,
to=payload.to,
customer_name=payload.customer_name,
report_id=payload.report_id,
)